Compare commits

..
Author SHA1 Message Date
skylartaylor 0ec463568b LRC-LIB-CATALOG-IMPORT: report offline records and keep import results readable
Avoid repeated missing-original probes and retain staged photo copies only for virtual-copy masters. Preserve the destination and warnings in a result dialog with Show in Library, and reset every filter constraint through the existing commands.

Generated with Codex.
2026-10-13 04:53:04 +02:00
192 changed files with 1408 additions and 9874 deletions

No files matched your search

Generated
+2 -2
View File
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.4"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
dependencies = [
"rayon",
]
+1 -4
View File
@@ -314,7 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
@@ -409,9 +409,6 @@ nix build github:storytold/lightcraft # → ./result/bin/{light
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
```
On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and
renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves.
In a flake configuration (NixOS, home-manager, nix-darwin):
```nix
+2 -5
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
@@ -114,8 +114,6 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
@@ -163,7 +161,6 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
+5 -9
View File
@@ -303,14 +303,10 @@ fn mcp(args: &[String]) -> Result<(), String> {
while i < args.len() {
match args[i].as_str() {
"--connect" => {
// Optional address argument. A bare port (all digits) is taken as a port on the
// loopback default — matches the help's `--connect [ADDR]` hint alongside the
// `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a
// command id like `ui.inspect`) leaves the address at its default so the
// command is still consumed next. (#1178)
match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) {
// Optional address argument.
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
Some(a) => {
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
connect = Some(a.clone());
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
@@ -529,9 +525,9 @@ fn run(args: &[String]) -> Result<(), String> {
i += 1;
break;
}
"--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) {
"--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) {
Some(a) => {
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
connect = Some(a.clone());
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
-22
View File
@@ -476,28 +476,6 @@ fn connect_failure_hint_names_the_attempted_port() {
assert!(!err.contains("--control 7980"), "{err}");
}
/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving
/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`".
/// A bare port is now taken as `127.0.0.1:<port>`; the same address used with `:port` still
/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through.
#[test]
fn connect_bare_port_is_treated_as_loopback_port() {
// Bare port: the failure hint names that port.
let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}");
assert!(!err.contains("--control 7980"), "{err}");
// `<host>:<port>` still works (issue #167 case).
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
// `--connect=<bare-port>` already worked (the `a.starts_with("--connect=")` arm); spot-check
// the hint matches its port too.
let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
}
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
#[test]
-1
View File
@@ -228,7 +228,6 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
reveal: None,
log_file: None,
open_with: None,
open_all_with: None,
check_editor: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
+1 -1
View File
@@ -64,7 +64,7 @@ impl WireJob {
/// `view`: the slot name, for jobs that keep a stage cache.
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge,
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
SourceRef::Smart { .. } => 2560,
};
-125
View File
@@ -1,125 +0,0 @@
//! The ICC profile the desktop assigns to the display (Linux, through colord).
//!
//! GNOME, KDE and most other desktops keep each monitor's profile in colord
//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a
//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile
//! is chosen in Settings ▸ Display, previews are shown through that profile
//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never
//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB.
//!
//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a
//! window is on), so the profile is used only when every display that has one has the same: a
//! mixed set stays sRGB rather than guess.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
/// How often colord is asked again (a profile change in the desktop's settings shows within this).
const POLL: Duration = Duration::from_secs(3);
/// A wait before trying again when colord isn't there (not installed, no system bus).
const RETRY: Duration = Duration::from_secs(30);
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
const MAX_PROFILE: u64 = 16 << 20;
const SERVICE: &str = "org.freedesktop.ColorManager";
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
const PROFILE: &str = "org.freedesktop.ColorManager.Profile";
/// Start the background reader and return the service the UI polls (`Services::screen_profile`).
pub fn service() -> Box<dyn FnMut() -> Option<Vec<u8>>> {
let shared: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
let writer = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || {
loop {
let wait = match connect() {
Some(conn) => {
let icc = read(&conn);
*writer.lock().unwrap_or_else(PoisonError::into_inner) = icc;
POLL
}
None => RETRY,
};
std::thread::sleep(wait);
// the app is gone: nobody reads the profile any more
if Arc::strong_count(&writer) == 1 {
return;
}
}
});
if let Err(e) = spawned {
log::warn!("screen profile: no colord reader: {e}");
}
Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone())
}
fn connect() -> Option<zbus::blocking::Connection> {
zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok()
}
/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays).
fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?;
let devices: Vec<zbus::zvariant::OwnedObjectPath> = manager.call("GetDevicesByKind", &("display",)).ok()?;
let mut files = Vec::new();
for device in devices.iter().take(64) {
let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue };
let profiles: Vec<zbus::zvariant::OwnedObjectPath> = proxy.get_property("Profiles").unwrap_or_default();
// colord lists a device's profiles default first
let Some(first) = profiles.first() else { continue };
let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue };
if let Ok(file) = profile.get_property::<String>("Filename") {
files.push(file);
}
}
let file = pick(&files)?;
load(std::path::Path::new(file))
}
/// The profile to use for these displays' profile files: the one they share, `None` with none or
/// with different ones (empty names are ignored).
fn pick(files: &[String]) -> Option<&str> {
let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty());
let first = named.next()?;
named.all(|f| f == first).then_some(first)
}
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
use std::io::Read as _;
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shared_profile_is_used_and_a_mixed_set_is_not() {
let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::<Vec<_>>();
assert_eq!(pick(&s(&[])), None);
assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
assert_eq!(pick(&s(&[""])), None);
}
#[test]
fn profile_files_are_read_with_a_size_cap() {
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let small = dir.join("small.icc");
std::fs::write(&small, b"acsp").unwrap();
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
let empty = dir.join("empty.icc");
std::fs::write(&empty, b"").unwrap();
assert_eq!(load(&empty), None);
assert_eq!(load(&dir.join("missing.icc")), None);
let big = dir.join("big.icc");
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
assert_eq!(load(&big), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -95
View File
@@ -55,65 +55,16 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
}
}
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no
/// frame callbacks, so the app's loop is paused: issue #718).
const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
/// The reply for a request given up because the app drew no frames.
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
ctx.request_repaint();
Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT))
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
/// and the reply says why; a request the app is running gets the whole `timeout`.
fn await_reply(
rrx: &Receiver<Value>,
state: &std::sync::atomic::AtomicU8,
frames: impl Fn() -> u64,
wake: impl Fn(),
no_frames: Duration,
timeout: Duration,
) -> Value {
let start = std::time::Instant::now();
let slice = (no_frames / 10).max(Duration::from_millis(1));
let (mut last, mut since) = (frames(), std::time::Instant::now());
loop {
match rrx.recv_timeout(slice) {
Ok(v) => return v,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
let now = frames();
if now != last {
(last, since) = (now, std::time::Instant::now());
}
let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN;
if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) {
return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR});
}
if start.elapsed() >= timeout {
// not run yet: make sure it never is; already running: it may still finish
ControlRequest::abandon(state);
return json!({"ok": false, "error": "timeout"});
}
wake();
}
}
/// One parsed line.
enum Line {
Request {
@@ -209,51 +160,6 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let t0 = std::time::Instant::now();
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
assert!(t0.elapsed() < Duration::from_secs(2));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
assert!(!req.take(), "the app does not run it once it shows up");
}
/// A request the app is running is not given up because frames stopped (a long command
/// blocks the frame loop), and its reply arrives.
#[test]
fn a_request_being_run_waits_for_its_reply() {
let (req, rrx) = ControlRequest::new("export.run", json!({}));
assert!(req.take());
let reply = req.reply.clone();
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
let _ = reply.send(json!({"ok": true, "result": 1}));
});
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
/// Frames still coming: the request just waits, then is answered.
#[test]
fn a_drawing_app_is_waited_for() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let n = std::sync::atomic::AtomicU64::new(0);
let reply = req.reply.clone();
let state = Arc::clone(&req.state);
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let _ = reply.send(json!({"ok": true, "result": 2}));
});
let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
+2 -56
View File
@@ -61,39 +61,6 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
/// still starting when it is started once per file. The system default opens them one by one
/// (Explorer and `xdg-open` take one file).
pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> {
match all_command(launcher(app)?, paths) {
Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()),
None => paths.iter().try_for_each(|p| open_with(p, app)),
}
}
/// The one command that opens all of `paths` in `launcher` (none for the system default).
fn all_command(launcher: Launcher, paths: &[String]) -> Option<Command> {
match launcher {
Launcher::Default => None,
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app]).args(paths);
Some(c)
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.args(paths);
Some(c)
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@");
Some(c)
}
}
}
/// Windows: open `path` in its default application through Explorer, given the path as its one
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
/// syntax (running what follows an `&` as a command).
@@ -205,10 +172,8 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
program.starts_with('/').then(|| PathBuf::from(program))
// a bare name (`photocraft %F`) was already looked for on the PATH
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
}
#[cfg(test)]
@@ -226,25 +191,6 @@ mod tests {
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
/// Open Original with several photos: one start of the editor with every file, which a
/// single-window editor (DxO PhotoLab) needs to get them all.
#[test]
fn several_files_go_to_one_start_of_the_editor() {
let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()];
let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap();
assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe");
assert_eq!(args(&c), paths);
let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap();
assert_eq!(
(c.get_program().to_string_lossy().into_owned(), args(&c)),
("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()])
);
let c = all_command(Launcher::Flatpak, &paths).unwrap();
assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]);
assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one");
}
#[test]
fn windows_lookup_prefers_the_recorded_install_folder() {
let env = |k: &str| match k {
-174
View File
@@ -1,174 +0,0 @@
//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR
//! white, and with which surface format. Pure decisions, unit-tested here; the window always
//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong.
//!
//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer,
//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol),
//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal
//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the
//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the
//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same
//! decision turns HDR on.
use eframe::wgpu::TextureFormat;
/// Windowing platform, as far as HDR output is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
MacOs,
Windows,
Wayland,
X11,
Web,
Other,
}
impl Platform {
/// The platform this build runs on (`wayland` from the session environment on Linux / BSD).
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_arch = "wasm32") {
Self::Web
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Self::Wayland
} else if std::env::var_os("DISPLAY").is_some() {
Self::X11
} else {
Self::Other
}
}
/// Whether the platform has an HDR compositing path wgpu can reach at all.
fn has_hdr_path(self) -> bool {
matches!(self, Self::MacOs | Self::Windows | Self::Wayland)
}
}
/// How HDR values reach the display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrEncoding {
/// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB,
/// Wayland extended linear).
ExtendedLinear,
/// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ).
Pq10,
}
/// The display output decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayOutput {
Sdr { reason: &'static str },
Hdr { format: TextureFormat, encoding: HdrEncoding },
}
impl DisplayOutput {
#[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR
pub fn is_hdr(self) -> bool {
matches!(self, Self::Hdr { .. })
}
}
/// The encoding a surface format carries on `platform`, if it is an HDR one.
fn encoding_of(format: TextureFormat, platform: Platform) -> Option<HdrEncoding> {
match format {
TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear),
// macOS takes HDR through EDR floats only
TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10),
_ => None,
}
}
/// The best HDR surface format among those a surface offers (`available`), for `platform`:
/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only.
/// (For configuring the surface once eframe lets the app pick its format.)
#[cfg_attr(not(test), allow(dead_code))]
pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option<TextureFormat> {
if !platform.has_hdr_path() {
return None;
}
[TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some())
}
/// The decision for the window: `target` is the format the renderer configured (`None`: the glow
/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the
/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS),
/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`).
pub fn choose(target: Option<TextureFormat>, platform: Platform, display_headroom: Option<f32>, disabled: bool) -> DisplayOutput {
if disabled {
return DisplayOutput::Sdr { reason: "HDR display is switched off" };
}
if !platform.has_hdr_path() {
return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" };
}
let Some(format) = target else {
return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" };
};
let Some(encoding) = encoding_of(format, platform) else {
return DisplayOutput::Sdr { reason: "the window surface is 8-bit" };
};
match display_headroom {
Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding },
Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" },
None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" },
}
}
/// The decision for this start, logged once (the window renderer's `target` format).
pub fn report(target: Option<TextureFormat>) -> DisplayOutput {
let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
// no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready
let out = choose(target, Platform::current(), None, disabled);
match out {
DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"),
DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use TextureFormat as F;
#[test]
fn eight_bit_surfaces_are_sdr() {
for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] {
for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] {
assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}");
}
}
}
#[test]
fn hdr_needs_platform_format_and_headroom() {
let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear };
assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr);
assert_eq!(
choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false),
DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 }
);
// any missing condition falls back to SDR
assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr());
assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr());
// macOS has no PQ swap chain path
assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr());
}
#[test]
fn preferred_format_picks_float_then_pq() {
let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float];
assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None);
assert_eq!(preferred_hdr_format(&all, Platform::X11), None);
assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None);
}
}
+1 -50
View File
@@ -24,12 +24,9 @@
mod alloc_release;
mod appearance;
#[cfg(target_os = "linux")]
mod colord;
mod control_server;
mod dialog_filter;
mod editors;
mod hdr_display;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -90,16 +87,6 @@ impl eframe::App for App {
if !self.0.shutdown(EXIT_WAIT) {
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
}
// macOS (#1331): end the process here, before eframe tears the window down, as Quit from
// the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's
// content view and restores its class on teardown; AppKit's Touch Bar support observes
// that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation
// invalidate]` throws "not registered" and closing the window ends in a crash report.
// Nothing after this is needed: settings and library are saved, background work has
// stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a
// renderer that fails to start still relaunches on the next one (`window_gpu`).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -463,7 +450,6 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
@@ -539,10 +525,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
// Linux: the profile the desktop assigns in colord, read off the UI thread
#[cfg(target_os = "linux")]
screen_profile: Some(colord::service()),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
#[cfg(not(target_os = "macos"))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
@@ -850,8 +833,6 @@ fn main() -> eframe::Result {
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session = session.with_default_sam3_model();
// HDR display: SDR unless the surface, platform and display all allow HDR
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
@@ -861,7 +842,6 @@ fn main() -> eframe::Result {
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
app.notices.extend(window_notice);
app.gpu_notice = true;
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
@@ -1227,35 +1207,6 @@ mod tests {
assert!(loaded.is_some() && warning.is_none());
let _ = std::fs::remove_dir_all(&d);
}
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
/// The check runs in a child (this test binary again), since on macOS it ends the process.
#[test]
fn on_exit_ends_the_process_on_macos() {
if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut app = App(
app,
PrefsWriter::default(),
#[cfg(target_os = "macos")]
None,
);
println!("calling on_exit");
eframe::App::on_exit(&mut app, None);
println!("on_exit returned");
return;
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
.env("LIGHTCRAFT_TEST_ON_EXIT", "1")
.env("LIGHTCRAFT_NO_PREFS", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{stdout}");
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
}
#[cfg(test)]
+1 -2
View File
@@ -50,7 +50,6 @@
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -75,7 +74,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 8;
pub const VERSION: u32 = 7;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+38 -68
View File
@@ -236,84 +236,63 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
use std::collections::HashMap;
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
// keywords share a parent)
struct Level {
name: String,
sort: String,
path: String,
count: usize,
seen: usize,
parent: Option<String>,
}
let mut levels: Vec<Level> = Vec::new();
let mut by_lower: HashMap<String, usize> = HashMap::new();
// each level's children (by index), and the top level
let mut kids: Vec<Vec<usize>> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
// the keyword list first (no photos counted): a listed keyword is there without photos
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
// each keyword as written → its levels (its own and those above it), worked out once: a
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
this_photo.clear();
for k in keywords {
let chain = chains.entry(k.as_str()).or_insert_with(|| {
let mut chain: Vec<usize> = Vec::new();
let mut path = String::new();
let mut lower = String::new();
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
let sort = part.to_lowercase();
lower.push_str(&sort);
let i = match by_lower.get(&lower) {
Some(&i) => i,
None => {
let i = levels.len();
match chain.last().and_then(|&p| kids.get_mut(p)) {
Some(k) => k.push(i),
None => roots.push(i),
}
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
kids.push(Vec::new());
by_lower.insert(lower.clone(), i);
i
}
};
chain.push(i);
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
chain
});
if counts {
for &i in chain.iter() {
let Some(l) = levels.get_mut(i) else { continue };
// photo 0 is the keyword list, which counts nothing
if l.seen != photo {
l.seen = photo;
l.count += 1;
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if counts && this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
}
}
}
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
list.into_iter()
.filter_map(|i| {
let lv = levels.get(i)?;
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
let children = build(below, levels, kids);
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
})
.collect()
}
build(roots, &levels, &mut kids)
build(None, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
@@ -825,15 +804,6 @@ mod tests {
assert_eq!(c.query(&f, &Default::default()).len(), 3);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[test]
fn rename_delete_merge_are_single_undoable_batches() {
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
+1 -20
View File
@@ -125,12 +125,6 @@ pub enum Op {
id: PhotoId,
step: HistoryStep,
},
/// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps.
SetHistoryCursor {
id: PhotoId,
#[serde(default)]
cursor: Option<usize>,
},
AddAlbum {
album: Album,
},
@@ -551,25 +545,12 @@ impl Catalog {
Op::PushHistory { id, step } => {
let p = self.photo_mut(id)?;
let old = p.history.clone();
let old_cursor = p.history_cursor.take();
// a new edit made from an earlier step drops the steps after it
if let Some(c) = old_cursor {
p.history.truncate(c.saturating_add(1));
}
p.history.push(step);
if p.history.len() > HISTORY_LIMIT {
let n = p.history.len() - HISTORY_LIMIT;
p.history.drain(..n);
}
if old_cursor.is_none() {
Op::SetHistory { id, history: old }
} else {
Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] }
}
}
Op::SetHistoryCursor { id, cursor } => {
let p = self.photo_mut(id)?;
Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) }
Op::SetHistory { id, history: old }
}
Op::AddAlbum { album } => {
if self.albums.contains_key(&album.id) {
-1
View File
@@ -279,7 +279,6 @@ impl Op {
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
+2 -3
View File
@@ -135,9 +135,8 @@ impl LibraryLock {
}
/// Another descriptor on the locked file: what a process spawned by another thread holds
/// between fork and exec (a `dup` refers to the same open file description). Tests only (the
/// test that uses it is Unix-only: `flock` semantics).
#[cfg(all(test, unix))]
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
#[cfg(test)]
pub(crate) fn descriptor_copy(&self) -> Option<File> {
self.file.as_ref().and_then(|f| f.try_clone().ok())
}
+5 -20
View File
@@ -247,11 +247,6 @@ pub struct Photo {
pub versions: Vec<Version>,
#[serde(default)]
pub history: Vec<HistoryStep>,
/// The History step the photo's settings are at, when it isn't the latest one (Go to History
/// Step moved back): later steps stay listed and can be returned to until a new edit drops
/// them. `None` (and catalogs written before the cursor existed) = the latest step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_cursor: Option<usize>,
/// In "Recently Deleted".
#[serde(default)]
pub deleted: bool,
@@ -323,22 +318,15 @@ pub fn relative_wb_format(format: &str) -> bool {
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor
/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin:
/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays
/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through
/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree.
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
}
impl Photo {
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
pub fn history_index(&self) -> Option<usize> {
let last = self.history.len().checked_sub(1)?;
Some(self.history_cursor.map_or(last, |c| c.min(last)))
}
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
id,
@@ -359,7 +347,6 @@ impl Photo {
edited: None,
versions: Vec::new(),
history: Vec::new(),
history_cursor: None,
deleted: false,
local: false,
duration: None,
@@ -612,8 +599,6 @@ mod edited_tests {
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
("SONY ILCA-99M2", "ARW", false),
("SONY SLT-A33", "ARW", false),
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
+2 -13
View File
@@ -1,7 +1,5 @@
//! Filtering, search and sorting.
use std::cmp::Ordering::Equal;
use serde::{Deserialize, Serialize};
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
@@ -361,13 +359,8 @@ impl Filter {
}
}
/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`).
pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
}
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
let root = filter.library_root();
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
@@ -376,15 +369,11 @@ impl Catalog {
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 => cmp_name(&a.file_name, &b.file_name),
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
+5 -24
View File
@@ -9,7 +9,6 @@
use std::collections::{HashMap, HashSet};
use crate::query::cmp_name;
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
impl Catalog {
@@ -204,19 +203,17 @@ impl Catalog {
/// Only runs of two or more are returned.
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
let index = self.stack_index();
let mut timed: Vec<(i64, &str, PhotoId)> = ids
let mut timed: Vec<(i64, PhotoId)> = ids
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !index.contains_key(&p.id))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
.collect();
// a burst within one second stays in file name order (the first file is the stack's cover)
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
// equal ids are the same photo, so same time and name: adjacent after the sort
timed.dedup_by_key(|(_, _, id)| *id);
timed.sort();
timed.dedup_by_key(|(_, id)| *id);
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
let mut last: Option<i64> = None;
for (t, _, id) in timed {
for (t, id) in timed {
match (last, groups.last_mut()) {
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
_ => groups.push(vec![id]),
@@ -267,22 +264,6 @@ mod tests {
(c, ids)
}
#[test]
fn auto_stack_keeps_a_burst_in_file_order() {
let mut c = Catalog::new();
let mut by_name = Vec::new();
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
let id = PhotoId(1000 - n as u64);
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some("2026-04-01T10:00:00".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
by_name.push(id);
}
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
assert_eq!(groups, vec![by_name.clone()]);
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
}
#[test]
fn iso_seconds_parses() {
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
-43
View File
@@ -694,46 +694,3 @@ fn undated_photos_group_under_unknown_date_and_sort_together() {
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]);
}
/// Photos with one import time and capture time, as one import batch or a burst gives them, added
/// with ids that run opposite to their names.
fn batch_with_reversed_ids(c: &mut Catalog) -> Vec<PhotoId> {
let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"];
let mut ids = Vec::new();
for (n, name) in names.iter().enumerate() {
let id = PhotoId(1000 - n as u64);
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00");
p.captured = Some("2026-10-01T12:00:00".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
ids // in name order
}
#[test]
fn one_import_batch_sorts_by_file_name_not_id() {
let mut c = Catalog::new();
let by_name = batch_with_reversed_ids(&mut c);
let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() };
assert_eq!(c.query(&Filter::default(), &sort), by_name);
}
#[test]
fn burst_in_one_second_sorts_by_file_name_not_id() {
let mut c = Catalog::new();
let by_name = batch_with_reversed_ids(&mut c);
let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() };
assert_eq!(c.query(&Filter::default(), &sort), by_name);
}
#[test]
fn cmp_name_ignores_case_without_allocating() {
use crate::query::cmp_name;
use std::cmp::Ordering::*;
assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal);
assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal);
assert_eq!(cmp_name("a", "B"), Less);
assert_eq!(cmp_name("B", "a"), Greater);
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
assert_eq!(cmp_name("a", "ab"), Less);
}
+1 -2
View File
@@ -211,9 +211,8 @@ fn op_variants_are_versioned() {
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
Op::SetAsShotWb { .. } => 7,
Op::SetHistoryCursor { .. } => 8,
}
}
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+2 -1
View File
@@ -140,7 +140,8 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
if let Ok(mut c) =
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
+2 -2
View File
@@ -11,7 +11,7 @@ let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
@@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
+9 -85
View File
@@ -394,41 +394,11 @@ pub fn pq_decode(e: f32) -> f32 {
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
}
/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HdrTransfer {
#[default]
Pq,
Hlg,
}
/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408).
pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0;
/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100:
/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma
/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75,
/// as BT.2408 asks. Non-finite or negative inputs count as 0.
pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] {
const K: [f32; 3] = [0.2627, 0.6780, 0.0593];
const GAMMA: f32 = 1.2;
let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS));
let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0);
let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 };
fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32)
}
/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]).
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq)
}
/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level
/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display).
/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result<Vec<u8>> {
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
@@ -450,10 +420,7 @@ pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += y_nits as f64;
let [r, g, b] = match transfer {
HdrTransfer::Pq => nits.map(pq_encode),
HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]),
};
let [r, g, b] = nits.map(pq_encode);
let y = K[0] * r + K[1] * g + K[2] * b;
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
@@ -479,15 +446,11 @@ pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
ec.pixel_range = PixelRange::Full;
ec.color_description = Some(ColorDescription {
color_primaries: ColorPrimaries::BT2020,
transfer_characteristics: match transfer {
HdrTransfer::Pq => TransferCharacteristics::SMPTE2084,
HdrTransfer::Hlg => TransferCharacteristics::HLG,
},
transfer_characteristics: TransferCharacteristics::SMPTE2084,
matrix_coefficients: MatrixCoefficients::BT2020NCL,
});
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
ec.content_light =
(transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.still_picture = true;
ec.quantizer = quantizer;
ec.min_quantizer = quantizer.min(255) as u8;
@@ -525,15 +488,10 @@ pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
use avif_serialize::constants as c;
let mut a = avif_serialize::Aviffy::new();
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
.set_transfer_characteristics(match transfer {
HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084,
HdrTransfer::Hlg => c::TransferCharacteristics::Hlg,
})
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
.set_color_primaries(c::ColorPrimaries::Bt2020)
.set_full_color_range(true);
if transfer == HdrTransfer::Pq {
a.set_content_light_level(cll.0, cll.1);
}
.set_full_color_range(true)
.set_content_light_level(cll.0, cll.1);
if let Some(exif) = meta.exif {
a.set_exif(exif.to_vec());
}
@@ -541,7 +499,7 @@ pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta, transfer);
let _ = (quality, speed, meta);
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
@@ -628,37 +586,3 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<
.map_err(|e| Error::Encode(e.to_string()))?;
Ok(r.avif_file)
}
#[cfg(test)]
mod hlg_tests {
use super::*;
#[test]
fn hlg_reference_white_and_peak() {
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
for v in hlg_signal([1.0; 3]) {
assert!((v - 0.75).abs() < 0.005, "{v}");
}
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
// black, negatives and non-finite values are 0, never NaN
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
// monotonic in brightness
let mut prev = 0.0;
for i in 1..50 {
let v = hlg_signal([i as f32 * 0.1; 3])[1];
assert!(v >= prev);
prev = v;
}
}
#[test]
fn hdr_avif_rejects_short_buffers() {
let m = EncodeMeta::default();
assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err());
assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err());
}
}
+6 -18
View File
@@ -39,8 +39,8 @@ mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression,
encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal,
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
};
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
pub use sniff::{Format, sniff};
@@ -244,24 +244,12 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
}
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
///
/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest
/// of the pipeline (issue #1212).
pub fn to_working(d: &Decoded) -> Rgb32f {
let mut img = if d.space.is_working() {
d.image.clone()
} else {
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
};
// white is (1, 1, 1) in the working space too: the conversion is white-preserving
if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) {
for (p, &av) in img.data.iter_mut().zip(&a.data) {
let av = av.clamp(0.0, 1.0);
*p = p.map(|c| c * av + (1.0 - av));
}
if d.space.is_working() {
return d.image.clone();
}
img
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
}
/// Where a thumbnail came from.
-18
View File
@@ -140,24 +140,6 @@ fn png_metadata_and_gray_alpha() {
assert!((a - 128.0 / 255.0).abs() < 1e-6);
}
/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212:
/// transparent areas, stored black, rendered black).
#[test]
fn png_transparency_develops_on_white() {
// opaque red, transparent black (as most encoders store it), half-transparent red
let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128];
let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap();
let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0);
let w = d.to_working();
let a = 128.0 / 255.0;
for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] {
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
}
}
#[test]
fn p3_icc_to_working() {
let icc = write_named(NamedSpace::DisplayP3);
-79
View File
@@ -99,45 +99,8 @@ impl ControlSpec {
let s = format!("{:.*}", self.decimals as usize, v);
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
/// linear.
pub fn position(&self, v: f64) -> f64 {
let v = self.clamp(v);
let p = match self.kelvin_scale() {
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
None => (v - self.min) / (self.max - self.min).max(1e-9),
};
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
}
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
pub fn value_at(&self, p: f64) -> f64 {
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
let v = match self.kelvin_scale() {
Some(_) if p >= 1.0 => self.max,
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
None => self.min + p * (self.max - self.min),
};
self.clamp(v)
}
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
/// `None` for every other control, or a range the centre isn't inside.
fn kelvin_scale(&self) -> Option<(f64, f64)> {
let centre = KELVIN_CENTRE;
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
}
}
/// The Kelvin temperature at the middle of its track, as in Lightroom.
const KELVIN_CENTRE: f64 = 7000.0;
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
const KELVIN_KNEE: f64 = 1000.0;
macro_rules! controls {
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
/// All numeric develop controls, in panel order.
@@ -545,46 +508,4 @@ mod tests {
assert_eq!(e.format(-1.0), "-1.00");
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
}
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
#[test]
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
let temp = find("wb.temp").unwrap();
for (k, lightroom, model) in [
(2000.0, 0.0, 0.0),
(3650.0, 0.167, 0.165),
(5400.0, 0.333, 0.34),
(7000.0, 0.5, 0.5),
(15000.0, 0.79, 0.79),
(20000.0, 0.853, 0.849),
(30000.0, 0.90, 0.92),
(50000.0, 1.0, 1.0),
] {
let p = temp.position(k);
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
}
// every 50 K step survives value → position → value, and the track never runs backwards
let mut last = -1.0;
for k in (2000..=50000).step_by(50).map(f64::from) {
let p = temp.position(k);
assert!(p > last, "{k} K");
last = p;
let back = (temp.value_at(p) / temp.step).round() * temp.step;
assert_eq!(back, k, "{k} K → {p} → {back}");
}
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
let exposure = find("light.exposure").unwrap();
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
}
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
}
}
+17 -405
View File
@@ -10,11 +10,7 @@ use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit, resize},
};
use lightcraft_raw::{
ColorData, RawFormat, RawImage,
color::CameraTransform,
profile::{HsvTable, ProfileLook, ToneCurve},
};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
@@ -29,8 +25,7 @@ use lightcraft_raw::{
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
/// states, for every make (#778).
/// 4: a tone curve from black-and-white camera JPEGs (#577).
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
pub const LOOK_VERSION: u32 = 5;
pub const LOOK_VERSION: u32 = 4;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -112,69 +107,6 @@ fn starting_colour_with(
(xy, t, look)
}
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered
/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them).
pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<ColorData> {
if lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
let (_, t, look) = starting_colour(raw, bytes);
dng_colour(&raw.color, &t, look.as_ref())
}
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation
/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
/// `None` when the white balance or the matrix can't give tags.
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
// into the baseline exposure
let white = m.apply([1.0; 3]);
let k = (white[0] + white[1] + white[2]) / 3.0;
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
return None;
}
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
let wb = t.wb.map(f64::from);
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
Some(ColorData {
illuminant: [21, 0],
color_matrix: [Some(camera_to_xyz.inverse()?), None],
forward_matrix: [Some(forward), None],
camera_calibration: [None, None],
analog_balance: None,
as_shot_neutral: Some(neutral),
as_shot_white_xy: None,
baseline_exposure: t.baseline_exposure + k.log2(),
baseline_sharpness: tags.baseline_sharpness,
kelvin_presets: None,
profile: ProfileLook {
hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None],
tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)),
..Default::default()
},
})
}
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
const N: usize = 256;
let samples = (0..=N).map(|i| {
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32);
[x, tone.apply(x).min(1.0)]
});
let flat: Vec<f64> = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect();
ToneCurve::from_tag(&flat)
}
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
@@ -333,19 +265,12 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
})
}
/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
/// 128 MP also admits 100 MP medium-format previews.
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
/// and per proxy pixel whether any sensor sample it covers is clipped.
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
let edge = (2 * size).max(384) as u32;
let decoded = crate::files::decode_raw_preview(
bytes,
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
)?;
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
@@ -644,7 +569,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
if (0.015..=1.0).contains(&y) {
bright.push((input.map(f64::from), output.map(f64::from)));
}
if !colour_pair(*input, *output) {
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
continue;
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
@@ -655,77 +580,6 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
}
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
&& (0.015..0.85).contains(&luminance_2020(output))
}
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
/// The original pairs and their split still own the colour fit and its acceptance gates.
/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and
/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise.
fn fit_tone_with_shadows(
fit: ToneFit,
pairs: &[([f64; 3], [f64; 3])],
sensor: &Rgb32f,
reference: &Rgb32f,
edge_limit: Option<f32>,
colour: impl Fn([f64; 3]) -> [f64; 3],
) -> Option<CameraTone> {
let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y));
let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect());
let mut eligible = Vec::new();
let (mut colour_index, mut extra_index) = (0, 0);
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
continue;
}
// About one 8-bit sRGB code above black: don't learn a flat black segment from
// JPEG compression. The sensor proxy averages many samples before this test.
if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5)
|| !output.iter().all(|v| v.is_finite() && *v >= 0.0)
|| !(0.0003..0.85).contains(&luminance_2020(*output))
{
continue;
}
// A colour holdout must never enter tone training just because the wider sample set
// changes its index. Split the extra samples separately, keeping every colour index.
let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index };
let held = *index % 3 == 0;
*index += 1;
let (x, y) = luminances(input.map(f64::from), output.map(f64::from));
if x.is_finite() && x > 0.0 {
eligible.push((x, y, held));
}
}
let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count();
let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect();
// Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank,
// so a single quantized grey also keeps the original curve.
if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION {
return original;
}
let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect();
let Some(candidate) = fit.fit(training) else { return original };
let error = |tone: &CameraTone| -> f64 {
held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum()
};
// A wider histogram must also help independent dark pixels, not just redistribute the
// knots. The usual held-out gates still check the completed look on the colour pairs.
if let Some(old) = original {
let (before, after) = (error(&old), error(&candidate));
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}");
}
if !after.is_finite() || after >= before * 0.99 {
return original;
}
}
Some(candidate)
}
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
let mut gram = [[0.0; 3]; 3];
@@ -851,7 +705,9 @@ fn fit_pairs_on(
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue };
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) = 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;
@@ -993,8 +849,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
})),
None => Mat3::IDENTITY,
};
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?;
let mut look = CameraLook { matrix, tone, hue_sat: None };
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None };
if let Some(tone) = fit_chroma(&bright, &look) {
look.tone = tone;
}
@@ -1100,8 +957,9 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
@@ -1459,167 +1317,6 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
mod tests {
use super::*;
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
// A broad scene with a steep camera toe below the old JPEG floor of 0.015.
// Permute brightness so the held-out pixels span the same range independently.
let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25;
let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025];
*src = rgb.map(|v| v * y / luminance_2020(rgb));
let display = 3.0 * y * (y / (y + 0.02)).powi(2);
*dst = src.map(|v| v * display / y);
}
(sensor, reference)
}
#[test]
fn camera_tone_follows_the_jpegs_deep_shadows() {
let (sensor, reference) = steep_toe_scene();
let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
assert!(dark.len() > 256);
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "deep-shadow luminance error {error}");
assert_eq!(look.matrix, Mat3::IDENTITY);
}
#[test]
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
let (sensor, reference) = steep_toe_scene();
let fit = |reference: &Rgb32f| {
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap();
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
};
let expected = fit(&reference);
let mut changed = reference.clone();
let mut index = 0;
for (x, y) in sensor.data.iter().zip(&mut changed.data) {
if colour_pair(*x, *y) {
if index % 3 == 0 {
*y = y.map(|v| v * 1.1);
assert!(colour_pair(*x, *y));
}
index += 1;
}
}
assert_eq!(fit(&changed), expected);
}
#[test]
fn camera_toe_is_robust_to_shadow_noise() {
let (sensor, clean) = steep_toe_scene();
let mut noisy = clean.clone();
for (i, p) in noisy.data.iter_mut().enumerate() {
if luminance_2020(*p) < 0.015 {
// Quantized JPEG levels with bounded noise and occasional compression outliers.
let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004;
*p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003);
if i % 23 == 0 {
*p = [0.014; 3];
}
}
}
let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "noisy shadow luminance error {error}");
assert_eq!(look.tone.apply(0.0), 0.0);
assert_eq!(look.matrix, Mat3::IDENTITY);
for i in 1..1000 {
assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0));
}
}
#[test]
fn camera_toe_requires_lower_independent_dark_error() {
for holdout_matches_original in [true, false] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 };
*src = [y; 3];
// Midtones teach the original slope of 3. Dark training teaches 0.3,
// but the independent dark holdout can agree with either slope.
let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 };
*dst = [y * slope; 3];
assert_eq!(colour_pair(*src, *dst), i >= 600);
}
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
// Both sample sets start at a multiple of three, so this split matches
// the helper's separate colour and extra-sample indices.
let candidate = fit_tone(
sensor
.data
.iter()
.zip(&reference.data)
.enumerate()
.filter(|(i, _)| i % 3 != 0)
.map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from))))
.collect(),
)
.unwrap();
let held: Pairs =
sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect();
assert_eq!(held.len(), 200);
assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y))));
assert!(rank_correlation_ties(&held) > 0.99);
let error = |tone: &CameraTone| {
held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::<f64>()
};
assert_ne!(candidate, original);
let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap();
if holdout_matches_original {
assert!(error(&candidate) > error(&original) + 0.1);
assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve");
} else {
assert!(error(&candidate) < error(&original) * 0.99);
assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate");
}
let mut previous = 0.0;
assert_eq!(selected.apply(0.0), 0.0);
for i in 0..10_000 {
let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0));
assert!(y.is_finite() && y >= previous && y <= 1.0);
previous = y;
}
assert_eq!(selected.apply(f32::NAN), 0.0);
assert_eq!(selected.apply(f32::INFINITY), 0.0);
}
}
#[test]
fn unreliable_dark_pairs_keep_the_original_tone_and_colour() {
let (sensor, reference) = steep_toe_scene();
let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect();
let mut excluded = sensor.clone();
for &i in &dark {
excluded.data[i] = [f32::NAN; 3];
}
let colour = Some((Mat3::IDENTITY, None));
let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap();
for case in 0..6 {
let (mut x, mut y) = (sensor.clone(), reference.clone());
for (j, &i) in dark.iter().enumerate() {
match case {
0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal
1 => x.data[i] = [0.00001; 3], // below the sensor noise floor
2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs
3 => x.data[i] = [0.003; 3], // no scene luminance spread
4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe
5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise
_ => {}
}
}
let look = fit_pairs_with(&x, &y, colour.clone()).unwrap();
assert_eq!(look.tone, original.tone, "case {case}");
assert_eq!(look.matrix, original.matrix, "case {case}");
assert!(look.hue_sat.is_none());
}
}
#[test]
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
@@ -2137,19 +1834,17 @@ mod tests {
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let own = lightcraft_raw::color::has_matrix(&raw.color);
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
let measured = lightcraft_raw::spectral::find(make, model).is_some();
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some();
let before = raw.color.clone();
let (_, t, fitted) = starting_colour(&mut raw, &bytes);
let spectral = !own && fitted.is_none() && measured;
let spectral = !own && fitted.is_none() && covered;
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
assert_eq!(raw.color != before, spectral, "{name}");
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
// white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin,
// through the spectral matrices or its presets, whichever look it got (issue #730)
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
// spectral matrices, whichever look it got (issue #730)
assert_eq!(info.relative_wb, !own && !covered, "{name}");
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
if covered && !own {
@@ -2367,43 +2062,6 @@ mod tests {
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same
/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation
/// table and its tone curve; without a look, those of the camera transform.
#[test]
fn dng_colour_renders_like_the_raws_starting_colour() {
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37));
assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb);
let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
for look in [Some(&look), None] {
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
for i in 0..3 {
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
}
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
for c in samples {
let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c));
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}");
}
assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone()));
let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve);
match look {
Some(look) => {
let tone = tone.expect("the look's tone curve");
let worst =
(0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max);
// (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here)
assert!(worst < 4e-3, "{worst}");
}
None => assert!(tone.is_none()),
}
}
}
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
CameraLook {
@@ -2659,36 +2317,6 @@ mod tests {
}
}
/// Sony ARWs without measured matrices read Kelvin from their white-balance presets
/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and
/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its
/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files.
#[test]
fn corpus_arw_white_balance_in_kelvin_through_the_presets() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let header = crate::files::probe_bytes(name, &bytes).unwrap();
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}");
assert!(
(info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0,
"{name}: {} {}",
info.as_shot_temp,
info.as_shot_tint
);
let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default());
assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}");
}
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
@@ -3182,20 +2810,4 @@ mod tests {
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
}
/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted.
#[test]
fn corpus_arw6_full_frame_gets_a_camera_look() {
const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) };
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw6-sony-ilce7rm6-ff-land-c.arw");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
}
+3 -14
View File
@@ -303,20 +303,9 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
}
let sibling = src.with_file_name("denoise-model.json");
let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else {
let why = if sibling.is_file() {
"denoise-model.json could not be read or is too large"
} else if src.with_file_name("model.json").is_file() {
// other apps ship their models with a model.json of their own (#1381)
"No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \
does not read: it needs its own denoise-model.json (see docs/denoise.md)"
} else {
"No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)"
};
return Err(bad(C, why));
};
let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?;
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
let dir = models_dir(s, C)?;
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
+7 -68
View File
@@ -1079,47 +1079,15 @@ pub fn specs() -> Vec<CommandSpec> {
let id = active(s, "history.clear")?;
let d = s.develop_of(id).unwrap_or_default();
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?;
s.commit("Clear History", Op::SetHistory { id, history })?;
ok()
}),
cmd!(
"history.restore",
"Go to History Step",
[],
None,
"{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit",
has_active,
|s, p| {
let c = "history.restore";
let id = active(s, c)?;
let i = f64_req(p, "index", c)?;
if !(i.is_finite() && i >= 0.0) {
return Err(bad(c, "no such step"));
}
go_to_history_step(s, c, id, i as usize)
}
),
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
let c = "history.previous";
let id = active(s, c)?;
let i = s
.catalog
.photo(id)
.and_then(|ph| ph.history_index())
.and_then(|i| i.checked_sub(1))
.ok_or_else(|| bad(c, "at the first History step"))?;
go_to_history_step(s, c, id, i)
}),
cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| {
let c = "history.next";
let id = active(s, c)?;
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let i = ph
.history_index()
.map(|i| i.saturating_add(1))
.filter(|i| *i < ph.history.len())
.ok_or_else(|| bad(c, "at the latest History step"))?;
go_to_history_step(s, c, id, i)
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
let id = active(s, "history.restore")?;
let i = f64_req(p, "index", "history.restore")? as usize;
let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?;
s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?;
ok()
}),
// ---- interactions (slider drags, brush strokes)
cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| {
@@ -1136,32 +1104,3 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later
/// steps stay listed (one undo step).
fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result<Value> {
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?;
let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i);
let label = format!("History: {}", st.label);
let edited = Some((s.clock)());
s.commit(
&label,
Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] },
)?;
ok()
}
fn has_history_before(s: &Session) -> std::result::Result<(), String> {
let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index());
if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) }
}
fn has_history_after(s: &Session) -> std::result::Result<(), String> {
let photo = s.active().and_then(|id| s.catalog.photo(id));
if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) {
Ok(())
} else {
Err("at the latest History step".into())
}
}
-24
View File
@@ -219,12 +219,6 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
Ok(json!({"active": vis[next].0}))
}
/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input
/// can't overflow).
fn row_step(p: &Value) -> isize {
p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize
}
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
let targets = s.targets(p);
let mut ops = Vec::new();
@@ -647,24 +641,6 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
cmd!(
"library.down",
"Photo Below",
[],
Some("Down"),
"{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}",
always,
|s, p| { step(s, row_step(p)) }
),
cmd!(
"library.up",
"Photo Above",
[],
Some("Up"),
"{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}",
always,
|s, p| { step(s, -row_step(p)) }
),
// ---- rating / flags / labels
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
+8 -9
View File
@@ -195,21 +195,20 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| {
Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect()))
}),
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| {
let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id);
let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)}));
// imported LUT profiles too (#618), marked as in `profiles.menu`
let imported =
s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true}));
Ok(Value::Array(builtin.chain(imported).collect()))
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| {
Ok(Value::Array(
crate::presets::PROFILES
.iter()
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)}))
.collect(),
))
}),
cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())),
cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| {
cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| {
let id = photo_arg(s, p, "history.list")?;
let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?;
Ok(json!({
"history": ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"cursor": ph.history_index(),
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
+1 -5
View File
@@ -49,10 +49,6 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
}
if !ops.is_empty() {
s.commit("Read Metadata from File", Op::Batch { ops })?;
// Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had
// only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's
// next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them.
s.skip_auto_write = true;
}
let mut result = json!({"read": read, "failed": failed});
if !warnings.is_empty() {
@@ -94,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
has_selection,
read
),
+3 -110
View File
@@ -28,8 +28,6 @@ pub enum ExportFormat {
Original,
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
Dng,
/// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in.
RenderedDng,
}
impl ExportFormat {
@@ -44,7 +42,6 @@ impl ExportFormat {
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
"original" => Self::Original,
"dng" => Self::Dng,
"rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng,
_ => return None,
})
}
@@ -59,7 +56,7 @@ impl ExportFormat {
Self::Jxl => "jxl",
// the original keeps its own extension (see `ExportOptions::file_name_for`)
Self::Original => "",
Self::Dng | Self::RenderedDng => "dng",
Self::Dng => "dng",
}
}
@@ -626,32 +623,6 @@ pub enum MetadataPolicy {
None,
}
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HdrCurve {
#[default]
Pq,
Hlg,
}
impl HdrCurve {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"pq" => Some(Self::Pq),
"hlg" => Some(Self::Hlg),
_ => None,
}
}
fn codec(self) -> lightcraft_codecs::HdrTransfer {
match self {
Self::Pq => lightcraft_codecs::HdrTransfer::Pq,
Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportOptions {
@@ -710,8 +681,6 @@ pub struct ExportOptions {
pub add_to_photos: bool,
/// The Photos album they go into, made when missing (empty = none).
pub photos_album: String,
/// The curve of an HDR AVIF: PQ (default) or HLG.
pub hdr_transfer: HdrCurve,
}
impl Default for ExportOptions {
@@ -741,7 +710,6 @@ impl Default for ExportOptions {
jxl_progressive: false,
add_to_photos: false,
photos_album: String::new(),
hdr_transfer: HdrCurve::Pq,
}
}
}
@@ -783,7 +751,6 @@ pub const OPTION_PARAMS: &[&str] = &[
"jxlProgressive",
"addToPhotos",
"photosAlbum",
"hdrTransfer",
];
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
@@ -956,7 +923,6 @@ impl ExportOptions {
}
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
@@ -1095,7 +1061,6 @@ impl ExportOptions {
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer),
}
}
@@ -1168,8 +1133,6 @@ impl ExportOptions {
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
(ExportFormat::Tiff, _) => OutputDepth::U16,
(ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::RenderedDng, _) => OutputDepth::F32Linear,
(ExportFormat::Psd, Some(8)) => OutputDepth::U8,
(ExportFormat::Psd, _) => OutputDepth::U16,
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
@@ -1188,7 +1151,6 @@ impl ExportOptions {
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")],
ExportFormat::RenderedDng => &[(16, "16-bit float")],
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
}
@@ -1379,7 +1341,6 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()),
};
r.map_err(|e| e.to_string())
}
@@ -1470,7 +1431,6 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
/// Encode a high-bit-depth image (see [`encode_rendered`]).
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let meta_src = meta;
let img = finish_deep(img, o);
let profile = match img.samples {
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
@@ -1489,12 +1449,8 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
(ExportFormat::Psd, _) => encode::encode_psd(&e, &meta),
(ExportFormat::RenderedDng, DeepSamples::F32(v)) => {
let m = meta_src.cloned().unwrap_or_default();
return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string());
}
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec())
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
}
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
@@ -1580,11 +1536,9 @@ pub struct Exported {
/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) {
let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
// whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie
let (w, h) = (w.round().max(1.0), h.round().max(1.0));
match &o.resize {
Some(r) => r.apply(w, h),
None => (w as usize, h as usize),
None => ((w.round() as usize).max(1), (h.round() as usize).max(1)),
}
}
@@ -2742,47 +2696,6 @@ mod tests {
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
}
#[test]
fn resize_size_survives_the_render() {
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
let cropped = |r: Resize, w: u32, h: u32, x1: f64| {
let mut p = named("IMG.ARW");
(p.width, p.height) = (w, h);
std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0);
let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() });
lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh))
};
let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0);
let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true };
// a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5
let hair = 1.0 - 1e-9;
assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365));
assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000));
// 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says
for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] {
for (w, h) in [(w, h), (h, w)] {
for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] {
for r in [
Resize::long_edge(value as u32),
mode(ResizeMode::ShortEdge, value, 0),
mode(ResizeMode::Width, value, 0),
mode(ResizeMode::Height, value, 0),
mode(ResizeMode::Dimensions, value, 1365),
mode(ResizeMode::Megapixels, value / 1000.0, 0),
mode(ResizeMode::Percent, value / 100.0, 0),
] {
let want = r.apply(w.into(), h.into());
assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}");
}
}
}
}
}
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
@@ -3196,26 +3109,6 @@ mod tests {
}
// Issue #496: photos rendered side by side give the same files, names, order and progress as
// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture
#[test]
fn rendered_dng_matches_the_rendered_tiff() {
let mut s = crate::Session::with_demo();
let id = s.visible()[0];
let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap();
assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name);
let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap();
let raw = lightcraft_raw::decode(&dng.bytes).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3));
assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one");
let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image;
assert_eq!((reference.width, reference.height), (back.width, back.height));
// mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB
let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::<f32>() / back.data.len() as f32;
let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::<f32>() / reference.data.len() as f32;
assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}");
}
// one at a time, and a cancel stops at the same photo
#[test]
fn side_by_side_batches_match_one_at_a_time() {
+24 -401
View File
@@ -176,7 +176,6 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
kind: MediaKind::Raw,
file_size: bytes.len() as u64,
captured,
file_modified: None,
meta,
as_shot_wb,
content_hash,
@@ -208,7 +207,6 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
kind: MediaKind::Image,
file_size: bytes.len() as u64,
captured,
file_modified: None,
meta,
as_shot_wb: None,
content_hash,
@@ -510,16 +508,15 @@ fn load_bytes_now(
///
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
struct WbScaleOf {
own_matrix: bool,
relative: bool,
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
legacy_relative: bool,
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
scale_white: Option<lightcraft_color::Xy>,
/// The as-shot white through the measured matrices, when that is where the scale comes from.
spectral_white: Option<lightcraft_color::Xy>,
}
impl WbScaleOf {
@@ -531,26 +528,18 @@ impl WbScaleOf {
) -> Self {
let own_matrix = lightcraft_raw::color::has_matrix(color);
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
let model = meta.model.as_deref().filter(|_| fitted);
let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model));
let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model));
let scale_white = match camera {
Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers),
None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers),
None => None,
};
let covered = camera.is_some() || presets;
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
}
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
/// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to
/// `own`, D65.
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
if self.relative {
return (6500.0, 0.0);
}
let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own));
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
(t.round(), tint.round())
}
}
@@ -654,16 +643,9 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
let jpeg = stand_in_image(bytes)?;
// headers only, as for other images (issue #367): decoding a full-size preview just for its
// size took about a second per file in an import; a decode remains the fallback
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
Ok(header) => (header.width, header.height, header.orientation),
Err(_) => {
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
(d.source_width, d.source_height, d.orientation)
}
};
if preview_orientation(bytes, orientation).swaps_axes() {
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
let (mut w, mut h) = (d.source_width, d.source_height);
if preview_orientation(bytes, d.orientation).swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
Some((w, h))
@@ -699,10 +681,9 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
/// Filesystem-backed embedded-preview hook (native).
///
/// The quick path reads the file's header and its embedded preview and nothing else (see
/// [`preview_window`]); a container the header cannot point into is planned from its tags
/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a
/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts
/// the whole file, which is what the decode of one can hold.
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
/// file, which is what the decode of one can hold.
pub fn fs_preview_loader() -> PreviewLoader {
Arc::new(|path: &str, max_edge: usize| {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
@@ -725,10 +706,6 @@ pub struct PreviewRead {
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
/// every preview cost before the header path existed).
pub header_path: bool,
/// Whether the sparse planner named the ranges, rather than a format's own header fields: the
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
/// the header path, and on the whole-file fallback).
pub planned: bool,
}
impl PreviewRead {
@@ -768,13 +745,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
{
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
}
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else {
// The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
// sparse planner which ranges `embedded_preview` reads before handing the caller the
// whole-file fallback.
return planned_preview(&mut file, path, total);
};
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
// A preview at the file's end may have been padded past it: clamp rather than fail.
let end = range.offset.saturating_add(range.window_len).min(total);
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
@@ -796,7 +767,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
let read = PreviewRead { read: bytes_read, total, header_path: true };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
@@ -805,107 +776,6 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
Ok(Some((prefix, window, read)))
}
/// The most times the sparse planner may name ranges before the preview gives up: the plan is
/// monotone (it never asks for a range twice), so a real file needs a handful.
const MAX_PREVIEW_PLAN_ROUNDS: usize = 8;
/// Tenths of a file the planner may have read before the preview gives up and the whole-file path
/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the
/// file has stopped being one.
const MAX_PREVIEW_PLAN_SHARE: u64 = 6;
/// The largest file the planner path builds its buffer for. The raws it serves are far below this;
/// a file past it is read whole as before rather than allocating its length up front.
const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30;
/// The header the quick path is handed on the planner route. The header route grows its prefix to
/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out
/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough
/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes.
const PLAN_PREFIX: u64 = 64 * 1024;
/// The preview of a container the header-only path cannot answer for, from the byte ranges
/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads.
///
/// The planner is asked about a buffer of the file's whole length in which only the ranges named so
/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The
/// buffer is the file's length, which the loader's memory gate already counts, and its untouched
/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in
/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so
/// this path's cost is the bytes read, not a promise about RSS.
///
/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the
/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it
/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when
/// the quick path could not reproduce what the whole-file path shows (the two container facts
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
/// gives what the whole file gives), so a window that still fails to decode ends in the same
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
if total > MAX_PREVIEW_PLAN_BYTES {
return Ok(None);
}
let Ok(len) = usize::try_from(total) else { return Ok(None) };
let mut buf = vec![0u8; len];
let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?;
let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) };
head.copy_from_slice(&header);
let mut loaded = lightcraft_raw::sparse::Ranges::new();
loaded.insert(0, header.len() as u64);
for _ in 0..MAX_PREVIEW_PLAN_ROUNDS {
match lightcraft_raw::sparse::preview_plan(&buf, &loaded) {
lightcraft_raw::sparse::HeaderPlan::Need(ranges) => {
let wanted = loaded.total().saturating_add(ranges.total());
if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) {
return Ok(None);
}
for &(start, end) in ranges.spans() {
let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) };
let Some(dst) = buf.get_mut(a..b) else { return Ok(None) };
file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?;
loaded.insert(start, end);
}
}
lightcraft_raw::sparse::HeaderPlan::Ready => {
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
// The quick path hands the JPEG to the decoder alone, so what the *container* says
// about the preview has to be something the bytes it is handed can show. Two container
// facts change the pixels and are not in the JPEG: the preview's colour space (which
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
// no orientation of its own, the file's EXIF orientation. Where either is present and
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
// it already does for a container-declared colour space. A container that says sRGB
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
// space declines.
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
return Ok(None);
}
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
// first: a JPEG that carries its own orientation needs nothing from the container.
if lightcraft_meta::extract(&window).orientation.is_none()
&& lightcraft_meta::extract(&header).orientation.is_none()
&& lightcraft_meta::extract(&buf).orientation.is_some()
{
return Ok(None);
}
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
// A plan-driven preview is the one that used to cost the whole file, so it is worth
// saying what it cost now too.
if lightcraft_pipeline::profiling() {
eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len());
}
// The planner's buffer is the file's length, so give its pages back the way a decode
// does (see `fs_pair_loader`).
drop(buf);
crate::memory::release();
return Ok(Some((header, window, read)));
}
lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None),
}
}
Ok(None)
}
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
@@ -986,21 +856,11 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let _permit = crate::memory::work_gate().acquire(len);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let mut info = probe_bytes(path, &bytes)?;
info.file_modified = file_modified(path);
Ok(info)
probe_bytes(path, &bytes)
});
(loader, probe)
}
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
/// filesystem doesn't say or the time is before 1970.
fn file_modified(path: &str) -> Option<String> {
let t = std::fs::metadata(path).ok()?.modified().ok()?;
let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?;
Some(crate::import::civil(secs))
}
impl crate::Session {
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
@@ -1048,49 +908,28 @@ mod tests {
let d65 = lightcraft_color::D65;
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some());
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
let (t, tint) = a7m3.as_shot(d65);
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
assert!(bare.legacy_relative && bare.scale_white.is_none());
assert!(bare.legacy_relative && bare.spectral_white.is_none());
assert_eq!(bare.as_shot(d65), {
let (t, tint) = xy_to_temp_tint(d65);
(t.round(), tint.round())
});
// a camera without measured matrices: relative, whatever the multipliers
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none());
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
// a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as
// 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65)
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
(2500.0, g(1336.0, 3672.0)),
(3200.0, g(1720.0, 2620.0)),
(4500.0, g(2256.0, 1880.0)),
(6000.0, g(2680.0, 1544.0)),
(8500.0, g(3136.0, 1300.0)),
]);
let with = ColorData { kelvin_presets: presets, ..Default::default() };
let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2"));
assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some());
let (t, tint) = a99m2.as_shot(d65);
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
assert!(bare.legacy_relative && bare.scale_white.is_none());
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1"));
assert!(other.relative && other.scale_white.is_none());
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none());
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
@@ -1128,7 +967,6 @@ mod tests {
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
assert!(read.header_path, "the header path is what answered");
assert!(!read.planned, "a RAF pointer in the header needs no planner");
assert_eq!(read.total, file.len() as u64);
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
@@ -1147,179 +985,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the
/// file's own orientation, and the EXIF colour space the container declares for its preview.
#[derive(Clone, Copy, Default)]
struct Fixture {
dng: bool,
sony: bool,
orientation: Option<u16>,
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
color_space: Option<u16>,
}
/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the
/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut preview = IfdBuilder::new();
if what.dng {
preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
}
if what.sony {
preview.set(t::MAKE, Value::Ascii("SONY".into()));
}
if let Some(o) = what.orientation {
preview.set(t::ORIENTATION, Value::Short(vec![o]));
}
if let Some(space) = what.color_space {
let mut exif = IfdBuilder::new();
exif.set(0xA001, Value::Short(vec![space]));
if space == 0xFFFF {
let mut interop = IfdBuilder::new();
interop.set(0x0001, Value::Undefined(b"R03".to_vec()));
exif.set_child(t::INTEROP_IFD, interop);
}
preview.set_child(t::EXIF_IFD, exif);
}
preview.set(t::COMPRESSION, Value::Short(vec![7]));
preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
TiffWriter::default().write(&[preview, raw]).expect("the fixture writes")
}
/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview
/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges
/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels
/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline
/// rather than guess, so the caller keeps reading it whole.
#[test]
fn preview_loading_plans_the_ranges_it_needs() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() });
let dir = crate::tests_xmp::temp_dir("preview-plan");
let path = dir.join("preview.dng");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
assert!(read.planned, "the planner is what answered");
assert!(read.header_path, "and it is still a bounded read");
assert_eq!(read.total, file.len() as u64);
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len());
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
// The plan path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
// The same structure without the tag that makes it a raw: the planner declines, and the
// caller's whole-file read is what answers (no guessing at what a file might hold).
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
std::fs::write(&path, &not_a_raw).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
let _ = std::fs::remove_dir_all(&dir);
}
/// A file whose preview the quick path could not show the way the whole-file path does is left to
/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is
/// bigger than that header) and the preview JPEG carries none of its own, so a window read would
/// come out unrotated where the whole-file path rotates it — the plan must decline instead of
/// handing on a wrong picture.
#[test]
fn preview_loading_declines_an_orientation_only_the_whole_file_shows() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
// the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts
// IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image
let mut strip = jpeg.clone();
strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize));
let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() });
let dir = crate::tests_xmp::temp_dir("preview-plan-orientation");
let path = dir.join("rotated.dng");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
let _ = std::fs::remove_dir_all(&dir);
}
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
#[test]
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
use lightcraft_raw::PreviewColorSpace;
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let dir = crate::tests_xmp::temp_dir("preview-plan-space");
let path = dir.join("space.arw");
let path = path.to_string_lossy().into_owned();
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
std::fs::write(&path, &adobe).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
std::fs::write(&path, &srgb).unwrap();
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
assert!(read.planned, "the plan still answers");
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
}
/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as
/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time.
#[test]
fn undated_files_take_the_file_date() {
let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let rgb = vec![128u8; 16 * 16 * 3];
let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let path = dir.join("export.jpg");
std::fs::write(&path, &jpeg).unwrap();
let path = path.to_string_lossy().to_string();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let want = crate::import::civil(mtime as i64);
let (_, probe) = fs_hooks();
let info = probe(&path).unwrap();
assert_eq!(info.captured, None, "the file itself has no date");
assert_eq!(info.file_modified.as_deref(), Some(want.as_str()));
let mut s = crate::Session::new().with_fs();
let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap();
let id = lightcraft_catalog::PhotoId(r.imported[0]);
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
/// file or one that isn't an image.
@@ -1351,17 +1016,6 @@ mod tests {
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets
/// start from), not on the black its transparent pixels store.
#[test]
fn transparent_png_develops_on_white() {
let rgba = [200, 40, 40, 255, 0, 0, 0, 0];
let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap();
let (img, _) = load_bytes(&png, 64).unwrap();
assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0));
assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0));
}
/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample
/// of a block on a grey surface must not become green speckles (the block's green used to take the
/// clip level, which highlight reconstruction then kept).
@@ -2192,37 +1846,6 @@ mod tests {
assert!(e.contains("without an embedded preview"), "{e}");
}
/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606`
/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic.
#[test]
fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mos = |leaf_data: bool| {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] });
if leaf_data {
ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat()));
}
TiffWriter::default().write(&[ifd0]).unwrap()
};
let p = probe_bytes("aptus22.mos", &mos(true)).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30));
assert!(p.preview_only.is_some_and(|why| !why.is_empty()));
let (img, src) = load_bytes(&mos(true), 20).unwrap();
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
// the same grey image without Leaf's block is an ordinary 16-bit TIFF
let plain = probe_bytes("grey.tif", &mos(false)).unwrap();
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12));
}
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
#[test]
+9 -78
View File
@@ -466,18 +466,10 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp
/// Probe files (headers + content hash) as an import would.
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let progress = ScanProgress::default();
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
}
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
probe: Option<&crate::media::FileProbe>,
paths: &[String],
done: &std::sync::atomic::AtomicUsize,
cancel: &std::sync::atomic::AtomicBool,
) -> Vec<Result<ProbeInfo, String>> {
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
use std::sync::atomic::Ordering::Relaxed;
let Some(probe) = probe.cloned() else {
return paths
@@ -505,11 +497,11 @@ fn probe_all(
if i >= paths.len() {
break;
}
if cancel.load(Relaxed) {
if progress.cancel.load(Relaxed) {
break;
}
let r = probe(&paths[i]);
done.fetch_add(1, Relaxed);
progress.done.fetch_add(1, Relaxed);
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
}
});
@@ -521,11 +513,11 @@ fn probe_all(
paths
.iter()
.map(|p| {
if cancel.load(Relaxed) {
if progress.cancel.load(Relaxed) {
return Err("cancelled".into());
}
let r = probe(p);
done.fetch_add(1, Relaxed);
progress.done.fetch_add(1, Relaxed);
r
})
.collect()
@@ -628,7 +620,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
.collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
progress.done.store(todo.len() - missing.len(), Relaxed);
let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter();
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
@@ -649,8 +641,6 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
Some(Ok(info)) => {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
// as the import will date it (a sidecar's date, when there is one, still wins there)
c.captured = c.captured.or_else(|| info.file_modified.clone());
c.preview_only = info.preview_only.clone();
if let Some(h) = &info.content_hash {
match seen_hash.get(h) {
@@ -715,10 +705,6 @@ pub struct ImportJob {
/// Files found so far (folders expanded) and handled so far, for progress.
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
/// Of those, the files that have to be read first (not probed by a preceding scan) and the
/// ones read so far: the step before they are handled.
pub read_total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub read_done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
@@ -871,8 +857,6 @@ impl ImportJob {
now: (s.clock)(),
total: Default::default(),
done: Default::default(),
read_total: Default::default(),
read_done: Default::default(),
})
}
@@ -933,10 +917,8 @@ impl ImportJob {
})
.collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
// Reading the files is the slow part of adding them where they are: it is counted on its
// own, and `cancel` stops it too (a large library takes long enough to want both).
self.read_total.fetch_add(missing.len(), Relaxed);
let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter();
let progress = ScanProgress::default();
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
@@ -992,10 +974,6 @@ impl ImportJob {
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
// no date in the file or its sidecar: the file's own date, as Lightroom does
if info.captured.is_none() {
info.captured = info.file_modified.clone();
}
let stored = match (mode, &self.copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
@@ -1520,51 +1498,4 @@ mod prepared_tests {
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
}
fn unread_paths(n: usize) -> Vec<String> {
(0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect()
}
/// Reading the files of a large import (a Lightroom catalog's originals) reported nothing:
/// the progress stood still at "14 of 24554" for as long as it took.
#[test]
fn prepare_counts_the_files_it_reads() {
use std::sync::atomic::Ordering::Relaxed;
let mut s = Session::new();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)));
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
let (done, last) = (job.read_done.clone(), done_in_probe.clone());
job.probe = Some(std::sync::Arc::new(move |_: &str| {
seen_in_probe.fetch_add(1, Relaxed);
last.fetch_max(done.load(Relaxed), Relaxed);
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
}));
let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false));
assert_eq!(prepared.items.len(), 40);
assert_eq!(seen.load(Relaxed), 40);
assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read");
assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted");
assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own");
}
/// Cancel had no effect until every file had been read.
#[test]
fn cancel_stops_reading_files() {
use std::sync::atomic::Ordering::Relaxed;
let mut s = Session::new();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone());
job.probe = Some(std::sync::Arc::new(move |_: &str| {
seen_in_probe.fetch_add(1, Relaxed);
cancel_in_probe.store(true, Relaxed);
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
}));
let prepared = job.prepare(&unread_paths(400), &cancel);
// at most the files the (up to 8) readers had already started
assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed));
assert!(prepared.items.is_empty());
}
}
+3 -54
View File
@@ -41,25 +41,14 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
let crop =
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
let corr = lens.correction(&shot)?;
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
}
/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the
/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C /
/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor
/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the
/// difference is the tag's whole-millimetre rounding and the body's value stands.
fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -> Option<f64> {
let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c));
match (camera, frame) {
(Some(body), Some(frame)) if frame > body * 1.1 => Some(frame),
(body, frame) => body.or(frame),
}
}
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
crate::files::embedded_lens(raw).or_else(|| {
@@ -109,46 +98,6 @@ mod tests {
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
}
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
/// corners don't get the full frame's corner correction.
#[test]
fn in_camera_crop_mode_uses_the_frames_crop_factor() {
let db = LensDb::parse(
r#"<lensdatabase>
<camera><maker>Sony</maker><model>ILCE-1M2</model><mount>Sony E</mount><cropfactor>1</cropfactor></camera>
<lens><maker>Sigma</maker><model>24-70mm F2.8 DG DN | Art 019</model><mount>Sony E</mount><cropfactor>1.0</cropfactor>
<calibration>
<distortion model="ptlens" focal="70" a="0.0019947" b="0.0035263" c="0.0005016" />
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.4" k2="0" k3="0" />
</calibration>
</lens></lensdatabase>"#,
)
.unwrap();
let m = |f35: Option<f64>| lightcraft_meta::Metadata {
make: Some("SONY".into()),
model: Some("ILCE-1M2".into()),
lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()),
focal_length: Some(70.0),
focal_length_35mm: f35,
f_number: Some(2.8),
..Default::default()
};
let at = |crop: f64, w: f64, h: f64| {
let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };
db.lenses[0].correction(&shot).unwrap()
};
let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap();
assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));
assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame");
// full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor
for f35 in [Some(70.0), Some(71.0), None] {
assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}");
}
assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's");
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
}
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
/// stage with straight lines.
#[test]
+5 -17
View File
@@ -130,8 +130,7 @@ fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
| Op::SetMeta { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. } => Some(*id),
| Op::PushHistory { id, .. } => Some(*id),
Op::Batch { ops } if depth < 8 => {
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
let first = ids.next()??;
@@ -222,8 +221,7 @@ pub struct Session {
pub previous_active: Option<PhotoId>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
/// Built-in then user presets; its generation tells savers and caches when it changed.
pub presets: presets::PresetList,
pub presets: Vec<lightcraft_develop::Preset>,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
@@ -356,7 +354,7 @@ impl Session {
library_folder: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::PresetList::new(presets::builtin()),
presets: presets::builtin(),
profile_favorites: Vec::new(),
profile_recent: Vec::new(),
journal: Vec::new(),
@@ -883,17 +881,11 @@ impl Session {
}
}
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort direction (the grid groups by import day);
// a batch stays in file order (name, then id)
// newest import first, whatever the sort (the grid groups by import day)
let cat = &self.catalog;
visible.sort_by(|a, b| {
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str());
let by_name = match (name(a), name(b)) {
(Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y),
_ => std::cmp::Ordering::Equal,
};
key(b).cmp(&key(a)).then(by_name).then(a.cmp(b))
key(b).cmp(&key(a)).then(a.cmp(b))
});
}
if self.source == LibrarySource::Missing {
@@ -1007,8 +999,6 @@ mod tests_folders;
#[cfg(test)]
mod tests_forget_local;
#[cfg(test)]
mod tests_history_cursor;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
@@ -1029,8 +1019,6 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
+10 -69
View File
@@ -88,8 +88,7 @@ pub struct Library {
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
/// What presets.json holds (see [`PresetsSaved`]).
presets_saved: PresetsSaved,
presets_written: String,
view_written: Vec<u8>,
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
@@ -124,53 +123,6 @@ struct PresetsFile {
profile_recent: Vec<String>,
}
/// The state presets.json was last written (or read) from. Saving checks the preset list's
/// generation and the small profile lists before serializing anything: with a 10 000-preset
/// collection presets.json is ~40 MB, and serializing it after every command made each slider
/// tick take seconds.
#[derive(Default)]
struct PresetsSaved {
generation: u64,
profile_favorites: Vec<String>,
profile_recent: Vec<String>,
/// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten.
hash: u64,
}
impl PresetsSaved {
fn state_of(s: &Session) -> PresetsSaved {
PresetsSaved {
generation: s.presets.generation(),
profile_favorites: s.profile_favorites.clone(),
profile_recent: s.profile_recent.clone(),
hash: 0,
}
}
/// `s` changed its presets or profile lists since this state.
fn differs(&self, s: &Session) -> bool {
self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent
}
}
fn bytes_hash(b: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut h);
h.finish()
}
#[cfg(test)]
thread_local! {
static PRESETS_SERIALIZED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread serialized the preset list (tests).
#[cfg(test)]
pub(crate) fn presets_serialized() -> usize {
PRESETS_SERIALIZED.with(|c| c.get())
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct ViewFile {
@@ -235,8 +187,6 @@ struct PrefsFile {
}
fn presets_json(s: &Session) -> String {
#[cfg(test)]
PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1));
let presets = &s.presets;
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -464,9 +414,8 @@ impl Session {
for p in &mut self.presets {
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
}
let mut ids: std::collections::HashSet<String> = self.presets.iter().map(|p| p.id.clone()).collect();
for u in f.user {
if ids.insert(u.id.clone()) {
if !self.presets.iter().any(|p| p.id == u.id) {
self.presets.push(u);
}
}
@@ -498,8 +447,7 @@ impl Session {
{
self.selection = Selection::single(*first);
}
// presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes)
let presets_saved = PresetsSaved::state_of(self);
let presets_written = presets_json(self);
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
@@ -516,7 +464,7 @@ impl Session {
unsaved_error: None,
retry_at: None,
forgot_local: None,
presets_saved,
presets_written,
view_written,
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
warnings_reported: 0,
@@ -578,21 +526,14 @@ impl Session {
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json"));
if !due {
return Ok(());
}
let presets = presets_json(self);
let mut state = PresetsSaved::state_of(self);
state.hash = bytes_hash(presets.as_bytes());
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
// the state stays stale: the next save tries again
log::error!("library: presets: {e}");
} else {
lib.presets_saved = state;
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
log::error!("library: presets: {e}");
} else {
lib.presets_written = presets;
}
}
Ok(())
}
+79 -7
View File
@@ -698,14 +698,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut ops = Vec::new();
let now = (s.clock)();
let mut missing = Vec::new();
// Normal import counts probed originals; catalog import also adds offline records,
// distinct originals with identical bytes, and virtual copies.
let mut imported = files.imported.len();
let mut unmapped = BTreeMap::new();
let mut preserved = 0;
// Masters precede their virtual copies.
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
let indexed = index
.photos
.get(&identity(&data, src))
@@ -736,9 +743,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
} else {
let id = s.catalog.alloc_photo_id();
let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone());
if missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
let mut p = Photo::new(
id,
Source::File { path: src.path.clone() },
@@ -807,6 +811,12 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
if keep_develop {
(p.develop, p.edited) = previous_develop;
}
let photo_id = p.id;
// Only virtual-copy masters need a second owned photo while operations are staged.
// Keeping every photo here doubled the new-record working set on large catalogs.
if copy_masters.contains(&src.source_id) {
planned.insert(photo_id, p.clone());
}
if s.catalog.photo(p.id).is_some() {
ops.extend([
Op::SetRating { id: p.id, rating: p.rating },
@@ -817,10 +827,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
]);
ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() });
} else {
ops.push(Op::AddPhoto { photo: Box::new(p.clone()) });
imported += 1;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
by_path.entry(key(&src.path)).or_insert(p.id);
planned.insert(p.id, p);
by_path.entry(key(&src.path)).or_insert(photo_id);
}
let mut albums = HashMap::new();
for collection in &data.collections {
@@ -893,7 +903,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
@@ -944,6 +954,7 @@ mod tests {
let data = sample();
let r = apply(&mut s, data.clone(), false).unwrap();
assert_eq!(r["photos"], 2);
assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added");
assert_eq!(r["missing"].as_array().unwrap().len(), 1);
let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap();
assert_eq!(master.rating, 5);
@@ -1241,6 +1252,10 @@ mod tests {
s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap();
let second = apply(&mut s, data, true).unwrap();
assert_eq!(first["mapping"], second["mapping"]);
assert_eq!(first["imported"], 2);
assert_eq!(second["imported"], 0, "reimport updates records instead of adding them");
assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too");
assert_eq!(second["library"], dir.to_string_lossy().as_ref());
assert_eq!(s.catalog.len(), 2);
assert_eq!(s.catalog.albums().count(), 2);
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));
@@ -1359,6 +1374,63 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("source.lrcat");
let root = dir.join("offline");
let tables = photo_tables((root.to_str().unwrap(), ""));
std::fs::write(&source, native_catalog(&tables)).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err("a known-missing original must not be probed".into())
}));
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
assert_eq!(probes.load(Ordering::Relaxed), 0);
let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(result.report["imported"], 1);
assert_eq!(result.report["failed"], json!([]));
assert_eq!(result.report["missing"].as_array().unwrap().len(), 1);
let photo = s.catalog.photos().next().unwrap();
assert_eq!(photo.rating, 5);
assert_eq!((photo.width, photo.height), (20, 10));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.catalog.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn probed_original_is_counted_once_and_reimport_adds_nothing() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id()));
std::fs::create_dir_all(dir.join("Photos")).unwrap();
let source = dir.join("source.lrcat");
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() })
}));
for expected in [1, 0] {
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(completion.report["imported"], expected);
assert_eq!(completion.report["missing"], json!([]));
assert_eq!(completion.report["failed"], json!([]));
assert_eq!(s.catalog.len(), 1);
}
assert_eq!(probes.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_root_missing_here_is_found_relative_to_the_catalog() {
let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id()));
+52 -132
View File
@@ -129,65 +129,6 @@ pub struct LightroomCompletion {
/// Alias used by hosts that call the result a commit.
pub type LightroomCommit = LightroomCompletion;
/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LightroomStep {
/// Reading the catalog's tables.
Catalog,
/// Reading the photos the catalog names (headers, content hash): nearly all of the time.
ReadPhotos,
/// Readying the photos that were read for the catalog.
AddPhotos,
}
/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it.
#[derive(Clone, Default)]
pub struct LightroomProgress {
/// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]).
pub catalog_total: Arc<AtomicUsize>,
pub catalog_done: Arc<AtomicUsize>,
read_total: Arc<AtomicUsize>,
read_done: Arc<AtomicUsize>,
add_total: Arc<AtomicUsize>,
add_done: Arc<AtomicUsize>,
/// The catalog is read: the photos are next.
photos: Arc<AtomicBool>,
}
impl LightroomProgress {
/// Reading the photos is nearly all of an import's time; the catalog and readying the photos
/// take the rest of [`LightroomProgress::fraction`].
const CATALOG_SHARE: f32 = 0.05;
const READ_SHARE: f32 = 0.85;
/// The step under way, with how much of it is done and its total (0 while not yet known).
pub fn step(&self) -> (LightroomStep, usize, usize) {
let load = |n: &AtomicUsize| n.load(Ordering::Relaxed);
if !self.photos.load(Ordering::Relaxed) {
return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total));
}
let (read_done, read_total) = (load(&self.read_done), load(&self.read_total));
let (add_done, add_total) = (load(&self.add_done), load(&self.add_total));
// nothing to read (every photo is already in the library) goes straight to adding
if read_done < read_total || (read_total == 0 && add_done == 0) {
(LightroomStep::ReadPhotos, read_done, read_total)
} else {
(LightroomStep::AddPhotos, add_done, add_total)
}
}
/// The whole preparation, 0..=1.
pub fn fraction(&self) -> f32 {
let (step, done, total) = self.step();
let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) };
match step {
LightroomStep::Catalog => Self::CATALOG_SHARE * part,
LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part,
LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part,
}
}
}
/// A Lightroom import detached from the owner thread.
pub struct LightroomJob {
path: PathBuf,
@@ -195,7 +136,6 @@ pub struct LightroomJob {
import: ImportJob,
token: LightroomLibraryToken,
archive_dir: Option<PathBuf>,
progress: LightroomProgress,
}
impl LightroomJob {
@@ -205,25 +145,33 @@ impl LightroomJob {
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
let import = ImportJob::new(s, opts)?;
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
let progress = LightroomProgress {
read_total: Arc::clone(&import.read_total),
read_done: Arc::clone(&import.read_done),
add_total: Arc::clone(&import.total),
add_done: Arc::clone(&import.done),
..LightroomProgress::default()
};
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress })
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
}
/// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs.
pub fn progress(&self) -> LightroomProgress {
self.progress.clone()
pub fn total(&self) -> usize {
self.import.total.load(Ordering::Relaxed)
}
pub fn done(&self) -> usize {
self.import.done.load(Ordering::Relaxed)
}
pub fn progress(&self) -> (usize, usize) {
(self.done(), self.total())
}
pub fn token(&self) -> &LightroomLibraryToken {
&self.token
}
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.total)
}
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.done)
}
/// Finish an owner commit's deferred archive-index write on a worker.
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
finalization.finish()
@@ -234,29 +182,19 @@ impl LightroomJob {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
let mut data =
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
self.import.total.store(1, Ordering::Relaxed);
self.import.done.store(0, Ordering::Relaxed);
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
let paths: Vec<String> = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.map(|photo| photo.path.clone())
.collect();
let missing_sources = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.filter(|photo| !Path::new(&photo.path).is_file())
.map(|photo| normalize(&photo.path))
.collect();
// the catalog step's last unit (validation, and looking for each original), then the photos
self.progress.catalog_done.fetch_add(1, Ordering::Relaxed);
self.progress.photos.store(true, Ordering::Relaxed);
let normal = self.import.prepare(&paths, cancel);
self.import.done.fetch_add(1, Ordering::Relaxed);
let (paths, missing_sources) = original_paths(&data, cancel)?;
// Catalog entries name files, never folders. Missing originals already have enough
// catalog metadata for placeholders; probing them again only adds failed I/O and errors.
self.import.total.fetch_add(paths.len(), Ordering::Relaxed);
let normal = self.import.prepare_files(paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
return Err(error("Lightroom import cancelled"));
@@ -316,6 +254,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom)
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
}
fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec<String>, HashSet<String>)> {
let mut paths = Vec::new();
let mut missing = HashSet::new();
let mut seen = HashSet::new();
for photo in &data.photos {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 {
continue;
}
let key = normalize(&photo.path);
if !seen.insert(key.clone()) {
continue;
}
if Path::new(&photo.path).is_file() {
paths.push(photo.path.clone());
} else {
missing.insert(key);
}
}
Ok((paths, missing))
}
fn normalize(path: &str) -> String {
let path = path.replace('\\', "/");
if cfg!(windows) { path.to_lowercase() } else { path }
@@ -388,48 +350,6 @@ mod tests {
}
}
/// One count for the whole import read "14 of 24554" while the photos were read, then twice
/// the number of photos: each step has its own count, and the whole only moves forward.
#[test]
fn progress_names_each_step_with_its_own_count() {
let p = LightroomProgress::default();
let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed);
assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0));
assert_eq!(p.fraction(), 0.0);
set(&p.catalog_total, 14);
set(&p.catalog_done, 7);
assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14));
let mut last = p.fraction();
assert!(last > 0.0);
let mut forward = |p: &LightroomProgress| {
let f = p.fraction();
assert!(f >= last && f <= 1.0, "{f} after {last}");
last = f;
};
set(&p.catalog_done, 14);
p.photos.store(true, Ordering::Relaxed);
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet");
forward(&p);
set(&p.add_total, 100);
set(&p.read_total, 100);
set(&p.read_done, 40);
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100));
forward(&p);
set(&p.read_done, 100);
set(&p.add_done, 25);
assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100));
forward(&p);
set(&p.add_done, 100);
forward(&p);
assert!(p.fraction() > 0.999);
// every photo already in the library: nothing to read
let known = LightroomProgress::default();
known.photos.store(true, Ordering::Relaxed);
set(&known.add_total, 5);
set(&known.add_done, 5);
assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5));
}
#[test]
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
let mut session = crate::Session::new();
+16 -174
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 = 62;
pub const RENDER_CACHE_VERSION: u64 = 61;
/// 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];
@@ -223,13 +223,6 @@ impl DecodedSource {
#[derive(Clone)]
pub enum SourceRef {
Loaded(Box<DecodedSource>),
/// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of
/// the photo open in the loupe is cut from its preview-level source instead of decoding the
/// file again (a second or more for a 64 MP raw).
Fit {
from: Box<DecodedSource>,
max_edge: usize,
},
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
@@ -258,13 +251,6 @@ impl SourceRef {
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Fit { from, max_edge } => {
if from.image.width.max(from.image.height) <= *max_edge {
return Ok((**from).clone());
}
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
let plain = || match loader {
@@ -328,8 +314,6 @@ pub struct MediaCache {
full: Vec<(PhotoId, DecodedSource, u64)>,
/// How many full-resolution originals to keep (LRU, at least one).
full_capacity: usize,
/// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget.
on_screen: Option<PhotoId>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -407,7 +391,6 @@ impl Default for MediaCache {
previews: Vec::new(),
full: Vec::new(),
full_capacity: 1,
on_screen: None,
preview_capacity: 0,
budget,
file_loader: None,
@@ -464,16 +447,6 @@ impl MediaCache {
self.trim_full();
}
/// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays
/// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it
/// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once.
pub fn set_on_screen(&mut self, id: Option<PhotoId>) {
if self.on_screen != id {
self.on_screen = id;
self.enforce_budget();
}
}
fn trim_full(&mut self) {
while self.full.len() > self.full_capacity {
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
@@ -502,20 +475,15 @@ impl MediaCache {
/// Evict least recently used entries across the source caches and the rendered previews
/// until they fit the budget. The newest preview-level source (the photo on screen, or the
/// one just decoded) is never evicted here, nor is the original of the photo on screen
/// ([`Self::set_on_screen`]).
/// one just decoded) is never evicted here.
pub fn enforce_budget(&mut self) {
// The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB)
// nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and
// thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step.
let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum();
let mut held = self.held().saturating_sub(pinned);
let mut held = self.held();
while held > self.budget {
let newest_preview = self.previews.iter().map(|e| e.2).max();
let candidates = [
self.thumbs.oldest_tick().map(|t| (t, 0)),
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)),
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
self.rendered.oldest_tick().map(|t| (t, 3)),
];
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
@@ -657,15 +625,6 @@ impl MediaCache {
if cached && let Some(a) = self.get_source(p.id, level) {
return SourceRef::Loaded(Box::new(a));
}
// a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not
// with a denoised picture, which the thumbnail would lose
if cached
&& level == SourceLevel::Thumb
&& self.denoise.spec(p.id).is_none()
&& let Some(big) = self.get_source(p.id, SourceLevel::Preview)
{
return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() };
}
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
let max_edge = level.max_edge().min(match (&p.source, level) {
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
@@ -844,31 +803,7 @@ impl RenderJob {
self
}
/// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting
/// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace
/// it, so a few such panics used to stop every render).
pub fn run(self) -> RenderResult {
let failed = self.failed(String::new());
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed })
}
/// This job's result when it fails with `e`.
fn failed(&self, e: String) -> RenderResult {
RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
}
}
fn run_unguarded(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get_at(self.cache_generation, *key)
{
@@ -942,7 +877,18 @@ impl RenderJob {
display,
}
}
Err(e) => self.failed(e),
Err(e) => RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
},
}
}
}
@@ -1030,25 +976,7 @@ pub struct QuickJob {
}
impl QuickJob {
/// The stand-in, from the first source that has one. A panic inside is this job's failure
/// (see [`RenderJob::run`]).
pub fn run(self) -> RenderResult {
let (request_id, photo, key) = (self.request_id, self.photo, self.key);
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult {
request_id,
source_key: None,
photo,
level: SourceLevel::Thumb,
key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
})
}
fn run_unguarded(self) -> RenderResult {
let (photo, key) = (self.photo, self.key);
let request_id = self.request_id;
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
@@ -1551,10 +1479,6 @@ pub struct ProbeInfo {
pub kind: MediaKind,
pub file_size: u64,
pub captured: Option<String>,
/// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the
/// capture date of a file whose metadata has none, as Lightroom does (exports saved without
/// metadata would otherwise all land under "Unknown Date").
pub file_modified: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
@@ -1664,31 +1588,6 @@ mod thumbnail_hash_tests {
s.accept(&old);
assert_eq!(s.media.source_usage().0, 0);
}
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
/// for renders and for stand-ins, answering the request it came from.
#[test]
fn a_render_that_panics_fails_instead_of_never_answering() {
let mut s = session();
s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
let mut job = s.thumb_job(PhotoId(1), 256).unwrap();
job.request_id = 42;
let (key, level, source_key) = (job.key, job.level, job.source_key);
let r = job.run();
assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key));
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug"));
let quick = QuickJob {
request_id: 7,
photo: PhotoId(1),
key: 9,
cached: Vec::new(),
embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option<Rgba8> { panic!("preview reader bug") }), 256)),
small: None,
};
let r = quick.run();
assert_eq!((r.request_id, r.key), (7, 9));
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug"));
}
}
#[cfg(test)]
@@ -1894,45 +1793,6 @@ mod tests {
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
#[test]
fn the_on_screen_original_does_not_evict_the_other_sources() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
}
}
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
#[test]
fn the_original_on_screen_stays_even_over_budget() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.set_on_screen(Some(PhotoId(2)));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
assert!(m.held() <= 20 * mb, "{}", m.held());
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
@@ -2071,24 +1931,6 @@ mod tests {
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
fn a_thumbnail_is_cut_from_the_decoded_preview() {
let mut s = crate::Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
let job = s.thumb_job(p.id, 512).unwrap();
assert!(matches!(job.source, SourceRef::Fit { .. }));
let r = job.run();
let thumb = r.rendered.as_ref().unwrap();
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
s.accept(&r);
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
}
#[test]
fn has_source_follows_what_the_session_accepted() {
let mut s = crate::Session::with_demo();
+1 -3
View File
@@ -110,9 +110,7 @@ impl MergeJob {
.par_iter()
.map(|(_, path)| {
let bytes = (self.read)(path)?;
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
.map_err(|e| format!("{path}: {e}"));
let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}"));
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
f
+3 -81
View File
@@ -23,73 +23,6 @@ pub struct PresetFile {
pub presets: Vec<Preset>,
}
/// The session's presets (built-in, then the user's) with a generation that changes on every
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
///
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
/// Generations are unique across all lists in the process.
#[derive(Clone, Debug)]
pub struct PresetList {
items: Vec<Preset>,
generation: u64,
}
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_generation() -> u64 {
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PresetList {
pub fn new(items: Vec<Preset>) -> PresetList {
PresetList { items, generation: next_generation() }
}
/// Changes whenever the list may have changed.
pub fn generation(&self) -> u64 {
self.generation
}
}
impl Default for PresetList {
fn default() -> PresetList {
PresetList::new(Vec::new())
}
}
impl std::ops::Deref for PresetList {
type Target = Vec<Preset>;
fn deref(&self) -> &Vec<Preset> {
&self.items
}
}
impl std::ops::DerefMut for PresetList {
fn deref_mut(&mut self) -> &mut Vec<Preset> {
self.generation = next_generation();
&mut self.items
}
}
impl<'a> IntoIterator for &'a PresetList {
type Item = &'a Preset;
type IntoIter = std::slice::Iter<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter()
}
}
impl<'a> IntoIterator for &'a mut PresetList {
type Item = &'a mut Preset;
type IntoIter = std::slice::IterMut<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.generation = next_generation();
self.items.iter_mut()
}
}
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
pub fn slug(s: &str) -> String {
let mut out = String::new();
@@ -174,33 +107,22 @@ impl Session {
/// Add imported presets. Identical presets (same name, group and settings) already present are
/// skipped; id clashes get a fresh id. Returns the ids added.
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
use std::collections::{HashMap, HashSet};
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (i, q) in self.presets.iter().enumerate() {
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
}
let mut added = Vec::new();
for mut p in presets {
let key = (p.name.clone(), p.group.clone());
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
if same {
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
continue;
}
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
let mut id = base.clone();
let mut n = 2;
while ids.contains(&id) {
while self.presets.iter().any(|q| q.id == id) {
id = format!("{base}-{n}");
n += 1;
}
p.id = id;
}
p.builtin = false;
ids.insert(p.id.clone());
by_name.entry(key).or_default().push(self.presets.len());
added.push(p.id.clone());
self.presets.push(p);
}
+17 -88
View File
@@ -319,17 +319,6 @@ pub(crate) trait MoveFs {
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
/// The folder lists an entry spelled exactly like `p` (letter case included). On a
/// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two
/// apart. When the folder can't be listed: `true`, so nothing is skipped.
fn listed(&self, p: &Path) -> bool {
let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true };
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
match std::fs::read_dir(dir) {
Ok(entries) => entries.flatten().any(|e| e.file_name() == name),
Err(_) => true,
}
}
/// Content observed in a packet, independent of its pathname or inode. Model volumes
/// override this; disk implementations stream a bounded buffer without retaining the XML.
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
@@ -377,23 +366,15 @@ impl MoveFs for RealFs {
}
#[cfg(not(unix))]
{
// no stable file id in std here: `a` and `b` are one file under two spellings when
// they look alike and the folder doesn't list both (on a case-sensitive folder
// holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the
// listed one: the answer must not depend on the argument order.
let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false };
let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase();
if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
// no stable file id in std here: `b` is `a` under another spelling when they look
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
return false;
}
let Ok(entries) = std::fs::read_dir(dir) else { return false };
let (mut listed_a, mut listed_b) = (false, false);
for e in entries.flatten() {
let name = e.file_name();
listed_a |= name == name_a;
listed_b |= name == name_b;
}
!(listed_a && listed_b)
!entries.flatten().any(|e| e.file_name() == name)
}
}
@@ -718,14 +699,15 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
if fs.exists(t) && !is_respelling(fs, f, t) {
return Err(fail(format!("{to} already exists")));
}
// An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the
// lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`.
let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc));
let mut packets: Vec<RetainedPacket> =
sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect();
let mut packets: Vec<RetainedPacket> = sidecars(from)
.into_iter()
.map(|(p, _, _)| p)
.filter(|p| fs.is_file(p))
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
.collect();
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
let mut done: Vec<SidecarMove> = Vec::new();
let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| {
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
let mut dst = crate::sidecar::sidecar_path(to, naming);
if upper {
dst = dst.with_extension("XMP");
@@ -1142,24 +1124,6 @@ pub(crate) mod tests {
use super::*;
use std::cell::RefCell;
/// Windows has no file id in std: two spellings of one file are the same file whichever is
/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the
/// known `Trip.xmp`), and only the spelling on disk is listed.
#[cfg(windows)]
#[test]
fn same_file_spellings_are_symmetric_on_windows() {
let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp"));
std::fs::write(&real, b"packet").unwrap();
std::fs::write(&other, b"packet").unwrap();
assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real));
assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias));
assert!(RealFs.listed(&real) && !RealFs.listed(&alias));
std::fs::remove_dir_all(&dir).unwrap();
}
/// The cross-volume move's check compares files chunk by chunk: equal files across several
/// chunks, a difference in the last chunk, different lengths, a missing file.
#[test]
@@ -1207,10 +1171,7 @@ pub(crate) mod tests {
}
fn idx(&self, p: &Path) -> Option<usize> {
let p = Self::norm(p);
self.files.borrow().iter().position(|(f, _)| {
let f = f.replace('\\', "/");
if self.ci { folded_path(&f) == folded_path(&p) } else { f == p }
})
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
}
/// The listing: (exact path, contents), sorted.
pub fn listing(&self) -> Vec<(String, String)> {
@@ -1235,7 +1196,7 @@ pub(crate) mod tests {
self.idx(a).is_some() && self.idx(a) == self.idx(b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
@@ -1246,7 +1207,7 @@ pub(crate) mod tests {
Ok(())
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
@@ -1262,10 +1223,6 @@ pub(crate) mod tests {
self.files.borrow_mut().remove(i);
Ok(())
}
fn listed(&self, p: &Path) -> bool {
let p = Self::norm(p);
self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p)
}
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
let stem = Self::norm(&sidecar.with_extension(""));
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
@@ -1336,7 +1293,7 @@ pub(crate) mod tests {
} else {
serde_json::json!({"path": from, "into": root.join("Archive")})
};
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") };
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
let before = s.catalog.to_snapshot();
let history = (s.undo.len(), s.redo.len());
assert!(s.execute(command, &params).is_err());
@@ -1464,34 +1421,6 @@ pub(crate) mod tests {
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
}
/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed
/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling
/// is the one reported and queued.
#[test]
fn case_insensitive_renames_keep_each_sidecars_spelling() {
let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]);
move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap();
move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap();
assert_eq!(
fs.listing(),
vec![
("/p/c.jpg".into(), "A".into()),
("/p/c.jpg.XMP".into(), "full".into()),
("/p/c.xmp".into(), "stem".into()),
("/p/d.jpg".into(), "B".into()),
("/p/d.xmp".into(), "mixed".into())
]
);
// the move of the sidecar's real spelling is the one attempted (and here fails): the photo
// goes back and nothing changed
let before = fs.listing();
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into()];
let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err();
assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}");
assert!(!e.moved && e.pending_sidecars.is_empty());
assert_eq!(fs.listing(), before);
}
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
/// their new names (an undoable partial rename), so nothing goes missing.
#[test]
+1 -6
View File
@@ -566,12 +566,7 @@ impl Session {
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
// Keep an existing packet's spelling/style so foreign properties survive this save and
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
// On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder
// lists comes first, so the atomic write doesn't rename the packet.
use crate::rename::MoveFs;
let existing: Vec<PathBuf> = candidates.into_iter().filter(|path| path.is_file()).collect();
let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned();
let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred);
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
@@ -1,7 +1,6 @@
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::{same_path_for_volume, same_path_or_file};
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -17,7 +16,7 @@ impl MoveFs for Faults {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| same_path_or_file(p, a)) {
if self.0.iter().any(|p| p == a) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
} else {
RealFs.rename_no_replace(a, b)
@@ -262,8 +261,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
assert!(!second_packet.exists());
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from);
assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to);
assert_eq!(pending[0].from, shared);
assert_eq!(pending[0].to, second_packet);
let catalog_before = s.catalog.to_snapshot();
let history_before = (s.undo.len(), s.redo.len(), s.commits());
@@ -1,7 +1,6 @@
//! Native files and genuine Session entry points: packet ownership survives partial renames.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::same_path_or_file;
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -17,7 +16,7 @@ impl MoveFs for Faults {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| same_path_or_file(p, a)) {
if self.0.iter().any(|p| p == a) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
} else {
RealFs.rename_no_replace(a, b)
@@ -1,7 +1,6 @@
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::same_path_or_file;
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -20,14 +19,14 @@ impl MoveFs for PartialRename {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) {
if a == self.sidecar || a == self.photo {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
} else {
RealFs.rename_no_replace(a, b)
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if same_path_or_file(a, &self.sidecar) {
if a == self.sidecar {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
} else {
RealFs.copy_no_replace(a, b)
@@ -152,9 +151,7 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
assert_eq!(parsed.title.as_deref(), Some("Final title"));
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
if upper {
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
assert!(!listed, "merge uses the repaired uppercase packet");
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
}
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
s.execute(step, &json!({})).unwrap();
+15 -241
View File
@@ -55,28 +55,6 @@ const HEADER_MAX: u64 = 4096;
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
const LOOK_FIELD: &str = "look_version";
/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's
/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame
/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong
/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to
/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size
/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only
/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test
/// below; drop this to 95 for ~1.4× the size and roughly half the gain.)
const JPEG_QUALITY: u8 = 97;
/// The header field that records which version of this module's own encoding wrote a proxy's pixels
/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which
/// tracks the camera-look fit that is baked into those pixels.
const ENC_FIELD: &str = "enc";
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
/// makes every proxy from before this version stale. Version 2 is the first to record the field at
/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
pub(crate) const ENCODE_VERSION: u64 = 2;
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
// scale so all but the brightest 0.05 % fit into 0..1
@@ -100,19 +78,13 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
JPEG_QUALITY,
92,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.map_err(|e| e.to_string())?;
let mut out = MAGIC.to_vec();
let mut head = serde_json::json!({
"w": img.width,
"h": img.height,
"scale": scale,
LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
ENC_FIELD: ENCODE_VERSION,
});
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
@@ -293,17 +265,10 @@ fn header_look_version(head: &serde_json::Value) -> u64 {
head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
/// The encode version a header records: a missing, negative, fractional or otherwise malformed
/// value counts as 0 (older than any real version), like [`header_look_version`].
fn header_encode_version(head: &serde_json::Value) -> u64 {
head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
/// Whether the complete proxy at `path` is out of date for this build: written by an older
/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's
/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a
/// newer build is left alone (rebuilding it here would only undo that build's work), as is one
/// that can't be read: those are [`is_valid`]'s business.
pub fn is_stale(path: &Path) -> bool {
use std::io::Read;
if !is_valid(path) {
@@ -318,18 +283,15 @@ pub fn is_stale(path: &Path) -> bool {
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
rest.get(..nl)
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
}
/// The marker file in a smart previews folder: the camera-look fit version and encode version its
/// proxies were last checked at ([`look_checked`]).
/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked
/// at ([`look_checked`]).
const LOOK_MARKER: &str = "look-version";
/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's
/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts
/// as not, and so does one written before the encode version was recorded — it only says the look
/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`]
/// finds.
/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's
/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not.
pub fn look_checked(dir: &Path) -> bool {
use std::io::Read;
let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false };
@@ -337,20 +299,14 @@ pub fn look_checked(dir: &Path) -> bool {
if f.take(64).read_to_string(&mut text).is_err() {
return false;
}
let mut versions = text.split_whitespace();
let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false };
match (look.parse::<u64>(), enc.parse::<u64>()) {
(Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION,
_ => false,
}
text.trim().parse::<u64>().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION))
}
/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode
/// version.
/// Record that the proxies in `dir` were checked at this build's camera-look fit version.
pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
let path = dir.join(LOOK_MARKER);
let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION);
lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Load the proxy at `path`.
@@ -495,186 +451,4 @@ mod tests {
assert!(mean < 0.02, "mean encoded error {mean}");
assert!(decode(b"nope").is_err());
}
/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into
/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never
/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat
/// row-major layout are the real ones.
fn smooth_scene(w: usize, h: usize) -> Rgb32f {
let mut img = lightcraft_scenes::demo_library()[0].render(w, h);
let (w, h) = (img.width.max(1), img.height.max(1));
for (i, px) in img.data.iter_mut().enumerate() {
let u = (i % w) as f32 / (w - 1).max(1) as f32;
let v = (i / w) as f32 / (h - 1).max(1) as f32;
let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step
let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v];
let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)];
*px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0));
}
img
}
/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The
/// input is clamped into 0..1, the only range a proxy ever stores.
fn lab(c: [f32; 3]) -> [f64; 3] {
let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64);
let (x, y, z) = (
(0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047,
0.2126 * r + 0.7152 * g + 0.0722 * b,
(0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883,
);
let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 };
let (fx, fy, fz) = (f(x), f(y), f(z));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a
/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders.
fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
let n = a.data.len().min(b.data.len());
if n == 0 {
return 0.0;
}
(0..n)
.map(|i| {
let (p, q) = (lab(a.data[i]), lab(b.data[i]));
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
})
.sum::<f64>()
/ n as f64
}
/// A deliberately frozen copy of the pixel mapping and container this module used before the
/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the
/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on
/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the
/// quality raise bought instead of trusting a written-down number.
fn encode_at_q92(img: &Rgb32f) -> Vec<u8> {
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
let scale = if lum.is_empty() {
1.0
} else {
let k = ((lum.len() as f64) * 0.9995) as usize;
let k = k.min(lum.len() - 1);
let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
v.max(1.0)
};
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
92,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.unwrap();
// the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field
let mut out = MAGIC.to_vec();
let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
out.extend_from_slice(head.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&jpg);
out
}
/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one
/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its
/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a
/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels
/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share
/// eightfold, which is what a per-pixel ΔE drowns in.
fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
let w = a.width.min(b.width).max(1);
let h = a.height.min(b.height).max(1);
let (mut sum, mut n) = (0.0f64, 0usize);
for by in (0..h).step_by(8) {
for bx in (0..w).step_by(8) {
let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32);
for y in by..(by + 8).min(h) {
for x in bx..(bx + 8).min(w) {
if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) {
for k in 0..3 {
sa[k] += f64::from(ca[k]);
sb[k] += f64::from(cb[k]);
}
count += 1;
}
}
}
let d = f64::from(count.max(1));
let (pa, pb) = (
lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]),
lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]),
);
sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt();
n += 1;
}
}
sum / n.max(1) as f64
}
/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit
/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene,
/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the
/// quality raise is reverted and never trips on the honest result (measured 0.683).
const PROXY_BLOCK_GAIN: f64 = 0.8;
#[test]
fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() {
let img = smooth_scene(256, 160);
let ours = decode(&encode(&img, None).unwrap()).unwrap().0;
let before = decode(&encode_at_q92(&img)).unwrap().0;
let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before));
let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before));
println!(
"smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \
(per-pixel {:.3} vs {:.3})",
block.0, block.1, pixel.0, pixel.1
);
assert!(block.1 > 0.0, "the old encoder must measure something to compare against");
assert!(
block.0 <= PROXY_BLOCK_GAIN * block.1,
"q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \
(the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)",
block.0,
PROXY_BLOCK_GAIN * block.1,
block.1
);
}
#[test]
fn a_proxy_from_an_older_encode_is_stale() {
let dir = temp("encode-version");
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let bytes = encode(&img, None).unwrap();
let path = dir.join("p.lcsp");
std::fs::write(&path, &bytes).unwrap();
assert!(!is_stale(&path), "this build's own proxy is up to date");
// one written before the encode version was recorded is not
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION));
let mut older = head.as_object().cloned().unwrap_or_default();
older.remove(ENC_FIELD);
let mut old = MAGIC.to_vec();
old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes());
old.extend_from_slice(&bytes[nl..]);
std::fs::write(&path, &old).unwrap();
assert!(is_valid(&path), "losing the field keeps the file readable");
assert!(is_stale(&path), "an older encoding is rebuilt");
// the marker records both versions, and one from before the encode version forces a scan
assert!(!look_checked(&dir));
mark_look_checked(&dir).unwrap();
assert!(look_checked(&dir));
std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap();
assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild");
let _ = std::fs::remove_dir_all(&dir);
}
}
+2 -20
View File
@@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() {
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
// back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388)
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
let g = geometry(&s);
assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0));
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
assert!(g.is_within_image(w, h));
}
@@ -106,21 +106,3 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(geometry(&s), start));
}
#[test]
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
// issue #1388: slide the Straighten slider out and back, in one drag or in several
let mut s = Session::with_demo();
let (w, h) = dims(&s);
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] {
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
let g = geometry(&s);
assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap();
s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap();
let g = geometry(&s);
assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}");
}
-23
View File
@@ -277,29 +277,6 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
let _ = std::fs::remove_dir_all(&x.dir);
}
/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then
/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described.
#[test]
fn inspecting_another_apps_model_folder_says_what_is_missing() {
let dir = temp_dir("denoise-foreign");
let mut s = Session::new().with_fs();
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
let src = dir.join("rawdenoise");
std::fs::create_dir_all(&src).unwrap();
let onnx = src.join("rawdenoise.onnx");
std::fs::write(&onnx, b"not an onnx file").unwrap();
let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
let e = inspect();
assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}");
std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap();
let e = inspect();
assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}");
std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap();
let e = inspect();
assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
let mut x = setup("background", true);
-15
View File
@@ -455,21 +455,6 @@ fn hdr_avif_export_is_pq_rec2020() {
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
}
#[test]
fn hdr_avif_export_hlg() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70}));
assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg);
assert_eq!(o.to_json()["hdrTransfer"], "hlg");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
// nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box
let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box");
assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]);
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
}
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
#[test]
+11 -22
View File
@@ -31,21 +31,11 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool
})
}
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
fn slash(path: &str) -> String {
path.replace('\\', "/")
}
fn file_source(s: &Session, id: u64) -> String {
let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") };
slash(path)
}
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
let files = fs.listing();
for photo in s.catalog.photos() {
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0);
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
}
let replayed = memory_library(store);
@@ -112,8 +102,7 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal()
let renamed = fs.listing();
s.merge_undo(2, "Rename Twice");
assert_eq!(s.undo.len(), 1);
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]);
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
// First exercise the ordinary merged step, before any corrective paths exist.
for redo in [false, true] {
file_history_step(&mut s, &fs, redo).unwrap();
@@ -285,7 +274,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb
file_history_step(&mut s, &fs, redo).unwrap();
let mut after = before
.iter()
.map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone()))
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
.collect::<Vec<_>>();
after.sort();
assert_eq!(fs.listing(), after);
@@ -354,7 +343,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(file_source(&s, 1), "/p/Trip.jpg");
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
assert_eq!(s.undo.len(), 1);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
assert_file_catalog_and_journal(&s, &fs, &store);
@@ -524,11 +513,10 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
);
s.merge_undo(3, "Rename Three Times");
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]);
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
// The second photo move sticks; the preceding d -> b move must roll back to d.
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string());
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
let files = fs.listing();
@@ -541,8 +529,9 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
]
);
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
assert_eq!(file_source(&s, 1), "/p/a.jpg");
let path = file_source(&s, 2);
assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg");
assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
let Source::File { path } = &photo2.source else { panic!("file fixture") };
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
}
+1 -2
View File
@@ -40,8 +40,7 @@ impl Scratch {
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
// the host's separator throughout, as the code under test spells joined paths
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
self.0.join(rel).to_string_lossy().to_string()
}
}
-100
View File
@@ -1,100 +0,0 @@
//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a
//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent.
use serde_json::json;
use crate::Session;
fn exposure(s: &Session) -> f64 {
s.develop_of(s.active().unwrap()).unwrap().light.exposure
}
fn list(s: &mut Session) -> (usize, Option<usize>) {
let h = s.execute("history.list", &json!({})).unwrap();
(h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize))
}
/// A demo session with three exposure edits (1, 2, 3) as its latest History steps.
fn three_edits() -> (Session, usize) {
let mut s = Session::with_demo();
for v in [1.0, 2.0, 3.0] {
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
}
let (n, cursor) = list(&mut s);
assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step");
(s, n)
}
#[test]
fn going_back_adds_no_step_and_forward_returns() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
assert_eq!(exposure(&s), 1.0);
assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept");
s.execute("history.next", &json!({})).unwrap();
assert_eq!(exposure(&s), 2.0);
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
assert_eq!(exposure(&s), 3.0);
assert_eq!(list(&mut s), (n, Some(n - 1)));
assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step");
s.execute("history.previous", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2))));
assert!(s.execute("history.restore", &json!({"index": n})).is_err());
assert!(s.execute("history.restore", &json!({"index": -1})).is_err());
}
#[test]
fn a_new_edit_behind_the_end_drops_the_later_steps() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended");
assert_eq!(exposure(&s), -1.0);
// undo brings the dropped steps and the cursor back
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2))));
}
#[test]
fn undo_and_redo_move_the_cursor_with_the_settings() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
}
#[test]
fn clearing_history_resets_the_cursor() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
s.execute("history.clear", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0))));
assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None);
}
#[test]
fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() {
let mut s = Session::with_demo();
let snap = s.catalog.to_snapshot();
let _ = s.drain_log();
for v in [1.0, 2.0] {
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
}
let (n, _) = list(&mut s);
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap();
c.replay(&log).unwrap();
assert_eq!(c.to_snapshot(), s.catalog.to_snapshot());
let id = s.active().unwrap();
assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2));
// a photo written before the cursor existed has no `history_cursor` key: at the latest step
let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap();
v.as_object_mut().unwrap().remove("history_cursor");
let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap();
assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1)));
}
+2 -42
View File
@@ -21,9 +21,6 @@ fn write_png(path: &Path, seed: u8) {
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn ids(v: &Value, key: &str) -> usize {
@@ -312,27 +309,6 @@ fn recently_added_covers_recent_imports_newest_first() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recently_added_keeps_an_import_batch_in_file_order() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut s = Session::new();
// one import batch: one `imported` string, ids running opposite to the names
let mut by_name = Vec::new();
for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() {
let id = PhotoId(5000 - n as u64);
let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
by_name.push(name.to_string());
}
s.clock = Box::new(|| "2026-10-01T09:00:00".to_string());
for ascending in [true, false] {
s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap();
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
assert_eq!(names, by_name, "ascending: {ascending}");
}
}
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
/// import order), and a metadata preset on every photo.
#[test]
@@ -584,14 +560,6 @@ fn cube_luts_become_profiles() {
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
s.execute("profile.favorite", &json!({"id": id})).unwrap();
assert!(s.profile_favorites.contains(&id));
// `profiles.list` lists it next to the built-in profiles (#618)
let listed = |s: &mut Session| {
let list = s.execute("profiles.list", &json!({})).unwrap();
let list = list.as_array().unwrap();
assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed");
list.iter().find(|p| p["id"] == id.as_str()).cloned()
};
assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true})));
// the library keeps it
drop(s);
lightcraft_pipeline::lut::unregister(&id);
@@ -601,10 +569,8 @@ fn cube_luts_become_profiles() {
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen");
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
assert!(lightcraft_pipeline::lut::get(&id).is_none());
assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
@@ -1438,20 +1404,14 @@ fn smart_preview_look_marker_is_read_safely() {
let dir = temp_dir("smartlook-marker");
assert!(!crate::smart::look_checked(&dir), "no marker");
let cur = crate::camera_preview::LOOK_VERSION;
let enc = crate::smart::ENCODE_VERSION;
for (text, checked) in [
(String::new(), false),
("banana".into(), false),
("-1".into(), false),
(format!("{}", cur.saturating_sub(1)), false),
("9".repeat(200), false),
// Written before the encode version was recorded: only says the look was checked, so the
// folder is scanned once more and the old-encoding proxies are rebuilt.
(format!("{cur}\n"), false),
(format!("{cur} {}\n", enc.saturating_sub(1)), false),
(format!("{cur} banana\n"), false),
(format!("{cur} {enc}\n"), true),
(format!(" {} {}\n", cur + 1, enc + 1), true),
(format!("{cur}\n"), true),
(format!(" {}\n", cur + 1), true),
] {
std::fs::write(dir.join("look-version"), &text).unwrap();
assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");
-3
View File
@@ -22,9 +22,6 @@ fn write_png(path: &Path, seed: u8) {
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn xmp(rating: u8) -> String {
+6 -15
View File
@@ -155,22 +155,13 @@ fn odd_folders_never_fail_the_check() {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
// APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and
// the symlink cases below still run on a plain-named catalog folder
let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff"));
let utf8_only = std::fs::create_dir(&probe).is_err();
let _ = std::fs::remove_dir(&probe);
if !utf8_only {
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
}
let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") });
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe"));
std::fs::create_dir_all(&lr).unwrap();
if !utf8_only {
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
}
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
std::fs::write(lr.join("b.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped");
// a symlink to a catalog folder is one too; a dangling one is not
-25
View File
@@ -67,31 +67,6 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
#[test]
fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() {
let dir = temp_dir("hdr-colour");
let neutral = [0.5, 1.0, 0.7];
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() {
// the same pixels with a white balance but no colour matrices
let mut raw = lightcraft_raw::decode(&b).unwrap();
raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() };
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color);
let got = merged.color.as_shot_neutral.expect("the sources' white balance");
assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
-126
View File
@@ -1,126 +0,0 @@
//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
//! doesn't compare every preset with every other.
use std::path::PathBuf;
use std::time::Instant;
use lightcraft_develop::Preset;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn many(n: usize) -> Vec<Preset> {
(0..n)
.map(|i| Preset {
id: format!("user.coll-{}.look-{i}", i / 100),
name: format!("Look {i}"),
group: format!("Collection {}", i / 100),
settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
favorite: false,
builtin: false,
})
.collect()
}
#[test]
fn develop_commands_do_not_reserialize_unchanged_presets() {
let lib = temp_dir("no-reserialize");
let mut s = Session::new();
s.open_library(&lib, true).unwrap();
let added = s.add_presets(many(2000));
assert_eq!(added.len(), 2000);
s.persist().unwrap();
assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
let before = crate::library::presets_serialized();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
// a preset change is still saved
let pid = added[7].clone();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
assert!(crate::library::presets_serialized() > before);
s.close_library().unwrap();
drop(s);
let mut t = Session::new();
t.open_library(&lib, false).unwrap();
assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn preset_list_generation_follows_mutable_access_only() {
let mut s = Session::new();
let g0 = s.presets.generation();
let _ = s.presets.iter().filter(|p| p.builtin).count();
let _ = s.presets.len();
assert_eq!(s.presets.generation(), g0, "reads keep the generation");
s.presets.push(many(1).remove(0));
let g1 = s.presets.generation();
assert_ne!(g1, g0);
for p in &mut s.presets {
p.favorite = false;
}
assert_ne!(s.presets.generation(), g1);
// generations are unique across sessions (UI caches key on them alone)
let other = Session::new();
assert_ne!(other.presets.generation(), s.presets.generation());
}
#[test]
fn importing_thousands_of_presets_is_not_quadratic() {
let mut s = Session::new();
let t = Instant::now();
let added = s.add_presets(many(20_000));
assert_eq!(added.len(), 20_000);
// the same presets again are all skipped as duplicates
assert!(s.add_presets(many(20_000)).is_empty());
// a clashing id with different settings gets a fresh, unique one
let mut clash = many(1).remove(0);
clash.settings = json!({"light": {"exposure": 3.0}});
let id = s.add_presets(vec![clash]).remove(0);
assert_ne!(id, "user.coll-0.look-0");
assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
// quadratic was ~200 M comparisons here (tens of seconds in debug builds)
assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
}
/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
/// into a temporary library; the original is only read.
#[test]
#[ignore]
fn presets_scale_bench() {
let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
return;
};
let lib = temp_dir("bench");
std::fs::copy(&src, lib.join("presets.json")).unwrap();
let mut s = Session::new();
let t = Instant::now();
s.open_library(&lib, true).unwrap();
eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
let t = Instant::now();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
let t = Instant::now();
s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
eprintln!("preset.apply: {:?}", t.elapsed());
let t = Instant::now();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
let _ = std::fs::remove_dir_all(&lib);
}
-17
View File
@@ -4,23 +4,6 @@ use serde_json::json;
use crate::Session;
/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends.
#[test]
fn up_and_down_move_by_rows_and_stop_at_the_ends() {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
assert!(vis.len() >= 6);
s.selection = crate::Selection::single(vis[0]);
s.execute("library.down", &json!({"rows": 4})).unwrap();
assert_eq!(s.selection.active, Some(vis[4]));
s.execute("library.up", &json!({})).unwrap();
assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)");
s.execute("library.up", &json!({"rows": 99})).unwrap();
assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo");
s.execute("library.down", &json!({"rows": u64::MAX})).unwrap();
assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow");
}
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
}
-85
View File
@@ -7,26 +7,6 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
use serde_json::json;
use crate::Session;
use crate::rename::{MoveFs, RealFs};
/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves.
pub(crate) fn case_insensitive(dir: &Path) -> bool {
Path::new(&dir.to_string_lossy().to_uppercase()).exists()
}
/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault
/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a
/// case-sensitive volume only identical paths match.
pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool {
a == b || RealFs.same_file(a, b)
}
/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell
/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that
/// exists.
pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool {
a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
}
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
@@ -195,71 +175,6 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
let _ = std::fs::remove_dir_all(&src);
}
/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934).
fn crs_exposure_sidecar(exposure: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="15.0" crs:Exposure2012="{exposure}"/></rdf:RDF></x:xmpmeta>"#
)
}
/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back
/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made
/// after it is still written.
#[test]
fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() {
let src = temp_dir("read-no-autowrite");
write_png(&src.join("frame.png"), 6);
let sidecar = src.join("frame.xmp");
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = only(&s);
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
// something for the read to change back (auto-write is still off, so the file stays as it is)
s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(&sidecar).unwrap();
let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read");
let now = std::fs::read(&sidecar).unwrap();
assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now));
// a develop edit afterwards is still written (only the read is exempt)
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap();
let after = std::fs::read_to_string(&sidecar).unwrap();
match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop {
Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9),
other => panic!("the edit wasn't auto-written: {other:?}\n{after}"),
}
assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}");
let _ = std::fs::remove_dir_all(&src);
}
/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app
/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read
/// takes 1.2.
#[test]
fn a_crs_change_after_a_read_is_taken_with_auto_write_on() {
let src = temp_dir("read-then-crs-change");
write_png(&src.join("frame.png"), 7);
let sidecar = src.join("frame.xmp");
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = only(&s);
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
// the other app edits the file as it is now
let text = std::fs::read_to_string(&sidecar).unwrap();
let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#);
assert_ne!(changed, text);
std::fs::write(&sidecar, changed).unwrap();
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure");
let _ = std::fs::remove_dir_all(&src);
}
/// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar
/// and our own starting look stands in for them: importing one gives no camera-profile warning.
#[test]
+1 -28
View File
@@ -57,8 +57,6 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
// Include aliases to the library in the exclusion, not just its lexical path.
let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok());
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
@@ -68,7 +66,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
}
continue;
}
if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) {
if skip.is_some_and(|s| path == s) {
continue;
}
if depth >= limits.max_depth {
@@ -167,31 +165,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Folder imports must not revisit the user's own library through symlinks
/// or through an alias to the library's parent.
#[cfg(unix)]
#[test]
fn skipped_library_stays_skipped_through_symlinks_and_ancestors() {
let d = tmp("skip-alias");
let root = d.join("incoming");
let library = d.join("library");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(library.join("Originals")).unwrap();
std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap();
std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap();
std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap();
std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap();
let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
assert!(photos.files.contains(&root.join("ordinary.jpg")));
assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}");
// Scanning a subfolder already inside the excluded library is also forbidden.
let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true);
assert!(inside.files.is_empty(), "{inside:?}");
let _ = std::fs::remove_dir_all(&d);
}
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
+2 -8
View File
@@ -32,16 +32,10 @@ fn a_preview_reads_only_the_header_and_the_preview() {
read.total,
frac * 100.0,
elapsed.as_secs_f64(),
if read.planned {
" [plan]"
} else if read.header_path {
" [header path]"
} else {
" [whole file]"
}
if read.header_path { " [header path]" } else { " [whole file]" }
);
assert!(read.header_path, "the header or the plan must answer for a raw whose preview is not the whole file");
assert!(read.header_path, "the header path must answer for a raw whose pointer is in its header");
assert_eq!(read.total, file_len, "the report covers the whole file");
assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total);
assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4));
+5 -28
View File
@@ -84,21 +84,15 @@ impl CropGeometry {
/// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's
/// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle,
/// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so
/// they all give the same crop (issue #742). A crop that is the largest of its aspect at its
/// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new
/// angle, so it grows back as the angle returns towards 0° (issue #1388).
/// they all give the same crop (issue #742).
pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry {
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
if cand.is_within_image(w, h) {
return cand;
}
let r = self.rect_px(w, h);
// a degenerate rect has no aspect to keep: fall back to the image's own
let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0);
let largest = crop_fit_angle(w, h, self.angle, aspect).rect;
let is_largest = [(self.rect.x0, largest.x0), (self.rect.y0, largest.y0), (self.rect.x1, largest.x1), (self.rect.y1, largest.y1)]
.iter()
.all(|(a, b)| (a - b).abs() < 1e-9);
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
if !is_largest && cand.is_within_image(w, h) {
return cand;
}
crop_fit_angle(w, h, angle_deg, aspect)
}
}
@@ -178,23 +172,6 @@ mod tests {
}
}
#[test]
fn with_angle_grows_a_fitted_crop_back_as_the_angle_returns() {
// issue #1388: the crop a turn shrank to fit is the largest of its aspect; turning back
// gives the largest at the new angle, not the shrunk one again
for aspect in [None, Some(1.0), Some(16.0 / 9.0)] {
let mut c = crop_fit_angle(600.0, 400.0, 0.0, aspect);
for ang in [10.0, 25.0, -25.0, 7.0, 0.0] {
c = c.with_angle(ang, 600.0, 400.0);
let want = crop_fit_angle(600.0, 400.0, ang, aspect);
assert!(
(c.rect.width() - want.rect.width()).abs() < 1e-9 && (c.rect.height() - want.rect.height()).abs() < 1e-9,
"{aspect:?} at {ang}°: {c:?} vs {want:?}"
);
}
}
}
#[test]
fn with_angle_survives_a_degenerate_crop() {
// zero height: no aspect to keep (width / height = ∞)
+5 -26
View File
@@ -51,11 +51,11 @@ impl Homography {
let c01 = m[5] * m[6] - m[3] * m[8];
let c02 = m[3] * m[7] - m[4] * m[6];
let det = m[0] * c00 + m[1] * c01 + m[2] * c02;
if !det.is_finite() || det.abs() < 1e-300 {
if det.abs() < 1e-300 {
return None;
}
let inv = 1.0 / det;
let coefficients = [
Some(Homography([
c00 * inv,
(m[2] * m[7] - m[1] * m[8]) * inv,
(m[1] * m[5] - m[2] * m[4]) * inv,
@@ -65,17 +65,11 @@ impl Homography {
c02 * inv,
(m[1] * m[6] - m[0] * m[7]) * inv,
(m[0] * m[4] - m[1] * m[3]) * inv,
];
coefficients.iter().all(|v| v.is_finite()).then_some(Homography(coefficients))
]))
}
/// The homography mapping `src[i]` → `dst[i]` (4 correspondences, DLT with h33 = 1).
pub fn from_quads(src: &[Point; 4], dst: &[Point; 4]) -> Option<Homography> {
// Failed feature matches and invalid perspective parameters can contain
// non-finite coordinates. Do not return a transform that poisons rendering.
if src.iter().chain(dst.iter()).any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let mut a = [[0.0f64; 9]; 8];
for i in 0..4 {
let (x, y) = (src[i].x, src[i].y);
@@ -86,7 +80,7 @@ impl Homography {
// Gaussian elimination with partial pivoting on the 8×8 system (last column = rhs).
for col in 0..8 {
let piv = (col..8).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
if !a[piv][col].is_finite() || a[piv][col].abs() < 1e-12 {
if a[piv][col].abs() < 1e-12 {
return None;
}
a.swap(col, piv);
@@ -100,7 +94,7 @@ impl Homography {
}
}
let h: Vec<f64> = (0..8).map(|i| a[i][8] / a[i][i]).collect();
h.iter().all(|v| v.is_finite()).then_some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]))
Some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]))
}
}
@@ -127,19 +121,4 @@ mod tests {
let src = [Point::new(0.0, 0.0); 4];
assert!(Homography::from_quads(&src, &src).is_none());
}
#[test]
fn nonfinite_projective_and_affine_transforms_are_rejected() {
let src = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
let mut invalid = src;
invalid[2].x = f64::NAN;
assert!(Homography::from_quads(&invalid, &src).is_none());
invalid[2].x = f64::INFINITY;
assert!(Homography::from_quads(&src, &invalid).is_none());
assert!(Homography([f64::NAN, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none());
assert!(Homography([f64::INFINITY, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none());
assert!(Affine([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none());
assert!(Affine([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none());
assert_eq!(Affine::IDENTITY.inverse(), Some(Affine::IDENTITY));
}
}
+2 -3
View File
@@ -234,13 +234,12 @@ impl Affine {
}
pub fn inverse(&self) -> Option<Affine> {
let det = self.determinant();
if !det.is_finite() || det.abs() < 1e-300 {
if det.abs() < 1e-300 {
return None;
}
let [a, b, c, d, e, f] = self.0;
let inv = 1.0 / det;
let coefficients = [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv];
coefficients.iter().all(|v| v.is_finite()).then_some(Affine(coefficients))
Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]))
}
/// Bounding box of a transformed rectangle.
pub fn transform_rect_bbox(&self, r: &Rect) -> Rect {
+8 -73
View File
@@ -229,19 +229,10 @@ pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] {
// ---------------------------------------------------------------------------------------------
// Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`)
/// Rows each `box_v` thread slides its running sum over (at least; longer for large radii, so the
/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the
/// priming sum stays a small share of the work).
const CHUNK: usize = 16;
/// One box pass of radius `r` (clamped edges) over a `w × h` image of `nc` interleaved channels,
/// `src → dst`: horizontal or vertical.
fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (usize, usize), nc: usize, r: usize) {
let chunk = (2 * r + 1).clamp(CHUNK, 128);
let p = [w as u32, h as u32, nc as u32, r as u32, chunk as u32];
let (k, groups) = if horizontal { ("box_h", groups2(w, h, [64, 4])) } else { ("box_v", groups2(w, h.div_ceil(chunk), [64, 4])) };
cx.run(k, &p, &[Some(src), Some(dst)], groups);
}
/// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way
/// (`lightcraft_raster::blur::gaussian`).
pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf {
@@ -249,15 +240,18 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize
return cx.copy(src);
}
let radii = lightcraft_raster::blur::box_radii(sigma);
let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect();
let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect();
if passes.is_empty() {
return cx.copy(src);
}
// ping-pong between two buffers
let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)];
for (i, (horizontal, r)) in passes.iter().enumerate() {
for (i, (k, r)) in passes.iter().enumerate() {
let chunk = (2 * r + 1).clamp(CHUNK, 128);
let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) };
let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32];
let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]);
box_pass(cx, *horizontal, if i == 0 { src } else { prev }, out, (w, h), nc, *r);
cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups);
}
let last = (passes.len() - 1) % 2;
let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]);
@@ -718,27 +712,6 @@ pub fn render(
_ => Err(m),
}
});
// every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once).
// The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the
// single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it
// on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times).
let all_overlay_alphas: Option<Vec<Plane>> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) {
masks.as_ref().map(|all| {
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
if list.is_empty() {
return Vec::new();
}
let raw: Vec<f32> = cx.read(all, n * list.len());
let mut alphas = Vec::with_capacity(list.len());
for mi in 0..list.len() {
let s = mi * n;
alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() });
}
alphas
})
} else {
None
};
let data: Vec<[u8; 4]> = cx.read(&out, n);
let image = Rgba8 { width: w, height: h, data };
lap("finish + readback", &mut t, &mut cx);
@@ -774,7 +747,7 @@ pub fn render(
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
@@ -1159,33 +1132,6 @@ mod tests {
assert!(covered > 0 && covered < w * h, "{covered} px covered");
}
/// The blur against `lightcraft_raster::blur::gaussian`: widths that do and don't fill the last
/// workgroup, windows wider than the image, and 1, 2 (as two planes) and 3 channels.
#[test]
fn gaussian_matches_cpu() {
use lightcraft_raster::blur;
let Some(gpu) = crate::device() else { return };
let _scope = crate::ctx::RenderScope::new(gpu);
let v = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 5) % 17) as f32 / 16.0 + if x % 61 < 3 { 2.0 } else { 0.0 };
for (w, h) in [(1000, 5), (257, 3), (5, 4), (1, 9)] {
for sigma in [0.8f32, 2.0, 4.5, 30.0, 250.0, 1000.0] {
let planes: Vec<Plane> = (0..3).map(|c| Plane::from_fn(w, h, |x, y| v(x, y, c))).collect();
let want: Vec<Plane> = planes.iter().map(|p| blur::gaussian(p, sigma)).collect();
for nc in [1, 2, 3] {
let words: Vec<f32> = (0..w * h).flat_map(|i| (0..nc).map(move |c| (i, c))).map(|(i, c)| planes[c].data[i]).collect();
let mut cx = Cx::new(gpu);
let src = gpu.upload(&words);
let out = gaussian(&mut cx, &src, w, h, nc, sigma);
let got: Vec<f32> = cx.read(&out, w * h * nc);
for (i, g) in got.iter().enumerate() {
let want = want[i % nc].data[i / nc];
assert!((g - want).abs() < 1e-5 * (1.0 + sigma / 10.0), "{w}×{h} nc={nc} sigma={sigma}: word {i} {g} vs {want}");
}
}
}
}
}
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
#[test]
#[ignore]
@@ -1193,7 +1139,6 @@ mod tests {
let Some(gpu) = crate::device() else { return };
let _scope = crate::ctx::RenderScope::new(gpu);
let (w, h) = (6000, 4000);
eprintln!("{} ({:?})", gpu.info.name, gpu.info.backend);
let src = gpu.upload(&vec![0.5f32; w * h * 3]);
let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| {
let mut best = f64::MAX;
@@ -1207,19 +1152,9 @@ mod tests {
}
eprintln!("{name:<40} {best:.1} ms");
};
for nc in [1, 2, 3] {
let out = gpu.buffer(w * h * nc);
// noise reduction's radii, Texture's and Dehaze's at 6000 px
for r in [2, 11, 120] {
for (k, horizontal) in [("box_h", true), ("box_v", false)] {
time(&format!("{k} nc={nc} r={r}"), &mut |cx| box_pass(cx, horizontal, &src, &out, (w, h), nc, r));
}
}
}
for nc in [1, 2, 3] {
time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0)));
}
time("gaussian nc=3 sigma=4.5 (colour NR)", &mut |cx| drop(gaussian(cx, &src, w, h, 3, 4.5)));
time("map log_lum", &mut |cx| {
let o = cx.gpu.buffer(w * h);
map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o);
+13 -9
View File
@@ -1,6 +1,6 @@
// Box passes (clamped edges) over an image of NC interleaved channels; three of each approximate a
// Gaussian (`lightcraft_raster::blur::gaussian`).
// P: w, h, nc, r, ch (box_v only). Bindings: src, dst.
// Gaussian (`lightcraft_raster::blur::gaussian`). Each thread slides a running sum over CH pixels.
// P: w, h, nc, r, ch. Bindings: src, dst.
// Pixel `i` (all nc ≤ 3 channels at once: one contiguous read per pixel).
fn ld(i: u32, nc: u32) -> vec3<f32> {
@@ -25,27 +25,31 @@ fn st(i: u32, nc: u32, v: vec3<f32>) {
}
}
// Horizontal: one thread per pixel, summing its 2r + 1 window; adjacent threads read adjacent
// pixels, which the cache serves. (Threads that each slid a running sum over 16 pixels read pixels
// 16 apart across a workgroup: on a Radeon 780M that pass took 9–15× as long as `box_v`.)
// Horizontal: one thread per CH consecutive pixels of a row.
@compute @workgroup_size(64, 4)
fn box_h(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let nc = pu(2u);
let r = i32(pu(3u));
let x = g.x;
let ch = pu(4u);
let y = g.y;
if (x >= w || y >= h) {
let x0 = g.x * ch;
if (y >= h || x0 >= w) {
return;
}
let last = i32(w) - 1;
let inv = 1.0 / f32(2 * r + 1);
let row = y * w;
let x1 = min(x0 + ch, w);
var acc = vec3<f32>(0.0);
for (var k = -r; k <= r; k++) {
acc += ld(row + u32(clamp(i32(x) + k, 0, last)), nc);
acc += ld(row + u32(clamp(i32(x0) + k, 0, last)), nc);
}
for (var x = x0; x < x1; x++) {
st(row + x, nc, acc * inv);
acc = acc + ld(row + u32(min(i32(x) + r + 1, last)), nc) - ld(row + u32(max(i32(x) - r, 0)), nc);
}
st(row + x, nc, acc * (1.0 / f32(2 * r + 1)));
}
// Vertical: one thread per CH consecutive rows of a column (the CPU re-primes every BAND rows the
+2 -2
View File
@@ -11,11 +11,11 @@ license.workspace = true
# Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the
# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
# code structure; gamut-heic has no HEVC decoder of its own).
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
rayon = { workspace = true }
[features]
+1 -2
View File
@@ -100,8 +100,7 @@ pub struct Shot {
pub aperture: Option<f64>,
/// Focus distance, metres.
pub distance: Option<f64>,
/// Crop factor of the recorded frame: the camera's (from [`LensDb::find_camera`]), or larger when the camera
/// recorded a crop of its sensor; the profile's own when unknown.
/// Crop factor of the camera (from [`LensDb::find_camera`]); the profile's own when unknown.
pub crop_factor: Option<f64>,
/// Image size in pixels (the frame the correction is expressed for; the optical centre is its centre).
pub width: f64,
+10 -25
View File
@@ -10,6 +10,13 @@ use serde_json::{Value, json};
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", "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,
@@ -168,16 +175,15 @@ impl Backend for Headless {
}
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
/// and make paths absolute. Folders take the extensions `library.import` takes
/// (`lightcraft_engine::import::EXTENSIONS`), so the two can't drift apart (issue #1080).
/// and make paths absolute.
pub fn expand_paths(paths: &[String]) -> Vec<String> {
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let w =
lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), lightcraft_engine::import::is_supported);
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
out.extend(w.files.iter().map(|f| absolute(f)));
} else {
// Explicit files are kept even with unknown extensions (the probe decides).
@@ -220,27 +226,6 @@ mod tests {
assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}");
}
/// `--import <folder>` picks up every extension `library.import` takes, HEIC included (issue #1080).
#[test]
fn expand_paths_takes_the_engine_extensions() {
let dir = std::env::temp_dir().join(format!("lc-mcp-expand-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for e in lightcraft_engine::import::EXTENSIONS {
std::fs::write(dir.join(format!("a.{e}")), b"x").unwrap();
}
std::fs::write(dir.join("B.HEIC"), b"x").unwrap();
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
let found = expand_paths(&[dir.to_string_lossy().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
let ext = |f: &String| Path::new(f).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let mut got: Vec<String> = found.iter().map(ext).collect();
got.sort();
let mut want: Vec<String> = lightcraft_engine::import::EXTENSIONS.iter().map(|e| e.to_string()).chain(["HEIC".to_string()]).collect();
want.sort();
assert_eq!(got, want);
}
/// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock.
#[test]
fn headless_uses_the_system_clock() {
+1 -1
View File
@@ -23,7 +23,7 @@ mod tools;
pub use backend::{Backend, ProgressHook, Remote};
pub use base64::{base64_decode, base64_encode};
pub use headless::{Headless, encode_image, expand_paths, write_image};
pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image};
pub use server::{PROTOCOL_VERSION, Server};
pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions};
+1 -1
View File
@@ -25,7 +25,7 @@ const INTERNAL_ERROR: i64 = -32603;
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 (`set_develop`, `crop` and `export` with an explicit photo id end the current edit interaction, replace the selection with that one photo and make it active; `get_develop` and `render_photo` do not change the selection). \
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_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 \
-9
View File
@@ -2,13 +2,6 @@ use serde_json::{Value, json};
use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
/// An undated file takes its own date (#1216): give the fixture the date its folder template expects (noon UTC,
/// the same day in every time zone the tests run in).
fn file_dated_2026_01_14(path: &std::path::Path) {
let noon = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_392_000);
std::fs::File::options().write(true).open(path).unwrap().set_modified(noon).unwrap();
}
fn server() -> Server {
Server::new(Box::new(Headless::demo()))
}
@@ -89,7 +82,6 @@ fn import_tool_copies_with_a_folder_template() {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
file_dated_2026_01_14(&src.join("a.png"));
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
@@ -263,7 +255,6 @@ fn import_tool_moves_with_a_folder_template() {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("sample.png"), png).unwrap();
file_dated_2026_01_14(&src.join("sample.png"));
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(),
+3 -5
View File
@@ -182,7 +182,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"get_develop",
"Get develop settings",
"The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).",
json!({"id": {"type": "integer", "description": "Photo id (default: the active photo). Reads without changing the selection."}}),
json!({"id": photo_id}),
&[],
),
tool(
@@ -216,7 +216,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"Render photo",
"Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.",
json!({
"id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"},
"id": photo_id,
"size": {"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)"},
"path": {"type": "string", "description": "Also save the rendered file here"}
@@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); 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"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
@@ -262,7 +262,6 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"},
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
"hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"},
"addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"},
"photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"},
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
@@ -744,7 +743,6 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"colorSpace",
"bitDepth",
"hdr",
"hdrTransfer",
"preset",
"background",
"resize",
-13
View File
@@ -103,28 +103,15 @@ pub fn detect_clip(img: &Rgb32f) -> f32 {
if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 }
}
/// The colour tags a raw source starts from when the caller knows better than the file (`None`: the
/// file's own tags). Called with the decoded raw, its samples still there, and the file's bytes.
pub type RawColour<'a> = dyn Fn(&mut lightcraft_raw::RawImage, &[u8]) -> Option<ColorData> + Sync + 'a;
/// Decode a photo (raw or standard format) into a frame no larger than `max_edge` (when given).
/// `orient` applies the EXIF orientation to the pixels.
pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, orient: bool) -> Result<Frame> {
load_frame_with(bytes, max_edge, orient, &|_, _| None)
}
/// [`load_frame`] with a raw's colour tags from `colour`: a raw without colour matrices of its own
/// (CR2, NEF, ARW…) starts from a colour the app works out (issue #965).
pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colour: &RawColour) -> Result<Frame> {
let metadata = lightcraft_meta::extract(bytes);
let exposure = exif_exposure(&metadata);
if lightcraft_raw::probe(bytes).is_some() {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
// geometric lens corrections stay with the develop settings ("profile corrections")
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(color) = colour(&mut raw, bytes) {
raw.color = color;
}
let small = max_edge.is_some_and(|m| m <= 1200);
let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?;
+1 -1
View File
@@ -23,7 +23,7 @@ pub mod pano;
pub mod ransac;
pub mod synth;
pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with};
pub use frame::{Frame, FrameColor, load_frame};
pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr};
pub use output::{DngSamples, write_linear_dng};
pub use pano::{PanoOptions, PanoResult, Projection, stitch};
-1
View File
@@ -53,7 +53,6 @@ pub fn color_data(color: &FrameColor) -> ColorData {
baseline_exposure: 0.0,
baseline_sharpness: None,
profile: Default::default(),
kelvin_presets: None,
}
}
}
+2 -23
View File
@@ -25,19 +25,8 @@ fn num(s: &str) -> Option<u32> {
impl DateTime {
fn valid(self) -> Option<Self> {
// A timestamp must represent a real Gregorian calendar day, not just
// numbers in the range 1..=31. Invalid dates would otherwise be
// accepted from EXIF/XMP and silently normalized during time arithmetic.
let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400));
let max_day = match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => 0,
};
let ok = self.day >= 1
&& self.day <= max_day
let ok = (1..=12).contains(&self.month)
&& (1..=31).contains(&self.day)
&& self.hour < 24
&& self.minute < 60
&& self.second < 61
@@ -176,16 +165,6 @@ mod tests {
assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none());
assert!(DateTime::parse_exif(" : : : : ").is_none());
assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none());
// Invalid calendar days must never enter persisted capture metadata.
for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] {
assert!(DateTime::parse_exif(value).is_none(), "{value}");
}
assert!(DateTime::parse_exif("2024:02:29").is_some());
assert!(DateTime::parse_exif("2000:02:29").is_some());
for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] {
assert!(DateTime::parse_iso(value).is_none(), "{value}");
}
assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some());
assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12);
+1 -12
View File
@@ -96,9 +96,7 @@ struct Pending {
/// Parse a GPX document.
pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
let mut reader = quick_xml::Reader::from_str(s);
// Mismatched end tags must be rejected; otherwise the path stack can
// attribute a point's time or segment to the wrong enclosing element.
reader.config_mut().check_end_names = true;
reader.config_mut().check_end_names = false;
let mut path: Vec<Vec<u8>> = Vec::new();
let mut seen_gpx = false;
let mut log = Tracklog::default();
@@ -171,9 +169,6 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
if !seen_gpx {
return Err(GpxError::NotGpx);
}
if !path.is_empty() {
return Err(GpxError::Xml("unclosed GPX elements".into()));
}
// stable: points logged at the same second keep their file order
log.points.sort_by(|a, b| a.time.total_cmp(&b.time));
Ok(log)
@@ -367,12 +362,6 @@ mod tests {
fn rejects_non_gpx_and_bad_input() {
assert_eq!(parse_gpx("<kml><Document/></kml>"), Err(GpxError::NotGpx));
assert!(parse_gpx("").is_err());
// A partial logger write must not be mistaken for a complete track,
// even if it contains a valid timed point.
let point = r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk>"#;
assert!(matches!(parse_gpx(point), Err(GpxError::Xml(_))));
let wrong_close = r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T00:00:00Z</time></trkpt></trk></trkseg></gpx>"#;
assert!(matches!(parse_gpx(wrong_close), Err(GpxError::Xml(_))));
assert!(
parse_gpx("<gpx><trk><trkseg><trkpt lat=\"x\" lon=\"1\"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>")
.unwrap()
-5
View File
@@ -182,11 +182,6 @@ impl Frame {
pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) {
let a = self.aspect();
let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64);
// a box that is already this aspect up to rounding (an export size) is the size: fitting
// into it again rounds twice and can lose a pixel of the edge that was asked for
if (mw - 0.5) / a <= mh + 0.5 && (mw + 0.5) / a >= mh - 0.5 {
return (max_w.max(1), max_h.max(1));
}
let (w, h) = if mw / mh > a { (mh * a, mh) } else { (mw, mw / a) };
((w.round() as usize).max(1), (h.round() as usize).max(1))
}
+1 -24
View File
@@ -471,10 +471,6 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor
/// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures.
pub const HDR_HISTOGRAM_EDGE: usize = 384;
/// Long edge of the render that gives a slider drag its live HDR histogram (drafts have none: a
/// second render per tick would slow them down), run beside the drafts by the UI.
pub const HDR_HISTOGRAM_DRAG_EDGE: usize = 192;
/// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR
/// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the
/// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR
@@ -600,8 +596,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
let masks = overlay_alphas_all(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref());
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
let mut image = if plan.keep.is_some() { cut(&image) } else { image };
if req.overlay == Overlay::HdrRange && edit.hdr.enabled {
// the HDR rendition of the same request says how far above SDR white each pixel goes
@@ -653,24 +648,6 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane>
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
}
/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components,
/// reusing the render's own evaluation where available (a fresh evaluation otherwise).
fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Vec<Plane>> {
if !matches!(o, Overlay::AllMasks { .. }) {
return None;
}
let ev = plan.settings.light.exposure as f32;
let mut out = Vec::new();
for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) {
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
out.push(e.alpha.clone());
} else {
out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()));
}
}
Some(out)
}
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
/// (default look, keeping the crop so framing matches, under the photo's own rendering process).
pub fn before_settings(s: &DevelopSettings) -> DevelopSettings {
+2 -3
View File
@@ -247,9 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
let (pixels, _) = out.as_chunks::<3>();
for (d, v) in src.iter_mut().zip(pixels) {
*d = *v;
for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) {
*d = [v[0], v[1], v[2]];
}
}
+2 -64
View File
@@ -33,9 +33,6 @@ pub enum Overlay {
/// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in
/// `color` at `opacity` (0..100; the colour views only).
Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 },
/// The evaluated alpha of every visible mask that has components, drawn as one `view` in
/// `color` at `opacity` (0..100; the colour views only) — every mask's region at once.
AllMasks { view: MaskView, color: [u8; 3], opacity: u8 },
/// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR
/// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]).
HdrRange,
@@ -123,7 +120,6 @@ impl Overlay {
Overlay::PointColorRange(i) => (1, i as f64),
Overlay::Spots(t) => (2, t as f64),
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64),
Overlay::HdrRange => (4, 0.0),
}
}
@@ -135,14 +131,6 @@ impl Overlay {
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64),
4 => Overlay::HdrRange,
5 if v.is_finite() && v >= 0.0 => {
let rgb = (v as u64 >> 20) & 0xff_ffff;
Overlay::AllMasks {
view: MaskView::ALL.get(((v as u64 >> 16) & 0xf) as usize).copied().unwrap_or_default(),
color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8],
opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8,
}
}
_ => Overlay::None,
}
}
@@ -154,7 +142,6 @@ impl Overlay {
Overlay::PointColorRange(i) => 0x1000 + i as u64,
Overlay::Spots(t) => 0x2000 + t as u64,
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity),
Overlay::HdrRange => 0x4000,
}
}
@@ -181,9 +168,8 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of
/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components.
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) {
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]).
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) {
match o {
// drawn by the renderer from the HDR rendition ([`hdr_range`])
Overlay::None | Overlay::HdrRange => {}
@@ -198,15 +184,6 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>,
mask_view(img, a, view, color, opacity);
}
}
Overlay::AllMasks { view, color, opacity } => {
if let Some(planes) = masks {
for a in planes {
if (a.width, a.height) == (img.width, img.height) {
mask_view(img, a, view, color, opacity);
}
}
}
}
}
}
@@ -403,43 +380,4 @@ mod tests {
assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color);
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
}
#[test]
fn all_masks_overlay_shows_every_visible_mask_at_once() {
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let src = Rgb32f::from_fn(80, 40, |_, _| [0.1, 0.1, 0.1]);
let info = SourceInfo::default();
let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
visible: true,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }],
..Default::default()
});
s.masks.push(Mask {
id: 2,
visible: true,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }],
..Default::default()
});
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 };
let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40);
assert_eq!(img.data[in_l], [255; 4], "left mask region painted");
assert_eq!(img.data[in_r], [255; 4], "right mask region painted");
assert_eq!(img.data[mid], [0, 0, 0, 255], "between the masks: black");
// a hidden mask is skipped
s.masks[1].visible = false;
let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted");
assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted");
// parts round trip and a stable, distinct cache key
let (k, v) = o.to_parts();
assert_eq!(Overlay::from_parts(k, v), o);
assert_ne!(o.key(), Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }.key());
}
}
+8 -8
View File
@@ -94,20 +94,20 @@ impl DiskCache {
if std::fs::create_dir_all(parent).is_err() {
return;
}
// Serialize the file replacement and its size accounting. A repeated render of
// the same key replaces its JPEG; it must not count as another cache entry.
let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner());
let used = total.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum());
let old_len = std::fs::metadata(&p).map_or(0, |m| m.len());
// know the current total before adding (the first scan must not count this file twice)
let _ = self.size();
let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], ""));
if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() {
let _ = std::fs::remove_file(&tmp);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
*used = used.saturating_sub(old_len).saturating_add(bytes.len() as u64);
let over = *used > self.budget();
drop(total);
let over = {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
let total = t.get_or_insert(0);
*total += bytes.len() as u64;
*total > self.budget()
};
if over {
self.prune();
}
+5 -54
View File
@@ -30,11 +30,6 @@ struct Entry<S, R> {
struct Shared<S, R> {
queue: Mutex<Vec<Entry<S, R>>>,
/// Slots with a job running: their next job waits until it ends, so a slot never has two at
/// once (a slider drag submits faster than renders finish; overlapping renders of one view
/// were mostly thrown away, and took the GPU from the one that is shown). Locked inside
/// `queue`.
busy: Mutex<Vec<S>>,
cv: Condvar,
shutdown: AtomicBool,
/// The worker threads that have not ended yet, and the signal that one did.
@@ -61,8 +56,6 @@ pub struct JobPool<S, R> {
shared: Arc<Shared<S, R>>,
tx: Sender<Done<S, R>>,
rx: Receiver<Done<S, R>>,
/// A finished job [`Self::wait`] took off the channel, handed out first by [`Self::try_recv`].
early: Option<Done<S, R>>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
threads: usize,
started: bool,
@@ -80,7 +73,6 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
JobPool {
shared: Arc::new(Shared {
queue: Mutex::new(Vec::new()),
busy: Mutex::new(Vec::new()),
cv: Condvar::new(),
shutdown: AtomicBool::new(false),
#[cfg(not(target_arch = "wasm32"))]
@@ -90,7 +82,6 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
}),
tx,
rx,
early: None,
threads,
started: false,
seq: 0,
@@ -128,8 +119,6 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
let _ended = Ended(&shared);
while let Some(e) = take(&shared, true) {
let t0 = std::time::Instant::now();
// the slot is free again when the job ends, also if it panics
let _free = Free(&shared, e.slot);
let result = (e.job)();
let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 };
if tx.send(done).is_err() {
@@ -221,16 +210,8 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
}
/// A finished job, if any.
pub fn try_recv(&mut self) -> Option<Done<S, R>> {
self.early.take().or_else(|| self.rx.try_recv().ok())
}
/// Wait up to `timeout` for a job to finish; whether one has (it is the next [`Self::try_recv`]).
pub fn wait(&mut self, timeout: Duration) -> bool {
if self.early.is_none() {
self.early = self.rx.recv_timeout(timeout).ok();
}
self.early.is_some()
pub fn try_recv(&self) -> Option<Done<S, R>> {
self.rx.try_recv().ok()
}
/// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran.
@@ -238,7 +219,6 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
let mut ran = 0;
while ran < n {
let Some(e) = take(&self.shared, false) else { break };
let _free = Free(&self.shared, e.slot);
let result = (e.job)();
let _ = self.tx.send(Done { slot: e.slot, key: e.key, result, ms: 0.0 });
ran += 1;
@@ -249,21 +229,14 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
/// Pop the best job (highest priority, newest first). Blocks when `wait` until one is queued or
/// the pool shuts down.
fn take<S: PartialEq + Copy, R>(shared: &Shared<S, R>, wait: bool) -> Option<Entry<S, R>> {
fn take<S, R>(shared: &Shared<S, R>, wait: bool) -> Option<Entry<S, R>> {
let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner());
loop {
if shared.shutdown.load(Ordering::Relaxed) {
return None;
}
// (a slot with a job running waits for it: see `Shared::busy`)
let best = {
let busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner());
q.iter().enumerate().filter(|(_, e)| !busy.contains(&e.slot)).max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i)
};
if let Some(i) = best {
let e = q.swap_remove(i);
shared.busy.lock().unwrap_or_else(|e| e.into_inner()).push(e.slot);
return Some(e);
if let Some(i) = q.iter().enumerate().max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) {
return Some(q.swap_remove(i));
}
if !wait {
return None;
@@ -272,28 +245,6 @@ fn take<S: PartialEq + Copy, R>(shared: &Shared<S, R>, wait: bool) -> Option<Ent
}
}
/// The job for `slot` ended: a job queued for it may start.
fn finished<S: PartialEq, R>(shared: &Shared<S, R>, slot: &S) {
{
// under the queue lock: a worker is either before its look at `busy` or waiting
let _q = shared.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner());
if let Some(i) = busy.iter().position(|s| s == slot) {
busy.swap_remove(i);
}
}
shared.cv.notify_all();
}
/// Frees a job's slot when the job ends (see [`finished`]).
struct Free<'a, S: PartialEq, R>(&'a Shared<S, R>, S);
impl<S: PartialEq, R> Drop for Free<'_, S, R> {
fn drop(&mut self) {
finished(self.0, &self.1);
}
}
/// Takes a worker off the live list when its thread ends (also when a job panicked).
#[cfg(not(target_arch = "wasm32"))]
struct Ended<'a, S, R>(&'a Shared<S, R>);
-117
View File
@@ -123,34 +123,6 @@ fn disk_cache_roundtrip_and_prune() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() {
let dir = temp_dir("overwrite");
let cache = DiskCache::new(&dir, 1 << 20);
let (a, b) = (hash_bytes(b"overwrite-a"), hash_bytes(b"overwrite-b"));
let first = gradient(80, 60, 4);
cache.put(a, &first);
cache.put(b, &gradient(80, 60, 7));
let initial = cache.size();
// Re-rendering the same key used to double-count bytes although the
// replacement occupied exactly the same pathname on disk.
cache.put(a, &first);
assert_eq!(cache.size(), initial);
assert!(cache.get(b).is_some());
// A replacement with a different encoded size must also update the total.
cache.put(a, &gradient(120, 90, 9));
let path = |key: Hash128| {
let hex = key.to_string();
dir.join(&hex[..2]).join(format!("{hex}.jpg"))
};
let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len();
assert_eq!(cache.size(), actual);
cache.clear();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn preview_cache_memory_then_disk() {
let dir = temp_dir("pc");
@@ -379,92 +351,3 @@ fn disk_cache_never_touches_foreign_files() {
assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak"));
let _ = std::fs::remove_dir_all(&dir);
}
/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or
/// after the timeout.
#[test]
fn pool_wait_keeps_the_finished_job_for_try_recv() {
let mut p: JobPool<u8, u8> = JobPool::new(1);
assert!(!p.wait(std::time::Duration::from_millis(10)), "nothing runs");
p.submit(1, 0, 1, Box::new(|| 7));
assert!(p.wait(std::time::Duration::from_secs(10)));
assert!(p.wait(std::time::Duration::ZERO), "kept until taken");
assert_eq!(p.try_recv().map(|d| d.result), Some(7));
assert!(p.try_recv().is_none());
}
/// Wait up to `secs` for `f` to hold.
fn eventually(secs: u64, mut f: impl FnMut() -> bool) -> bool {
let t0 = std::time::Instant::now();
while t0.elapsed() < std::time::Duration::from_secs(secs) {
if f() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
}
/// A slider drag submits for a view faster than its renders finish. A newer job for a slot used
/// to start beside the running one on another worker: one view's renders piled up on the GPU and
/// most were thrown away. Now a slot runs one job at a time: the newer one waits (replacing an
/// older one still queued), other slots keep going, and it starts when the slot's job ends.
#[test]
fn a_slot_runs_one_job_at_a_time() {
use std::sync::atomic::{AtomicUsize, Ordering};
let mut p: JobPool<u8, u32> = JobPool::new(4);
let (release, released) = std::sync::mpsc::channel::<()>();
let (started, has_started) = std::sync::mpsc::channel();
let running = Arc::new(AtomicUsize::new(0));
let most = Arc::new(AtomicUsize::new(0));
let job = |n: u32, wait: Option<std::sync::mpsc::Receiver<()>>| -> crate::pool::Job<u32> {
let (running, most, started) = (running.clone(), most.clone(), started.clone());
Box::new(move || {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
most.fetch_max(now, Ordering::SeqCst);
let _ = started.send(n);
if let Some(w) = wait {
let _ = w.recv_timeout(std::time::Duration::from_secs(30));
}
running.fetch_sub(1, Ordering::SeqCst);
n
})
};
p.submit(1, 1, 9, job(1, Some(released)));
assert_eq!(has_started.recv().unwrap(), 1);
// two newer requests for the busy slot: the second replaces the first, neither starts
p.submit(1, 2, 9, job(2, None));
p.submit(1, 3, 9, job(3, None));
// another slot is not held up
p.submit(2, 9, 1, Box::new(|| 9));
let mut done = Vec::new();
assert!(eventually(10, || {
done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result));
done.contains(&9)
}));
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(has_started.try_recv().is_err(), "a second job of slot 1 started beside the first");
assert!(p.is_queued(1));
drop(release);
assert!(eventually(10, || {
done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result));
done.contains(&3)
}));
assert!(!done.contains(&2), "the replaced job never runs");
assert_eq!(most.load(Ordering::SeqCst), 1, "slot 1 ran two jobs at once");
}
/// A job that panics still frees its slot: the slot's next job runs (on another worker).
#[test]
fn a_panicking_job_frees_its_slot() {
let mut p: JobPool<u8, u32> = JobPool::new(2);
p.submit(1, 1, 9, Box::new(|| panic!("simulated render panic")));
assert!(eventually(10, || !p.is_queued(1) && p.live_workers() == 1));
p.submit(1, 2, 9, Box::new(|| 2));
let mut got = None;
assert!(eventually(10, || {
got = got.or_else(|| p.try_recv().map(|d| d.result));
got.is_some()
}));
assert_eq!(got, Some(2));
}
+2 -2
View File
@@ -77,10 +77,10 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]>
}
let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]);
let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25;
gh[i] = super::clamp_between(est, l, r);
gh[i] = est.clamp(l.min(r), l.max(r));
let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]);
let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25;
gv[i] = super::clamp_between(est, u, d);
gv[i] = est.clamp(u.min(d), u.max(d));
}
}
// 2. full candidates
-39
View File
@@ -52,14 +52,6 @@ pub(crate) fn reflect(i: isize, n: usize) -> usize {
i as usize
}
/// `v` clamped between `a` and `b`: `v.clamp(a.min(b), a.max(b))`, except that it returns `v` as is when both
/// bounds are NaN (NaN samples in a float raw), where `f32::clamp` panics.
#[inline]
pub(crate) fn clamp_between(v: f32, a: f32, b: f32) -> f32 {
let (lo, hi) = (a.min(b), a.max(b));
if lo <= hi { v.clamp(lo, hi) } else { v }
}
impl Mosaic<'_> {
#[inline]
pub fn get(&self, x: isize, y: isize) -> f32 {
@@ -230,37 +222,6 @@ mod tests {
assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]);
}
#[test]
fn non_finite_samples_do_not_panic() {
// regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics
let cfa = Cfa::bayer("RGGB").unwrap();
let mut patchy = mosaic_from_rgb(&edgy_scene(24, 20), &cfa);
for (i, v) in patchy.data.iter_mut().enumerate() {
let (x, y) = (i % 24, i / 24);
if (8..12).contains(&x) && (6..10).contains(&y) {
*v = f32::NAN;
} else if i % 7 == 0 {
*v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY };
}
}
let all_nan = Normalized { width: 9, height: 7, cpp: 1, data: vec![f32::NAN; 63], cfa: Some(cfa) };
for n in [&patchy, &all_nan] {
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let out = demosaic(n, m);
assert_eq!((out.width, out.height), (n.width, n.height), "{m:?}");
}
}
// without NaN bounds it is the `f32::clamp` it replaced, bit for bit (finite results stay identical)
let vals = [f32::NEG_INFINITY, -3e38, -2.5, -0.0, 0.0, 1e-40, 0.5, 1.0, 3e38, f32::INFINITY];
for v in vals {
for a in vals {
for b in vals {
assert_eq!(clamp_between(v, a, b).to_bits(), v.clamp(a.min(b), a.max(b)).to_bits(), "{v} {a} {b}");
}
}
}
}
#[test]
fn reflect_indices() {
assert_eq!(reflect(-1, 5), 1);
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn green_plane(m: &Mosaic) -> Vec<f32> {
_ => (ge, gw, ce),
};
let est = (near * 3.0 + far + c - cfar) * 0.25;
*o = super::clamp_between(est, near, far);
*o = est.clamp(near.min(far), near.max(far));
}
});
g
+1 -14
View File
@@ -121,7 +121,6 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData {
// DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored
baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0),
profile: profile_look(ifd0, raw, order),
kelvin_presets: None,
}
}
@@ -235,16 +234,6 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
if white.is_empty() {
white = vec![default_white];
}
// No sensor records NaN or infinity, so such a float sample is damage. Left in, it turns into NaN in demosaic
// arithmetic and spreads; NaN and −∞ read as black (0), +∞ as the white level (a clipped highlight). Finite
// samples, negative or above white included, are kept as stored.
if let RawData::F32(v) = &mut data {
for (i, s) in v.iter_mut().enumerate() {
if !s.is_finite() {
*s = if *s > 0.0 { white.get(i % cpp).or(white.first()).copied().unwrap_or(default_white) } else { 0.0 };
}
}
}
let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
let (br, bc) = match dim.as_slice() {
@@ -301,9 +290,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
if cpp != 1 {
return Err(RawError::Unsupported("CFA data with several samples per pixel".into()));
}
// the tag is anchored at the top-left of the ActiveArea (DNG spec); `RawImage::cfa` is anchored at the
// sensor origin (the crop to the active area shifts it back), so move the anchor out by the area's offset
Some(Cfa { width: cols, height: rows, pattern }.shifted(cols - active_area.x % cols, rows - active_area.y % rows))
Some(Cfa { width: cols, height: rows, pattern })
} else {
None
};
+1 -64
View File
@@ -125,9 +125,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
if let Some(cfa) = &raw.cfa {
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![cfa.height as u16, cfa.width as u16]));
// the tag is anchored at the ActiveArea's top-left, `raw.cfa` at the sensor origin
let a = raw.active_area;
ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.shifted(a.x, a.y).pattern));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.pattern.clone()));
ifd.set(t::CFA_PLANE_COLOR, Value::Byte(vec![0, 1, 2]));
ifd.set(t::CFA_LAYOUT, Value::Short(vec![1]));
}
@@ -364,64 +362,3 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
}
Ok(TiffWriter::new(opts.order, false).write(&[ifd])?)
}
/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23).
fn illuminant_of(white: lightcraft_color::Xy) -> u16 {
if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 }
}
/// Render to DNG: a linear DNG (`LinearRaw`, 16-bit float Deflate tiles) of an already rendered,
/// oriented image. `rgb` is `width × height` interleaved linear RGB in `space` (white = 1; values
/// above 1 are kept up to the half-float maximum, negative and non-finite ones become 0). The colour tags describe `space`
/// exactly — `ColorMatrix1` = XYZ → space, `ForwardMatrix1` = space → XYZ D50, a neutral
/// `AsShotNeutral` — and an identity `ProfileToneCurve` keeps raw converters from adding their
/// default tone curve on top of the baked-in edit.
pub fn write_rendered_dng(
width: usize,
height: usize,
rgb: &[f32],
space: &lightcraft_color::RgbSpace,
metadata: &lightcraft_meta::Metadata,
xmp: Option<String>,
) -> Result<Vec<u8>> {
let n = width.checked_mul(height).and_then(|p| p.checked_mul(3)).ok_or(RawError::Limit("image too large"))?;
if width == 0 || height == 0 || width > u32::MAX as usize || height > u32::MAX as usize {
return Err(RawError::Corrupt(format!("cannot write a {width}×{height} DNG")));
}
let src = rgb.get(..n).ok_or_else(|| RawError::Corrupt(format!("{} samples for a {width}×{height} RGB image", rgb.len())))?;
let data: Vec<f32> = src.iter().map(|&v| if v.is_finite() { v.clamp(0.0, 65504.0) } else { 0.0 }).collect();
let full = crate::Rect::new(0, 0, width, height);
let forward = lightcraft_color::bradford(space.white, lightcraft_color::D50).mul(&space.to_xyz());
let color = crate::ColorData {
illuminant: [illuminant_of(space.white), 0],
color_matrix: [Some(space.from_xyz()), None],
forward_matrix: [Some(forward), None],
as_shot_neutral: Some([1.0; 3]),
profile: crate::profile::ProfileLook {
tone_curve: Some(crate::profile::ToneCurve { points: vec![[0.0, 0.0], [1.0, 1.0]] }),
..Default::default()
},
..Default::default()
};
let raw = RawImage {
format: crate::RawFormat::Dng,
width,
height,
cpp: 3,
data: RawData::F32(data),
cfa: None,
bits: 16,
black: crate::BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: full,
crop: full,
orientation: lightcraft_geom::Orientation::Normal,
color,
wb_multipliers: None,
linearized: false,
opcodes: crate::OpcodeLists::default(),
metadata: metadata.clone(),
};
let order = ByteOrder::Little;
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
}
-227
View File
@@ -1,227 +0,0 @@
//! A Kelvin scale from the camera's own white-balance presets, for raws whose camera has no colour matrices.
//!
//! Sony ARWs from the DSLR-A200 on store, in their encrypted `SR2SubIFD` (see `vendor::sr2`), the multipliers the
//! camera would apply for each of its white-balance presets, among them five at a stated colour temperature:
//! `WB_RGBLevels2500K` (`0x782d`), `3200K` (`0x782c`), `4500K` (`0x7824`), `6000K` (`0x782b`) and `8500K` (`0x782a`;
//! tag numbers from `exiftool -v3`, run as a black-box tool on the files). They are Sony's own reading of its sensor:
//! the as-shot multipliers placed on the curve through them give the temperature Sony's Kelvin dial names for that
//! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the
//! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730).
//!
//! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]):
//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments
//! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to
//! 8500 K within ±3 %), so the segments add no visible kinks.
//! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled
//! by the segment's own spacing of the Planckian locus in CIE 1960 (u, v), per unit arc length.
//! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by
//! [`ISOTHERM_TILT`] times the mired the same distance along the curve would.
//!
//! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0
//! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices):
//! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1).
//! On 150 ILCE-7SM2 photos read both ways, the temperatures agree to ±4 mired around a constant offset (Sony's
//! Kelvin is about 8 % above the matrices', as #730 found for the ILCE-7M3's dial) and the tint to −9 ± 4: the
//! matrices put Sony's presets slightly off the locus, the presets themselves are tint 0 here.
use crate::ColorData;
use lightcraft_color::{Xy, cct};
use serde::{Deserialize, Serialize};
/// Mired change per unit offset from the preset curve, in units of the mired change per unit distance along it.
pub const ISOTHERM_TILT: f64 = 0.74;
/// The slider's range: an as-shot reading is kept inside it.
const MIN_K: f64 = 2000.0;
const MAX_K: f64 = 50000.0;
const MAX_TINT: f64 = 150.0;
/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own
/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A
/// camera with measured matrices (`crate::spectral`) is read through those instead.
const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"];
/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first).
pub fn covers(make: Option<&str>, model: &str) -> bool {
let model = model.trim();
let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model);
make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY")))
&& MODELS.iter().any(|m| m.eq_ignore_ascii_case(model))
}
/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`).
pub fn covers_camera(camera: &str) -> bool {
match camera.split_once(char::is_whitespace) {
Some((make, model)) => covers(Some(make), model),
None => covers(None, camera),
}
}
/// A camera's multipliers at five stated temperatures, ascending, green = 1.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct KelvinPresets {
points: [(f32, [f32; 3]); 5],
}
fn usable(m: [f32; 3]) -> bool {
m.iter().all(|v| *v > 0.0 && v.is_finite())
}
fn log_point(m: [f32; 3]) -> [f64; 2] {
[(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()]
}
fn locus_uv(t: f64) -> (f64, f64) {
cct::xy_to_uv(cct::temp_tint_to_xy(t, 0.0))
}
impl KelvinPresets {
/// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive
/// multipliers, red rising and blue falling from one preset to the next.
pub fn new(points: [(f32, [f32; 3]); 5]) -> Option<KelvinPresets> {
let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m))
&& points.windows(2).all(|w| {
let (a, b) = (log_point(w[0].1), log_point(w[1].1));
w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1]
});
ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) })
}
/// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable
/// multipliers.
pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> {
if !usable(wb_multipliers) {
return None;
}
let q = log_point(wb_multipliers);
let last = self.points.len() - 2;
let mut best: Option<(f64, f64, f64)> = None;
for (i, w) in self.points.windows(2).enumerate() {
let ((k0, m0), (k1, m1)) = (w[0], w[1]);
let (p0, p1) = (log_point(m0), log_point(m1));
let d = [p1[0] - p0[0], p1[1] - p0[1]];
let len = d[0].hypot(d[1]);
let t = [d[0] / len, d[1] / len];
// the normal towards higher red and blue: a green white
let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] };
let r = [q[0] - p0[0], q[1] - p0[1]];
let along = (r[0] * t[0] + r[1] * t[1]) / len;
let off = r[0] * n[0] + r[1] * n[1];
let lo = if i == 0 { f64::NEG_INFINITY } else { 0.0 };
let hi = if i == last { f64::INFINITY } else { 1.0 };
let c = along.clamp(lo, hi);
let dist = (r[0] - c * d[0]).hypot(r[1] - c * d[1]);
let (mired0, mired1) = (1e6 / k0 as f64, 1e6 / k1 as f64);
let mired_per_len = (mired1 - mired0) / len;
let ((u0, v0), (u1, v1)) = (locus_uv(k0 as f64), locus_uv(k1 as f64));
let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len;
let mired = mired0 + along * (mired1 - mired0) + ISOTHERM_TILT * mired_per_len * off;
if best.is_none_or(|b| dist < b.0) {
best = Some((dist, mired, tint_per_len * off));
}
}
let (_, mired, tint) = best?;
let t = 1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K);
Some((t, tint.clamp(-MAX_TINT, MAX_TINT)))
}
/// The as-shot white of a file through its presets: [`Self::temp_tint`] of its multipliers as a chromaticity.
pub fn as_shot_white(&self, wb_multipliers: Option<[f32; 3]>) -> Option<Xy> {
let (t, tint) = self.temp_tint(wb_multipliers?)?;
Some(cct::temp_tint_to_xy(t, tint))
}
}
/// The as-shot white of a file through its own presets ([`ColorData::kelvin_presets`]); `None` without presets or
/// usable multipliers.
pub fn as_shot_white(color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option<Xy> {
color.kelvin_presets.as_ref()?.as_shot_white(wb_multipliers)
}
#[cfg(test)]
mod tests {
use super::*;
/// The ILCA-99M2's presets (every file of the body carries the same).
fn a99m2() -> KelvinPresets {
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
KelvinPresets::new([
(2500.0, g(1336.0, 3672.0)),
(3200.0, g(1720.0, 2620.0)),
(4500.0, g(2256.0, 1880.0)),
(6000.0, g(2680.0, 1544.0)),
(8500.0, g(3136.0, 1300.0)),
])
.unwrap()
}
#[test]
fn a_preset_reads_as_its_own_temperature_with_no_tint() {
let p = a99m2();
for (k, m) in p.points {
let (t, tint) = p.temp_tint(m).unwrap();
assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}");
// the scale of the multipliers doesn't matter, only their ratios to green
let (t2, tint2) = p.temp_tint(m.map(|v| v * 2.3)).unwrap();
assert!((t2 - t).abs() < 1e-3 && (tint2 - tint).abs() < 1e-4, "{t2} {tint2}");
}
}
#[test]
fn between_presets_the_temperature_follows_the_curve() {
let p = a99m2();
let mid = |a: [f32; 3], b: [f32; 3]| [(a[0] * b[0]).sqrt(), 1.0, (a[2] * b[2]).sqrt()];
let (t, tint) = p.temp_tint(mid(p.points[2].1, p.points[3].1)).unwrap();
assert!((4500.0..6000.0).contains(&t) && tint.abs() < 1.0, "{t} {tint}");
// beyond the ends: extended, then held to the slider's range
let (hot, _) = p.temp_tint([3.5, 1.0, 1.15]).unwrap();
let (cold, _) = p.temp_tint([1.15, 1.0, 4.0]).unwrap();
let (coldest, _) = p.temp_tint([0.3, 1.0, 20.0]).unwrap();
assert!((8500.0..MAX_K).contains(&hot) && (MIN_K..2500.0).contains(&cold) && coldest == MIN_K, "{hot} {cold} {coldest}");
}
#[test]
fn less_green_in_the_multipliers_is_a_green_white_and_a_magenta_correction() {
let p = a99m2();
let day = [2680.0 / 1024.0, 1.0, 1544.0 / 1024.0];
let (t0, _) = p.temp_tint(day).unwrap();
// a green light: the camera raises red and blue against green
let (t1, tint1) = p.temp_tint([day[0] * 1.08, 1.0, day[2] * 1.08]).unwrap();
assert!(tint1 > 15.0 && tint1 < 40.0, "{tint1}");
// the tilted isotherm: a higher temperature than the curve point it sits beside
assert!(t1 > t0 + 100.0, "{t1} {t0}");
let (_, magenta) = p.temp_tint([day[0] / 1.08, 1.0, day[2] / 1.08]).unwrap();
assert!(magenta < -15.0, "{magenta}");
}
#[test]
fn unusable_presets_or_multipliers_give_nothing() {
let p = a99m2();
assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None);
assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None);
assert_eq!(p.as_shot_white(None), None);
let mut pts = p.points;
pts.swap(1, 2);
assert_eq!(KelvinPresets::new(pts), None, "out of order");
let mut pts = p.points;
pts[4].1 = pts[3].1;
assert_eq!(KelvinPresets::new(pts), None, "no step");
let mut pts = p.points;
pts[0].1[0] = 0.0;
assert_eq!(KelvinPresets::new(pts), None);
assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None);
let with = ColorData { kelvin_presets: Some(p), ..Default::default() };
assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some());
}
#[test]
fn covered_models() {
for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] {
assert!(covers_camera(c), "{c}");
}
for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] {
assert!(!covers_camera(c), "{c}");
}
assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2"));
}
}
+3 -82
View File
@@ -34,7 +34,6 @@ pub mod gaintable;
pub mod highlight;
#[cfg(feature = "jxl")]
mod jxl;
pub mod kelvin_presets;
pub mod ljpeg;
pub(crate) mod llvc;
pub mod opcodes;
@@ -49,7 +48,7 @@ mod unpack;
mod vendor;
pub use demosaic::{Method, demosaic};
pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng};
pub use dngwrite::{DngCompression, DngWriteOptions, write_dng};
pub use lightcraft_color::Mat3;
pub use lightcraft_geom::Orientation;
pub use lightcraft_meta::Metadata;
@@ -190,10 +189,7 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if make.starts_with("NIKON") && has_cfa {
return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw });
}
// Hasselblad's Sony-built bodies (the Lunar is a NEX-7 inside) write a Sony ARW under their own Make. Sony's
// raw coding gives them away: compression 32767 or the raw IFD's tone-curve tag 0x7010, which no 3FR / FFF has
let sony_coded = || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION) == Some(32767) || i.contains(0x7010));
if (make.starts_with("SONY") && sony_raw) || (make.starts_with("HASSELBLAD") && sony_coded()) {
if make.starts_with("SONY") && sony_raw {
return Some(RawFormat::Arw);
}
if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw {
@@ -212,23 +208,12 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
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) || is_leaf_mosaic(ifd0) {
if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) {
return Some(RawFormat::OtherTiff);
}
None
}
/// Leaf's private data block in IFD0, tag `0x8606` (`LeafData` in the ExifTool EXIF tag-name documentation, which
/// says the Leaf tags "are found in .MOS images from Leaf digital camera backs").
const LEAF_DATA: u16 = 0x8606;
/// An uncompressed Leaf MOS: IFD0 is the 16-bit mosaic itself (compression 1, BlackIsZero, one sample per pixel),
/// with no Make tag and no CFA photometric, so only Leaf's data block marks it as a raw (issue #1275). A rendered
/// RGB image that kept the block stays an ordinary image.
fn is_leaf_mosaic(ifd0: &lightcraft_tiff::Ifd) -> bool {
ifd0.contains(LEAF_DATA) && ifd0.u16(lightcraft_tiff::tags::SAMPLES_PER_PIXEL).unwrap_or(1) == 1
}
/// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`).
fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool {
let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false };
@@ -613,10 +598,6 @@ pub struct ColorData {
/// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`).
#[serde(default)]
pub profile: profile::ProfileLook,
/// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour
/// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it.
#[serde(default)]
pub kelvin_presets: Option<kelvin_presets::KelvinPresets>,
}
/// A decoded raw image.
@@ -1085,66 +1066,6 @@ mod tests {
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
}
/// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's
/// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD
/// without the block, or an RGB image carrying it, stays an ordinary image.
#[test]
fn uncompressed_leaf_mos_is_a_raw() {
let mosaic = |leaf_data: bool| {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] });
if leaf_data {
ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec()));
}
ifd
};
let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap();
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_))));
assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_))));
assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF");
let mut rgb = rgb_ifd(16, 12);
rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec()));
assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block");
}
/// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767
/// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there.
#[test]
fn hasselblad_make_with_sony_raw_coding_is_arw() {
let file = |make: &str, compression: u16, tone_curve: bool| {
let mut raw = IfdBuilder::new();
raw.set(t::IMAGE_WIDTH, Value::Long(vec![64]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
raw.set(t::COMPRESSION, Value::Short(vec![compression]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
if tone_curve {
raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100]));
}
raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] });
let mut ifd0 = rgb_ifd(4, 3);
ifd0.set(t::MAKE, Value::Ascii(make.into()));
ifd0.set(t::MODEL, Value::Ascii("Lunar".into()));
ifd0.add_sub_ifd(raw);
write(&[ifd0])
};
assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw));
assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw));
let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap();
assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14));
// a Hasselblad CFA image in another coding is not an ARW
assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw));
assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw));
}
// --- 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`.
+4 -105
View File
@@ -62,9 +62,6 @@ const CROP_SIZE: u16 = 0x74c8;
const SR2_OLD_BLACK: u16 = 0x7300;
const SR2_OLD_WB: u16 = 0x7303;
const SR2_OLD_CROP: u16 = 0x74c3;
/// `SR2SubIFD` white-balance presets at a stated temperature (`WB_RGBLevels2500K` … `8500K`: R, G, B), which give
/// the Kelvin scale of a camera without colour matrices (`crate::kelvin_presets`).
const SR2_KELVIN_PRESETS: [(f32, u16); 5] = [(2500.0, 0x782d), (3200.0, 0x782c), (4500.0, 0x7824), (6000.0, 0x782b), (8500.0, 0x782a)];
const YCBCR_COEFFICIENTS: u16 = 529;
const REFERENCE_BLACK_WHITE: u16 = 532;
/// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width).
@@ -202,28 +199,9 @@ const TAG2010_WB: &[(&[&str], usize)] = &[
),
];
/// Maker-note `SonyModelID` (`0xb001`, int16u, ExifTool Sony tag documentation).
const MN_SONY_MODEL_ID: u16 = 0xb001;
/// `SonyModelID` values (ExifTool Sony tag documentation) of the Sony bodies Hasselblad sold under its own Make and
/// model (the Lunar, a NEX-7; the Stellar and Stellar II, an RX100 and RX100 II; the HV, an SLT-A99), so that
/// [`TAG2010_WB`] finds their layout. Measured on the CC0 Lunar (raw.pixls.us 4051): Make HASSELBLAD, Model Lunar,
/// SonyModelID 289 (issue #1275).
const SONY_MODEL_IDS: &[(u64, &str)] = &[(289, "NEX-7"), (294, "SLT-A99"), (297, "DSC-RX100"), (308, "DSC-RX100M2")];
/// The Sony body for the model-keyed tables: the Exif `model` of a Sony-branded file, else the one the maker note's
/// `SonyModelID` names (a Lunar says "Lunar"), else `model` as it is.
fn sony_body<'a>(make: &str, model: &'a str, mn: Option<&makernote::MakerNote>) -> &'a str {
if make.trim().to_ascii_uppercase().starts_with("SONY") {
return model;
}
let id = mn.and_then(|m| m.ifd.u64(MN_SONY_MODEL_ID));
SONY_MODEL_IDS.iter().find(|(i, _)| Some(*i) == id).map_or(model, |(_, name)| name)
}
/// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`].
/// `model` is the Sony body ([`sony_body`]); Sony appends a regional "V" to some names (SLT-A77V), which the
/// documented lists omit.
/// `model` is the Exif model; Sony appends a regional "V" to some names (SLT-A77V), which the documented lists
/// omit.
fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> {
let model = model.trim();
let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?;
@@ -500,16 +478,6 @@ fn sr2_old_wb(sr2: &super::sr2::SubIfd) -> Option<[f32; 3]> {
}
/// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame.
/// The five presets of [`SR2_KELVIN_PRESETS`], when the block has them all and they make a curve.
fn sr2_kelvin_presets(sr2: &super::sr2::SubIfd) -> Option<crate::kelvin_presets::KelvinPresets> {
let mut points = [(0.0f32, [0.0f32; 3]); 5];
for (p, (k, tag)) in points.iter_mut().zip(SR2_KELVIN_PRESETS) {
let v = sr2.shorts(tag).filter(|v| v.len() == 3)?;
*p = (k, [v[0] as f32, v[1] as f32, v[2] as f32]);
}
crate::kelvin_presets::KelvinPresets::new(points)
}
fn sr2_old_crop(sr2: &super::sr2::SubIfd, w: usize, h: usize) -> Option<Rect> {
let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?;
let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize];
@@ -729,12 +697,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
.exif()
.and_then(|e| e.get(t::MAKER_NOTE))
.and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default()));
let body = sony_body(&ifd0.string(t::MAKE).unwrap_or_default(), &model, mn.as_ref());
let wb = raw
.f64s(WB_RGGB)
.and_then(|v| rggb_gains(&v))
.or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?))
.or_else(|| mn.as_ref().and_then(|m| tag2010_wb(body, m.ifd.bytes(MN_TAG2010)?, m.order)))
.or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order)))
.or_else(|| sr2_old_wb(sr2()?));
let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) {
(Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h),
@@ -761,8 +728,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active_area: Rect::new(0, 0, w, h),
crop,
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
// (linear YCbCr comes white-balanced: its unit multipliers have no place on the presets' curve)
color: ColorData { kelvin_presets: if linear_rgb { None } else { sr2().and_then(sr2_kelvin_presets) }, ..Default::default() },
color: ColorData::default(),
// Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again
// makes a neutral surface magenta. WB edits remain relative to this as-shot RGB.
wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb },
@@ -1211,73 +1177,6 @@ mod tests {
file
}
/// Issue #1275: a Hasselblad Lunar (Make HASSELBLAD, Model Lunar) is a NEX-7. Its maker note (header-less, as the
/// Lunar's is) names the body by `SonyModelID`, which picks the NEX-7's `Tag2010` layout for the fallback white
/// balance; without the ID, or with one that isn't listed, there is no layout to read.
#[test]
fn rebadged_body_takes_its_tag2010_layout_from_the_sony_model_id() {
use lightcraft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value};
let file = |make: &str, model: &str, id: Option<u16>| {
let mut block = vec![0u8; 4600];
for (i, v) in [639u16, 256, 368].iter().enumerate() {
block[4480 + 2 * i..4482 + 2 * i].copy_from_slice(&v.to_le_bytes());
}
let block = encipher(&block);
let note = |at: usize| -> Vec<u8> {
let entries = 1 + u16::from(id.is_some());
let mut n = entries.to_le_bytes().to_vec();
n.extend(MN_TAG2010.to_le_bytes());
n.extend(7u16.to_le_bytes());
n.extend((block.len() as u32).to_le_bytes());
n.extend(((at + 2 + 12 * entries as usize + 4) as u32).to_le_bytes());
if let Some(id) = id {
n.extend(MN_SONY_MODEL_ID.to_le_bytes());
n.extend(3u16.to_le_bytes());
n.extend(1u32.to_le_bytes());
n.extend(id.to_le_bytes());
n.extend([0, 0]);
}
n.extend([0; 4]);
n.extend(&block);
n
};
let mut raw = IfdBuilder::new();
raw.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![32767]));
raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100]));
raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii(make.into()));
ifd0.set(t::MODEL, Value::Ascii(model.into()));
ifd0.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0))));
ifd0.add_sub_ifd(raw);
let mut file = TiffWriter::new(Little, false).write(&[ifd0]).unwrap();
let parsed = Tiff::parse(&file).unwrap();
let e = parsed.exif().unwrap().get(t::MAKER_NOTE).unwrap();
let at = e.offset as usize;
file[at..at + e.count()].copy_from_slice(&note(at));
file
};
let wb = |make: &str, model: &str, id: Option<u16>| {
let bytes = file(make, model, id);
assert_eq!(crate::probe(&bytes), Some(RawFormat::Arw), "{make} {model}");
let full = crate::decode(&bytes).unwrap();
assert_eq!(crate::probe_info(&bytes).unwrap().wb_multipliers, full.wb_multipliers);
full.wb_multipliers
};
let nex7 = Some([639.0 / 256.0, 1.0, 368.0 / 256.0]);
assert_eq!(wb("HASSELBLAD", "Lunar", Some(289)), nex7);
assert_eq!(wb("SONY", "NEX-7", None), nex7, "the Sony body itself");
assert_eq!(wb("HASSELBLAD", "Lunar", None), None, "no SonyModelID");
assert_eq!(wb("HASSELBLAD", "Lunar", Some(288)), None, "an ID that is not listed (NEX-5N)");
// a Sony file keeps its Exif model whatever the ID says
assert_eq!(wb("SONY", "ILCE-1", Some(289)), None);
assert_eq!(sony_body("HASSELBLAD", "HV", None), "HV");
}
#[test]
fn dsc_r1_sr2_subifd_gives_black_wb_and_crop() {
use lightcraft_tiff::ByteOrder::Little;
+21 -149
View File
@@ -23,7 +23,7 @@
mod sraw;
use super::{active_rows, black_from_columns, white_from_data};
use super::{black_from_columns, white_from_data};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
@@ -37,10 +37,6 @@ const SRAW_YCC: u32 = 4;
const CR2_CFA_PATTERN: u16 = 0xc5e0;
const SENSOR_INFO: u16 = 0x00e0;
const COLOR_BALANCE: u16 = 0x4001;
/// ShotInfo, whose value 7 is the white-balance setting.
const SHOT_INFO: u16 = 0x0004;
/// WBInfo, the PowerShot G9's preset levels (it writes no `ColorData`).
const WB_INFO: u16 = 0x0029;
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE)))
@@ -69,51 +65,23 @@ fn words(v: &Value, order: ByteOrder) -> Vec<u64> {
}
}
/// WB multipliers (R, G, B; G = 1) from one set of levels, when they are plausible.
fn wb_from_levels(r: f32, g1: f32, g2: f32, b: f32) -> Option<[f32; 3]> {
if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 {
return None;
}
let g = (g1 + g2) / 2.0;
let (mr, mb) = (r / g, b / g);
((0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb)).then_some([mr, 1.0, mb])
}
/// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array.
fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] {
let q = v.get(off..off + 4)?;
if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) {
return Some(wb);
let (r, g1, g2, b) = (q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32);
if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 {
continue;
}
let g = (g1 + g2) / 2.0;
let (mr, mb) = (r / g, b / g);
if (0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb) {
return Some([mr, 1.0, mb]);
}
}
None
}
/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that
/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in
/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy,
/// 26 Tungsten, 34 Fluorescent, 50 Flash, 58 Underwater, 66 Custom 1, 74 Custom 2); other settings (14, Daylight
/// Fluorescent, has no documented row) give none. Measured on the CC0 file IMG_1188.CR2 (Custom 1, row 66:
/// 848 1747 1676 848), whose camera JPEG's neutral areas imply the same gains within 0.1 %.
fn wb_from_wb_info(m: &makernote::MakerNote) -> Option<[f32; 3]> {
let row = match m.ifd.u64s(SHOT_INFO)?.get(7)? {
0 => 2,
1 => 10,
2 => 18,
3 => 26,
4 => 34,
5 => 50,
15 => 66,
16 => 74,
17 => 58,
_ => return None,
};
let b = m.ifd.bytes(WB_INFO)?;
let level = |i: usize| Some(m.order.u32(b.get(4 * (row + i)..4 * (row + i + 1))?.try_into().ok()?) as i32 as f32);
wb_from_levels(level(1)?, level(0)?, level(3)?, level(2)?)
}
/// The optically black columns left of the image area: from column 2 up to two columns before the first column
/// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining
/// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow.
@@ -219,13 +187,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
}
}
let wb = mn
.as_ref()
.and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)))
.and_then(|v| wb_from_color_balance(&v))
.or_else(|| wb_from_wb_info(mn.as_ref()?));
let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v));
let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB"));
let white = white_from_data(&active_rows(&data, width, active), prec as u32);
let white = white_from_data(&data, prec as u32);
let black = match masked_columns(&data, width, active, white) {
cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active),
_ => BlackLevel::uniform(0.0),
@@ -269,11 +233,6 @@ pub(crate) mod tests {
/// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored).
fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> (Vec<u8>, Vec<u16>) {
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
(synthetic_cr2_of(&img, w, h, slices, note), img)
}
/// A synthetic CR2 whose sensor samples are `img` (`w`×`h`, 14-bit), with `note` as the Exif maker note.
fn synthetic_cr2_of(img: &[u16], w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> Vec<u8> {
let widths: Vec<usize> = match slices {
Some([n, sw, last]) => {
let mut v = vec![sw as usize; n as usize];
@@ -310,9 +269,10 @@ pub(crate) mod tests {
}
ifd3.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![enc] });
// (real files also carry a "CR\x02\0" signature at offset 8; probe recognises the structure without it)
TiffWriter::new(ByteOrder::Little, false)
let bytes = TiffWriter::new(ByteOrder::Little, false)
.write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), IfdBuilder::new().with(1, Value::Short(vec![0])), ifd3])
.unwrap()
.unwrap();
(bytes, img)
}
#[test]
@@ -392,13 +352,13 @@ pub(crate) mod tests {
assert!(masked_columns(&[], w, active, 15000.0).is_empty());
}
/// A maker note holding only `tag` as `ty` (3 = SHORT, 7 = UNDEFINED) with `data`, a plain IFD whose value
/// offset is relative to the TIFF header (patched by [`patch_note`] once the note's position is known).
fn note_with(tag: u16, ty: u16, data: &[u8]) -> Vec<u8> {
/// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain
/// IFD whose value offset is relative to the TIFF header (patched once the note's position is known).
fn note_with_color_data(ty: u16, data: &[u8]) -> Vec<u8> {
let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32;
let mut note = Vec::new();
note.extend_from_slice(&1u16.to_le_bytes());
note.extend_from_slice(&tag.to_le_bytes());
note.extend_from_slice(&COLOR_BALANCE.to_le_bytes());
note.extend_from_slice(&ty.to_le_bytes());
note.extend_from_slice(&count.to_le_bytes());
note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below
@@ -407,81 +367,15 @@ pub(crate) mod tests {
note
}
fn patch_note(bytes: &mut [u8]) {
let tiff = Tiff::parse(bytes).unwrap();
fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage {
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data)));
let tiff = Tiff::parse(&bytes).unwrap();
let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap();
let off = e.offset as usize;
bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes());
}
fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage {
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with(COLOR_BALANCE, ty, data)));
patch_note(&mut bytes);
crate::decode(&bytes).unwrap()
}
/// A maker note of `entries` (tag, TIFF type, value bytes), each value stored after the IFD, decoded; the value
/// offsets (relative to the TIFF header) are patched once the note's position is known.
fn decode_with_note(entries: &[(u16, u16, Vec<u8>)]) -> RawImage {
let ifd_len = 2 + 12 * entries.len() + 4;
let (mut note, mut data, mut at) = (Vec::new(), Vec::new(), Vec::new());
note.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, ty, value) in entries {
let count = if *ty == 3 { value.len() / 2 } else { value.len() } as u32;
note.extend_from_slice(&tag.to_le_bytes());
note.extend_from_slice(&ty.to_le_bytes());
note.extend_from_slice(&count.to_le_bytes());
at.push((note.len(), ifd_len + data.len()));
note.extend_from_slice(&0u32.to_le_bytes());
data.extend_from_slice(value);
}
note.extend_from_slice(&0u32.to_le_bytes());
note.extend_from_slice(&data);
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note));
let tiff = Tiff::parse(&bytes).unwrap();
let off = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize;
for (field, value) in at {
bytes[off + field..off + field + 4].copy_from_slice(&((off + value) as u32).to_le_bytes());
}
crate::decode(&bytes).unwrap()
}
/// Issue #1273: the PowerShot G9 has no `ColorData`; its preset levels are WBInfo (`0x0029`: BYTE, int32 values,
/// four per preset in G R B G order), and ShotInfo (`0x0004`) value 7 says which preset was used (15 = Custom 1,
/// row 66: 848 1747 1676 848 on the CC0 file IMG_1188.CR2).
#[test]
fn white_balance_from_wb_info_when_there_is_no_color_data() {
let shot_info = |wb: u16| {
let mut v = [0u16; 34];
v[7] = wb;
v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>()
};
let mut rows = vec![0i32; 512];
rows[2..6].copy_from_slice(&[850, 1805, 1684, 850]);
rows[66..70].copy_from_slice(&[848, 1747, 1676, 848]);
rows[74..78].copy_from_slice(&[820, 1901, 4000, 820]);
rows[18..22].copy_from_slice(&[850, 1805, 6000, 850]);
let wb_info: Vec<u8> = rows.iter().flat_map(|x| x.to_le_bytes()).collect();
let wb = |si: u16| decode_with_note(&[(0x0004, 3, shot_info(si)), (0x0029, 1, wb_info.clone())]).wb_multipliers;
assert_eq!(wb(15), Some([1747.0 / 848.0, 1.0, 1676.0 / 848.0]));
assert_eq!(wb(0), Some([1805.0 / 850.0, 1.0, 1684.0 / 850.0]));
// each preset its own row (Custom 2: row 74); an empty row (Daylight here), an implausible one (Cloudy:
// B 7.1) or a value without a documented row (14) gives none
assert_eq!(wb(16), Some([1901.0 / 820.0, 1.0, 4000.0 / 820.0]));
assert_eq!(wb(1), None);
assert_eq!(wb(2), None);
assert_eq!(wb(14), None);
// without ShotInfo, or with WBInfo too short for the row
assert_eq!(decode_with_note(&[(0x0029, 1, wb_info.clone())]).wb_multipliers, None);
assert_eq!(decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info[..4 * 68].to_vec())]).wb_multipliers, None);
// ColorData, when it gives levels, wins
let mut color_data = vec![0u8; 2 * 120];
color_data[0..2].copy_from_slice(&[0xfd, 0xff]);
color_data[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]);
let both = decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info.clone()), (COLOR_BALANCE, 7, color_data)]);
assert_eq!(both.wb_multipliers, Some([1988.0 / 745.0, 1.0, 1641.0 / 745.0]));
}
/// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71,
/// exactly as the same words stored as SHORT do.
#[test]
@@ -494,26 +388,4 @@ pub(crate) mod tests {
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}");
}
}
/// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's
/// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0.
#[test]
fn white_from_active_area_only() {
let (w, h, top, left) = (64, 40, 4, 8);
let img: Vec<u16> = (0..w * h)
.map(|i| match (i % w, i / w) {
(_, y) if y < top => 16383,
(x, _) if x < left => 1024,
_ if i % 7 == 0 => 13433,
_ => 1024 + ((i * 7919) % 11000) as u16,
})
.collect();
// SensorInfo: width, height, three unused words, then the inclusive left/top/right/bottom borders
let si: Vec<u8> = [w, h, 0, 0, 0, left, top, w - 1, h - 1].iter().flat_map(|&v| (v as u16).to_le_bytes()).collect();
let mut bytes = synthetic_cr2_of(&img, w, h, None, Some(note_with(SENSOR_INFO, 3, &si)));
patch_note(&mut bytes);
let r = crate::decode(&bytes).unwrap();
assert_eq!(r.active_area, Rect::new(left, top, w - left, h - top));
assert!((13300.0..=13433.0).contains(&r.white[0]), "{:?}", r.white);
}
}
+5 -12
View File
@@ -135,15 +135,13 @@ struct CameraLevels {
/// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not
/// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout.
/// A version without a white-level offset takes its white level from the data (see `docs/cr3.md`).
fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels {
let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() };
let offsets = match values.first().copied() {
Some(16..=19) => (71, 329, Some(797)),
Some(32 | 33) => (85, 343, Some(811)),
Some(34 | 48) => (105, 363, Some(641)),
Some(64 | 65) => (105, 383, Some(661)),
Some(66) => (105, 383, None),
Some(16..=19) => (71, 329, 797),
Some(32 | 33) => (85, 343, 811),
Some(34 | 48) => (105, 363, 641),
Some(64 | 65) => (105, 383, 661),
_ => return CameraLevels::default(),
};
let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32;
@@ -162,7 +160,7 @@ fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels {
});
let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5));
let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0);
let white = offsets.2.and_then(|at| values.get(at)).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full);
let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full);
CameraLevels { wb, black, white }
}
@@ -528,11 +526,6 @@ mod tests {
assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0]));
assert_eq!(levels.white, Some(15000.0));
}
// A version without a white-level offset leaves the white level to the data.
let levels = camera_levels(Some(&maker(66, 105, 383, 661)), 14);
assert_eq!(levels.wb, Some([2.0, 1.0, 1.5]));
assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0]));
assert_eq!(levels.white, None);
assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none());
assert!(camera_levels(None, 14).black.is_none());
}
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more