mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:51:07 +00:00
Merge remote-tracking branch 'origin/fix/colour-fit-lens-geometry' into feat/nikon-z6ii-camera-profile
# Conflicts: # crates/engine/src/camera_preview.rs # crates/engine/src/tests_xmp.rs # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
@@ -125,11 +125,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
|
||||
@@ -133,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
|
||||
Generated
+13
@@ -3081,6 +3081,7 @@ dependencies = [
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -3144,6 +3145,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
@@ -3240,6 +3242,7 @@ dependencies = [
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3255,6 +3258,7 @@ dependencies = [
|
||||
name = "lightcraft-faces"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
@@ -3305,6 +3309,7 @@ dependencies = [
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
@@ -3419,12 +3424,14 @@ dependencies = [
|
||||
name = "lightcraft-raw"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
@@ -3455,6 +3462,7 @@ dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3474,6 +3482,10 @@ dependencies = [
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.8.0"
|
||||
@@ -3504,6 +3516,7 @@ dependencies = [
|
||||
"lightcraft-preview",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@ lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-geocode = { path = "crates/geocode" }
|
||||
lightcraft-slideshow = { path = "crates/slideshow" }
|
||||
lightcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,7 +54,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
crc32fast = "1"
|
||||
siphasher = "1"
|
||||
libc = "0.2" # dev-only: process CPU time in benches
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
|
||||
|
||||
# formats
|
||||
quick-xml = "0.38"
|
||||
|
||||
@@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
|
||||
This is a contact-sheet workflow,
|
||||
not a printer-driver or custom picture-package layout editor.
|
||||
- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image,
|
||||
Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells,
|
||||
stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper
|
||||
sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi,
|
||||
and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command
|
||||
(`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`,
|
||||
`print.template.save`…), so the CLI and MCP drive it too:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
|
||||
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
|
||||
```
|
||||
|
||||
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
@@ -395,6 +409,13 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG
|
||||
its own profile settings can override inherited variables; adjust the packaging recipe locally or
|
||||
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
|
||||
|
||||
Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when
|
||||
those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip
|
||||
with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU
|
||||
adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and
|
||||
`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the
|
||||
GPU tests on every backend that has a hardware adapter. Nothing is uploaded.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
|
||||
+10
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **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
|
||||
@@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---:|---|
|
||||
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (weighted by use) | **~77%** | estimated |
|
||||
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
|
||||
@@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 82% of 1,446 Adobe models decode, 36% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 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) |
|
||||
@@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Area | Ready | Breadth | Remaining |
|
||||
|---|---:|---:|---:|
|
||||
| Import and library | 80% | 90% | 25–45 h |
|
||||
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
|
||||
| RAW decoding and camera coverage | 77% | 82% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
|
||||
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
|
||||
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
|
||||
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
|
||||
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
|
||||
photographers on a verified body; headline numbers unchanged.
|
||||
@@ -180,6 +183,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
|
||||
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
|
||||
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
|
||||
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
|
||||
@@ -188,6 +192,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
|
||||
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); 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 |
|
||||
|
||||
@@ -828,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
// as the desktop app opens it: an empty library on the grid, where Import is offered
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
||||
@@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_commands_chain_into_a_picture_package_pdf() {
|
||||
let input = tmp("print-input.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("print.pdf");
|
||||
let (ok, lines, err) = run_cli(
|
||||
&[
|
||||
"--import",
|
||||
input.to_str().unwrap(),
|
||||
"print.set",
|
||||
r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#,
|
||||
r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#,
|
||||
"job={\"ppi\":72}",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.layout.autoLayout",
|
||||
"print.exportPdf",
|
||||
&format!("path={}", out.display()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
let export = lines.last().unwrap();
|
||||
assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1)));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in");
|
||||
// a bad setting fails the run and writes nothing new
|
||||
let before = std::fs::read(&out).unwrap();
|
||||
let (ok, _, _) =
|
||||
run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None);
|
||||
assert!(!ok);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), before);
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
|
||||
@@ -48,3 +48,7 @@ muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
objc2-app-kit = { workspace = true }
|
||||
objc2-foundation = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -978,6 +978,11 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// an empty library opens where Import is offered (a grid, with the sidebar); before
|
||||
// the snapshot below, so this alone isn't written to the settings file
|
||||
if problem.is_none() && files.is_empty() {
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
}
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
|
||||
@@ -278,11 +278,11 @@ mod tests {
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU device");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
|
||||
@@ -30,6 +30,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
|
||||
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)]
|
||||
cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)]
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library
|
||||
//! don't scan a list each time: which stack a photo is in, whether a regular album holds it.
|
||||
//!
|
||||
//! They are never saved (the on-disk format is unchanged). Each is built on first use from what
|
||||
//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts
|
||||
//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to
|
||||
//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`,
|
||||
//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the
|
||||
//! rollback of a failed batch included, as those are ops too.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::{AlbumId, Catalog, PhotoId, StackId};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Indexes {
|
||||
/// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks
|
||||
/// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts.
|
||||
stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>,
|
||||
/// Album → its photos (regular albums; a smart album's list is empty).
|
||||
albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>,
|
||||
}
|
||||
|
||||
/// A copy builds its own when asked (copies are made to be saved, not queried).
|
||||
impl Clone for Indexes {
|
||||
fn clone(&self) -> Self {
|
||||
Indexes::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was
|
||||
/// built.
|
||||
impl PartialEq for Indexes {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Indexes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len));
|
||||
f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indexes {
|
||||
/// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone).
|
||||
pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) {
|
||||
let Some(Some(m)) = self.stacks.get_mut() else { return };
|
||||
for p in old {
|
||||
if m.get(p) == Some(&id) {
|
||||
m.remove(p);
|
||||
}
|
||||
}
|
||||
for p in new {
|
||||
m.insert(*p, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Album `id` holds `photos` now (`None`: it is gone).
|
||||
pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) {
|
||||
let Some(m) = self.albums.get_mut() else { return };
|
||||
match photos {
|
||||
Some(photos) => {
|
||||
m.insert(id, photos.iter().copied().collect());
|
||||
}
|
||||
None => {
|
||||
m.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The stack photo `photo` is in, by the index (built on first use).
|
||||
pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> {
|
||||
let m = self.index.stacks.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for s in self.stacks.values() {
|
||||
for p in &s.photos {
|
||||
if m.insert(*p, s.id).is_some_and(|other| other != s.id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
});
|
||||
match m {
|
||||
Some(m) => m.get(&photo).copied(),
|
||||
// in id order, the first stack listing the photo has it
|
||||
None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether album `album`'s own list holds `photo`, by the index (built on first use).
|
||||
pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool {
|
||||
let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect());
|
||||
m.get(&album).is_some_and(|s| s.contains(&photo))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
mod index;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -289,6 +290,9 @@ pub struct Catalog {
|
||||
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
|
||||
#[serde(skip)]
|
||||
changes: Changes,
|
||||
/// Lookups derived from the above (never saved), see [`index`].
|
||||
#[serde(skip)]
|
||||
index: index::Indexes,
|
||||
}
|
||||
|
||||
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
|
||||
@@ -435,13 +439,18 @@ impl Catalog {
|
||||
/// no smart album).
|
||||
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
|
||||
match self.albums.get(&id) {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
|
||||
Some(a) => a.photos.contains(&p.id),
|
||||
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
|
||||
Some(a) => self.album_lists(a.id, p.id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
|
||||
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
|
||||
}
|
||||
|
||||
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
|
||||
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
|
||||
/// search, they don't repeat it.
|
||||
@@ -489,7 +498,10 @@ impl Catalog {
|
||||
/// The photos of an album: the stored list, or a smart album's current matches (id order).
|
||||
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
|
||||
Some(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
|
||||
}
|
||||
Some(a) => a.photos.clone(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
@@ -499,7 +511,10 @@ impl Catalog {
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
|
||||
}
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -750,6 +765,7 @@ impl Catalog {
|
||||
}
|
||||
let id = album.id;
|
||||
self.next_album = self.next_album.max(id.0.saturating_add(1));
|
||||
self.index.album_changed(id, Some(&album.photos));
|
||||
self.albums.insert(id, album);
|
||||
Op::RemoveAlbum { id }
|
||||
}
|
||||
@@ -758,6 +774,7 @@ impl Catalog {
|
||||
return Err(CatalogError::Invalid("folder is not empty".into()));
|
||||
}
|
||||
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
self.index.album_changed(id, None);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
Op::RenameAlbum { id, name } => {
|
||||
@@ -797,13 +814,16 @@ impl Catalog {
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
// the field, not `album_mut`: the index is updated while the album is borrowed
|
||||
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
|
||||
return Err(CatalogError::Invalid(
|
||||
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
|
||||
));
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
|
||||
let old = std::mem::replace(&mut a.photos, photos);
|
||||
self.index.album_changed(id, Some(&a.photos));
|
||||
Op::SetAlbumPhotos { id, photos: old }
|
||||
}
|
||||
Op::SetAlbumCover { id, cover } => {
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -824,11 +844,13 @@ impl Catalog {
|
||||
self.validate_stack(stack.id, &stack.photos)?;
|
||||
let id = stack.id;
|
||||
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
|
||||
self.index.stack_changed(id, &[], &stack.photos);
|
||||
self.stacks.insert(id, stack);
|
||||
Op::RemoveStack { id }
|
||||
}
|
||||
Op::RemoveStack { id } => {
|
||||
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
self.index.stack_changed(id, &s.photos, &[]);
|
||||
Op::AddStack { stack: s }
|
||||
}
|
||||
Op::SetStack { id, photos, collapsed } => {
|
||||
@@ -837,7 +859,9 @@ impl Catalog {
|
||||
}
|
||||
self.validate_stack(id, &photos)?;
|
||||
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
|
||||
let old_photos = std::mem::replace(&mut s.photos, photos);
|
||||
self.index.stack_changed(id, &old_photos, &s.photos);
|
||||
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
@@ -1075,6 +1099,10 @@ impl Catalog {
|
||||
self.next_album = self.next_album.max(album);
|
||||
self.next_stack = self.next_stack.max(stack);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
// stacks and albums changed outside `apply`: the lookups are built again when asked
|
||||
self.index = index::Indexes::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -1124,6 +1152,8 @@ mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_index;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
@@ -1132,4 +1162,6 @@ mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_repair;
|
||||
#[cfg(test)]
|
||||
mod tests_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
|
||||
+34
-16
@@ -135,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
/// What [`Filter::prepare`] works out once for a pass over many photos.
|
||||
pub(crate) struct Prepared {
|
||||
root: Option<String>,
|
||||
only: Option<std::collections::HashSet<PhotoId>>,
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -257,7 +263,18 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
self.matches_in(p, cat, &self.prepare_one())
|
||||
}
|
||||
|
||||
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
|
||||
pub(crate) fn prepare_one(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: None }
|
||||
}
|
||||
|
||||
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
|
||||
/// once per photo: the library folder's identity and the `only` ids as a set.
|
||||
pub(crate) fn prepare(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
@@ -267,9 +284,9 @@ impl Filter {
|
||||
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
|
||||
}
|
||||
|
||||
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
|
||||
/// over a big library does that once, not once per photo.
|
||||
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
|
||||
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
|
||||
/// big library does that once, not once per photo.
|
||||
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -308,7 +325,7 @@ impl Filter {
|
||||
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
|
||||
return false;
|
||||
}
|
||||
if !self.only.is_empty() && !self.only.contains(&p.id) {
|
||||
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
@@ -348,7 +365,7 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
if let Some(root) = prepared.root.as_deref()
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
@@ -398,12 +415,13 @@ impl Catalog {
|
||||
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
|
||||
/// the album, the folder; not deleted).
|
||||
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
|
||||
let root = filter.library_root();
|
||||
let within_root = within.library_root();
|
||||
let prepared = filter.prepare();
|
||||
// `within` only decides for the few kept photos: worked out per photo, not hashed
|
||||
let within_prepared = within.prepare_one();
|
||||
let mut v: Vec<&Photo> = self
|
||||
.photos()
|
||||
.map(|p| p.as_ref())
|
||||
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
|
||||
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
|
||||
.collect();
|
||||
v.sort_by(|a, b| sort.order(a, b));
|
||||
v.into_iter().map(|p| p.id).collect()
|
||||
@@ -417,11 +435,11 @@ impl Catalog {
|
||||
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
|
||||
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
|
||||
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
|
||||
let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root));
|
||||
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
|
||||
for id in touched {
|
||||
let Some(p) = self.photo(*id) else { continue };
|
||||
let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref()));
|
||||
if filter.matches_in(p, self, root.as_deref()) || kept {
|
||||
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
|
||||
if filter.matches_in(p, self, &prepared) || kept {
|
||||
// (the list is in order without the touched photos, and stays so as each is put back)
|
||||
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
|
||||
ids.insert(at, *id);
|
||||
@@ -432,8 +450,8 @@ impl Catalog {
|
||||
|
||||
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
|
||||
pub fn count(&self, filter: &Filter) -> usize {
|
||||
let root = filter.library_root();
|
||||
self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count()
|
||||
let prepared = filter.prepare();
|
||||
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
|
||||
}
|
||||
|
||||
/// Year → month → day counts for the "By Date" section (newest first).
|
||||
@@ -489,8 +507,8 @@ impl Catalog {
|
||||
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
|
||||
let filter = Filter { person: None, ..filter.clone() };
|
||||
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
|
||||
let root = filter.library_root();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
|
||||
let prepared = filter.prepare();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
|
||||
@@ -598,6 +598,35 @@ pub fn now() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flag as rules name it (its variant, lower case), without building a string per photo.
|
||||
fn flag_name(f: crate::Flag) -> &'static str {
|
||||
match f {
|
||||
crate::Flag::None => "none",
|
||||
crate::Flag::Pick => "pick",
|
||||
crate::Flag::Reject => "reject",
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour label's colour as rules name it (lower case).
|
||||
fn label_colour(l: crate::ColorLabel) -> &'static str {
|
||||
match l {
|
||||
crate::ColorLabel::Red => "red",
|
||||
crate::ColorLabel::Yellow => "yellow",
|
||||
crate::ColorLabel::Green => "green",
|
||||
crate::ColorLabel::Blue => "blue",
|
||||
crate::ColorLabel::Purple => "purple",
|
||||
}
|
||||
}
|
||||
|
||||
/// A media kind as rules name it (lower case).
|
||||
fn kind_name(k: crate::MediaKind) -> &'static str {
|
||||
match k {
|
||||
crate::MediaKind::Image => "image",
|
||||
crate::MediaKind::Raw => "raw",
|
||||
crate::MediaKind::Video => "video",
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
@@ -778,16 +807,16 @@ impl Rule {
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
let f = flag_name(p.flag);
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
let l = p.label.map_or("none", label_colour);
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
|
||||
(l == want || named) == (op == "is")
|
||||
}
|
||||
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
|
||||
"kind" => (kind_name(p.kind) == want) == (op == "is"),
|
||||
"edited" => yes(p.is_edited()),
|
||||
"hasGps" => yes(m.gps.is_some()),
|
||||
"virtualCopy" => yes(p.copy_of.is_some()),
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Catalog {
|
||||
}
|
||||
/// The stack a photo belongs to.
|
||||
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
|
||||
self.stacks.values().find(|s| s.photos.contains(&photo))
|
||||
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
|
||||
}
|
||||
/// Photo → (stack, position in the stack), for every stacked photo.
|
||||
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
|
||||
@@ -41,7 +41,7 @@ impl Catalog {
|
||||
return Err(CatalogError::NoPhoto(*p));
|
||||
}
|
||||
}
|
||||
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
|
||||
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
|
||||
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op,
|
||||
//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same
|
||||
//! results as before.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
const PHOTOS: u64 = 8;
|
||||
|
||||
fn library() -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for i in 0..PHOTOS {
|
||||
let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// What the indexes must say, worked out by scanning (as the catalog did before it had them).
|
||||
fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// What the indexes say.
|
||||
fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail).
|
||||
fn ids(bits: u16) -> Vec<PhotoId> {
|
||||
(0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect()
|
||||
}
|
||||
|
||||
fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op {
|
||||
match kind % 8 {
|
||||
0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } },
|
||||
1 => Op::RemoveStack { id: StackId(n % 4 + 1) },
|
||||
2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed },
|
||||
3 => {
|
||||
let mut a = Album::new(AlbumId(n % 4 + 1), "A");
|
||||
a.photos = ids(bits);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) },
|
||||
5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
// a batch whose last op fails half the time: what it did is rolled back
|
||||
6 => Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed },
|
||||
Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } },
|
||||
],
|
||||
},
|
||||
// as the engine deletes a photo: out of its albums and stacks too, in one batch
|
||||
_ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
#[test]
|
||||
fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) {
|
||||
let mut c = library();
|
||||
let start = c.to_snapshot();
|
||||
// built before the ops, so every op has to keep them up to date
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
let mut log = String::new();
|
||||
let mut inverses = Vec::new();
|
||||
for (kind, n, bits, collapsed) in steps {
|
||||
let op = op(&c, kind, n, bits, collapsed);
|
||||
if let Ok(inv) = c.apply(op.clone()) {
|
||||
log.push_str(&Catalog::op_to_log_line(&op));
|
||||
inverses.push(inv);
|
||||
}
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
// a snapshot, a replayed journal and a clone build their own
|
||||
let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
||||
prop_assert_eq!(indexed(&snap), scanned(&c));
|
||||
let mut replayed = Catalog::from_snapshot(&start).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&replayed));
|
||||
replayed.replay(&log).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&c));
|
||||
prop_assert_eq!(indexed(&c.clone()), scanned(&c));
|
||||
prop_assert!(c.clone() == c, "the indexes don't take part in equality");
|
||||
// undone step by step
|
||||
for inv in inverses.into_iter().rev() {
|
||||
c.apply(inv).unwrap();
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_another_stack_is_refused_by_the_index() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err();
|
||||
assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
// its own stack may keep it
|
||||
c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap();
|
||||
assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1)));
|
||||
}
|
||||
|
||||
/// The sort keys are worked out once per photo now; the order is the one the comparisons gave.
|
||||
#[test]
|
||||
fn sorted_queries_keep_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"];
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3));
|
||||
p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4));
|
||||
p.rating = (i % 3) as u8;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let all = Filter::default();
|
||||
for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random]
|
||||
{
|
||||
for ascending in [true, false] {
|
||||
let sort = Sort { key, ascending, seed: 7, ..Default::default() };
|
||||
// the comparison the query sorts by
|
||||
let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect();
|
||||
want.sort_by(|a, b| sort.order(a, b));
|
||||
let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect();
|
||||
assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}");
|
||||
}
|
||||
}
|
||||
// `only` as a set gives the photos it names
|
||||
let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() };
|
||||
assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]);
|
||||
}
|
||||
|
||||
/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave).
|
||||
#[test]
|
||||
fn rule_names_are_the_variants_in_lower_case() {
|
||||
let mut c = Catalog::new();
|
||||
let mut n = 0;
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
for label in
|
||||
[None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)]
|
||||
{
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
n += 1;
|
||||
let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
(p.flag, p.label, p.kind) = (flag, label, kind);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap();
|
||||
let count = |field: &str, value: String| {
|
||||
let rules: RuleSet =
|
||||
serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap();
|
||||
c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len()
|
||||
};
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}");
|
||||
}
|
||||
assert_eq!(count("flag", "picked".into()), 18);
|
||||
for label in ColorLabel::ALL {
|
||||
assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}");
|
||||
}
|
||||
assert_eq!(count("label", "sky".into()), 9, "a custom name");
|
||||
assert_eq!(count("label", "none".into()), 9);
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo
|
||||
/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before
|
||||
/// and after either stack goes.
|
||||
#[test]
|
||||
fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap();
|
||||
v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]);
|
||||
let mut c = Catalog::from_snapshot(&v.to_string()).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1)));
|
||||
// the second stack can't be changed to keep it, as before
|
||||
assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err());
|
||||
c.apply(Op::RemoveStack { id: StackId(1) }).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! What library queries cost at 100k photos (ignored:
|
||||
//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags.
|
||||
fn library(n: u64) -> (Catalog, AlbumId) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ops: Vec<Op> = (0..n)
|
||||
.map(|i| {
|
||||
let mut p = Photo::new(
|
||||
PhotoId(i + 1),
|
||||
Source::Demo { scene: (i % 20) as u32 },
|
||||
&format!("IMG_{:06}.jpg", mix64(i) % 1_000_000),
|
||||
"JPEG",
|
||||
6000,
|
||||
4000,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
|
||||
p.rating = (i % 6) as u8;
|
||||
p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None };
|
||||
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
|
||||
Op::AddPhoto { photo: Box::new(p) }
|
||||
})
|
||||
.collect();
|
||||
ops.extend(
|
||||
(0..n / 10)
|
||||
.map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }),
|
||||
);
|
||||
let album = AlbumId(1);
|
||||
let mut a = Album::new(album, "Big");
|
||||
a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect();
|
||||
ops.push(Op::AddAlbum { album: a });
|
||||
let t = Instant::now();
|
||||
c.apply(Op::Batch { ops }).unwrap();
|
||||
eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3);
|
||||
(c, album)
|
||||
}
|
||||
|
||||
fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId {
|
||||
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
|
||||
a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() }));
|
||||
c.apply(Op::AddAlbum { album: a }).unwrap();
|
||||
AlbumId(id)
|
||||
}
|
||||
|
||||
/// Best of five runs (the machine may be busy with other work).
|
||||
fn time<T>(what: &str, f: impl Fn() -> T) -> T {
|
||||
let mut best = f64::MAX;
|
||||
let mut r = None;
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
r = Some(f());
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
eprintln!("{what}: {best:.1} ms");
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scale_100k() {
|
||||
let (mut c, album) = library(100_000);
|
||||
let all = Filter::default();
|
||||
time("query, capture date", || c.query(&all, &Sort::default()));
|
||||
time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() }));
|
||||
time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default()));
|
||||
time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default()));
|
||||
let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect();
|
||||
time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default()));
|
||||
let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]}));
|
||||
let flagged = smart(
|
||||
&mut c,
|
||||
11,
|
||||
json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}),
|
||||
);
|
||||
let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]}));
|
||||
let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]}));
|
||||
let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]}));
|
||||
for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] {
|
||||
let n = time(&format!("smart album count, {what}"), || c.album_count(a));
|
||||
eprintln!(" = {n}");
|
||||
}
|
||||
time("albums_of", || c.albums_of(PhotoId(6)));
|
||||
time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default())));
|
||||
time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count());
|
||||
}
|
||||
@@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -32,6 +32,7 @@ mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod print;
|
||||
pub mod proof;
|
||||
mod psd;
|
||||
mod sniff;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the
|
||||
//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space
|
||||
//! of the chosen printer / paper profile.
|
||||
//!
|
||||
//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the
|
||||
//! page to the device's own values. When the printer manages colour instead, the page is sent in a
|
||||
//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it.
|
||||
//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]).
|
||||
//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is
|
||||
//! measured by a round trip of black through the profile, and the page's linear values are scaled
|
||||
//! so its black lands there instead of every shadow below it clipping to the same ink. Linear
|
||||
//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping
|
||||
//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black
|
||||
//! mapping and are left alone.
|
||||
//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or
|
||||
//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown
|
||||
//! on screen (the print is what it corrects).
|
||||
//!
|
||||
//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table
|
||||
//! indexed by the 16-bit sample; the conversion itself is moxcms's.
|
||||
//!
|
||||
//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point
|
||||
//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions};
|
||||
|
||||
use crate::display::MAX_PROFILE_BYTES;
|
||||
use crate::proof::ProofIntent;
|
||||
use crate::space::{NamedSpace, Trc};
|
||||
|
||||
/// Where a print's colours go.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PrintTarget {
|
||||
/// The printer manages colour: the page is sent in this RGB space, with its profile embedded.
|
||||
Managed(NamedSpace),
|
||||
/// A printer / paper ICC profile: the page is converted to the device's values.
|
||||
Profile(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
/// How the page is rendered into the target.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PrintColor {
|
||||
pub intent: ProofIntent,
|
||||
/// Map the page's black to the device's darkest (relative colorimetric only).
|
||||
pub black_point_compensation: bool,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub brightness: f32,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub contrast: f32,
|
||||
}
|
||||
|
||||
impl Default for PrintColor {
|
||||
fn default() -> Self {
|
||||
PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size.
|
||||
const BATCH: usize = 1 << 16;
|
||||
|
||||
/// The darkest black point compensation will lift the page's black to. A profile whose round trip
|
||||
/// says more is broken or not a print profile; leave the page alone rather than wash it out.
|
||||
const MAX_BLACK: f32 = 0.5;
|
||||
|
||||
type Executor = Arc<dyn TransformExecutor<f32> + Send + Sync>;
|
||||
|
||||
/// A page conversion, built once per print job and applied to every page.
|
||||
pub struct PrintTransform {
|
||||
forward: Executor,
|
||||
layout: Layout,
|
||||
channels: usize,
|
||||
icc: Arc<Vec<u8>>,
|
||||
/// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation.
|
||||
pre: Vec<f32>,
|
||||
black: f32,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PrintTransform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PrintTransform")
|
||||
.field("channels", &self.channels)
|
||||
.field("black", &self.black)
|
||||
.field("description", &self.description)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
fn rendering_intent(intent: ProofIntent) -> RenderingIntent {
|
||||
match intent {
|
||||
ProofIntent::Perceptual => RenderingIntent::Perceptual,
|
||||
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
|
||||
}
|
||||
}
|
||||
|
||||
/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0).
|
||||
fn amount(v: f32) -> f32 {
|
||||
if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a
|
||||
/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black,
|
||||
/// mid grey and white where they are and stays monotonic for |k| < 1.
|
||||
pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 {
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (b, c) = (amount(brightness), amount(contrast));
|
||||
let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e };
|
||||
let k = 0.6 * c;
|
||||
let tau = std::f32::consts::TAU;
|
||||
(e - k * (tau * e).sin() / tau).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
impl PrintTransform {
|
||||
/// Build the conversion for a target. Errors say what's wrong with the profile; nothing here
|
||||
/// panics on a hostile one.
|
||||
pub fn new(target: &PrintTarget, color: &PrintColor) -> Result<PrintTransform, String> {
|
||||
let icc = match target {
|
||||
PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)),
|
||||
PrintTarget::Profile(bytes) => Arc::clone(bytes),
|
||||
};
|
||||
if icc.len() > MAX_PROFILE_BYTES {
|
||||
return Err("the profile is too large".into());
|
||||
}
|
||||
let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?;
|
||||
let (layout, channels) = match dev.color_space {
|
||||
DataColorSpace::Rgb => (Layout::Rgb, 3),
|
||||
DataColorSpace::Cmyk => (Layout::Rgba, 4),
|
||||
DataColorSpace::Gray => (Layout::Gray, 1),
|
||||
_ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()),
|
||||
};
|
||||
let lin = crate::icc::linear_rec2020_profile();
|
||||
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
|
||||
let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok())
|
||||
.ok_or("can't convert to this profile")?;
|
||||
|
||||
let black = if color.black_point_compensation && color.intent == ProofIntent::Relative {
|
||||
let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok())
|
||||
.ok_or("can't convert from this profile")?;
|
||||
device_black(&forward, &back, channels)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let decode = Trc::Rec709.lut16();
|
||||
let (b, c) = (color.brightness, color.contrast);
|
||||
let adjust = amount(b) != 0.0 || amount(c) != 0.0;
|
||||
let pre = decode
|
||||
.iter()
|
||||
.map(|&lin| {
|
||||
let lin = if adjust {
|
||||
let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0));
|
||||
lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c))
|
||||
} else {
|
||||
lin.clamp(0.0, 1.0)
|
||||
};
|
||||
lin * (1.0 - black) + black
|
||||
})
|
||||
.collect();
|
||||
let description = crate::icc::parse(&icc).and_then(|i| i.description);
|
||||
Ok(PrintTransform { forward, layout, channels, icc, pre, black, description })
|
||||
}
|
||||
|
||||
/// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey).
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// The profile the converted samples are in: embed it with them.
|
||||
pub fn icc(&self) -> &Arc<Vec<u8>> {
|
||||
&self.icc
|
||||
}
|
||||
|
||||
/// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise,
|
||||
/// so writers need to know).
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
self.layout == Layout::Rgba
|
||||
}
|
||||
|
||||
/// The linear black black point compensation lifts the page's black to (0 when off).
|
||||
pub fn black(&self) -> f32 {
|
||||
self.black
|
||||
}
|
||||
|
||||
/// The profile's description, for the job's settings and error messages.
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
/// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result
|
||||
/// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel).
|
||||
pub fn apply(&self, rgb: &[u16]) -> Result<Vec<u16>, String> {
|
||||
if !rgb.len().is_multiple_of(3) {
|
||||
return Err("the page isn't whole RGB pixels".into());
|
||||
}
|
||||
let pixels = rgb.len() / 3;
|
||||
let ch = self.channels;
|
||||
let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?;
|
||||
let mut out = vec![0u16; out_len];
|
||||
let run = |src: &[u16], dst: &mut [u16]| -> Option<()> {
|
||||
let lin: Vec<f32> = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect();
|
||||
let mut dev = vec![0f32; dst.len()];
|
||||
self.forward.transform(&lin, &mut dev).ok()?;
|
||||
for (d, v) in dst.iter_mut().zip(dev) {
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
*d = (v * 65535.0 + 0.5) as u16;
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
let src = rgb.chunks(BATCH * 3);
|
||||
let dst = out.chunks_mut(BATCH * ch);
|
||||
#[cfg(feature = "parallel")]
|
||||
let ok = {
|
||||
use rayon::prelude::*;
|
||||
let pairs: Vec<_> = src.zip(dst).collect();
|
||||
pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some())
|
||||
};
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some());
|
||||
if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative
|
||||
/// colorimetric both ways (for a device that reaches true black, 0).
|
||||
fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 {
|
||||
let mut device = vec![0f32; channels];
|
||||
let mut lin = [0f32; 3];
|
||||
let ok = guarded(|| {
|
||||
forward.transform(&[0.0; 3], &mut device).ok()?;
|
||||
back.transform(&device, &mut lin).ok()
|
||||
});
|
||||
if ok.is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
// BT.2020 luminance weights
|
||||
let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2];
|
||||
if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use moxcms::ToneReprCurve;
|
||||
|
||||
/// A page of one 16-bit pixel per linear grey level.
|
||||
fn greys(levels: &[f32]) -> Vec<u16> {
|
||||
levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect()
|
||||
}
|
||||
|
||||
fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec<u16> {
|
||||
PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap()
|
||||
}
|
||||
|
||||
/// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey.
|
||||
fn raised_black_profile() -> Arc<Vec<u8>> {
|
||||
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap();
|
||||
let (g, e) = (2.2f32, 0.03f32);
|
||||
let a = (1.0 - e).powf(1.0 / g);
|
||||
// ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below
|
||||
let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]);
|
||||
p.red_trc = Some(trc.clone());
|
||||
p.green_trc = Some(trc.clone());
|
||||
p.blue_trc = Some(trc);
|
||||
Arc::new(p.encode().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_srgb_keeps_black_grey_and_white() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(t.channels(), 3);
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb));
|
||||
let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap();
|
||||
let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32;
|
||||
assert!(out[..3].iter().all(|&v| v < 64), "{out:?}");
|
||||
assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}");
|
||||
assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_target_embeds_its_profile() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb));
|
||||
// a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped
|
||||
let px = [0u16, 65535, 0];
|
||||
let out = t.apply(&px).unwrap();
|
||||
assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_lifts_the_mid_tones_and_holds_the_ends() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.0, 0.18, 1.0];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels);
|
||||
let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels);
|
||||
assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}");
|
||||
for v in [&plain, &bright, &dark] {
|
||||
assert!(v[0] < 64 && v[6] > 65400, "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.03, 0.6];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels);
|
||||
assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}");
|
||||
for c in [-100.0, 100.0] {
|
||||
for b in [-100.0, 100.0] {
|
||||
let ramp: Vec<f32> = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect();
|
||||
assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}");
|
||||
assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0];
|
||||
let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() };
|
||||
let clipped = convert(&target, relative, &shadows);
|
||||
let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows);
|
||||
let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::<Vec<_>>();
|
||||
let (clipped, kept) = (reds(&clipped), reds(&kept));
|
||||
// everything below the ink's black prints as (within rounding) the same device value without
|
||||
// compensation …
|
||||
assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}");
|
||||
// … and as distinct, rising values with it; white still prints as white
|
||||
assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}");
|
||||
assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}");
|
||||
let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap();
|
||||
assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_is_relative_colorimetric_only() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let levels = [0.0, 0.01, 0.2];
|
||||
let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels);
|
||||
let without =
|
||||
convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels);
|
||||
assert_eq!(with, without);
|
||||
let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap();
|
||||
assert_eq!(t.black(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let color = PrintColor::default();
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err());
|
||||
let good = crate::icc::write_named(NamedSpace::DisplayP3);
|
||||
for i in (0..good.len()).step_by(7) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) {
|
||||
let _ = t.apply(&greys(&[0.0, 0.5, 1.0]));
|
||||
}
|
||||
}
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color })
|
||||
.unwrap();
|
||||
assert!(t.apply(&[1, 2]).is_err(), "half a pixel");
|
||||
assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18]));
|
||||
assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
fn corpus_images_decode() {
|
||||
let dir = corpus_root().join("images");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let (mut ok, mut unsupported) = (0, 0);
|
||||
@@ -57,7 +57,7 @@ fn corpus_images_decode() {
|
||||
fn corpus_pngsuite() {
|
||||
let dir = corpus_root().join("pngsuite");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let mut n = 0;
|
||||
@@ -83,7 +83,7 @@ fn corpus_pngsuite() {
|
||||
fn corpus_raw_is_routed() {
|
||||
let dir = corpus_root().join("raw");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
for p in files(&dir) {
|
||||
@@ -103,7 +103,7 @@ fn system_icc_profiles() {
|
||||
use lightcraft_codecs::icc::{IccColorModel, IccKind, parse};
|
||||
let dir = Path::new("/System/Library/ColorSync/Profiles");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: no system ICC profiles");
|
||||
lightcraft_testkit::skip!(System, "no system ICC profiles");
|
||||
return;
|
||||
}
|
||||
let expect = [
|
||||
|
||||
@@ -52,6 +52,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
|
||||
@@ -78,10 +78,41 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
|
||||
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
|
||||
/// as-shot white, the camera transform at it and the fitted look.
|
||||
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
|
||||
/// `warp` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
|
||||
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
|
||||
pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens))
|
||||
pub(crate) fn starting_colour(
|
||||
raw: &mut RawImage,
|
||||
bytes: &[u8],
|
||||
lens: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
warp: Option<&LensGeometry>,
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, warp), |raw, t| monochrome_look(raw, bytes, t, warp));
|
||||
if look.is_none() && dng_tone_eligible(raw, &t) {
|
||||
let look = fit_dng_tone(raw, bytes, &t, lens);
|
||||
return (xy, t, look);
|
||||
}
|
||||
(xy, t, look)
|
||||
}
|
||||
|
||||
/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range).
|
||||
fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool {
|
||||
curve.is_none_or(|c| {
|
||||
(0..=32).all(|i| {
|
||||
let x = i as f32 / 32.0;
|
||||
(c.eval(x) - x).abs() < 0.005
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
|
||||
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
|
||||
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
|
||||
let p = &raw.color.profile;
|
||||
raw.format == RawFormat::Dng
|
||||
&& !t.matrix_is_fallback
|
||||
&& identity_tone_curve(p.tone_curve.as_ref())
|
||||
&& p.look_table.is_none()
|
||||
&& p.gain_table_map.is_none()
|
||||
}
|
||||
|
||||
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
|
||||
@@ -130,7 +161,7 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
|
||||
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling, and Export as DNG
|
||||
// takes the lens corrections off first)
|
||||
let (_, t, look) = starting_colour(raw, bytes, None);
|
||||
let (_, t, look) = starting_colour(raw, bytes, None, None);
|
||||
dng_colour(&raw.color, &white, &t, look.as_ref())
|
||||
}
|
||||
|
||||
@@ -232,7 +263,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return (None, false);
|
||||
}
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, lens) else { return (None, false) };
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) };
|
||||
let black_and_white = monochrome(&reference);
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
@@ -389,6 +420,159 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
|
||||
/// 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;
|
||||
|
||||
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
|
||||
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
|
||||
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
|
||||
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
|
||||
/// the plain render ([`clips_more`]).
|
||||
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
|
||||
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
|
||||
let refuse = |why: &str| {
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!("DNGTONE refused {why}");
|
||||
}
|
||||
None
|
||||
};
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
|
||||
// As the loader renders it: the profile's tables around the baseline exposure.
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain));
|
||||
}
|
||||
correct_vignetting(&mut sensor, lens, raw.orientation);
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) {
|
||||
return refuse("preview");
|
||||
}
|
||||
if !structure_agrees(&sensor, &reference) {
|
||||
return refuse("structure");
|
||||
}
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let look = TONE_FITS.iter().find_map(|&tone| {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
|
||||
});
|
||||
let Some(look) = look else { return refuse("fit") };
|
||||
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
|
||||
Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0),
|
||||
None => ToneMap::new(0.0, 0.0, 0.0),
|
||||
};
|
||||
let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
if clips_more(&sensor, &reference, &fitted, &plain) {
|
||||
return refuse("clip");
|
||||
}
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!("DNGTONE accepted");
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain
|
||||
/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the
|
||||
/// camera's look, one that clips what neither does loses highlight detail.
|
||||
fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool {
|
||||
let near_white = |tone: &ToneMap| {
|
||||
let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count();
|
||||
n as f64 / sensor.data.len().max(1) as f64
|
||||
};
|
||||
let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64;
|
||||
near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE
|
||||
}
|
||||
|
||||
/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the
|
||||
/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the
|
||||
/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice.
|
||||
fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) {
|
||||
let Some(vignette) = lens.and_then(|l| l.vignette) else { return };
|
||||
let (w, h) = (sensor.width as f64, sensor.height as f64);
|
||||
let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let width = sensor.width.max(1);
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h);
|
||||
let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32;
|
||||
*p = p.map(|v| v * gain);
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
|
||||
/// small thumbnail is not fitted.
|
||||
const MIN_PREVIEW_FRACTION: f64 = 0.9;
|
||||
|
||||
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
|
||||
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
|
||||
const DARK_FLOOR: (f32, f32) = (0.004, 0.001);
|
||||
|
||||
/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's
|
||||
/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits
|
||||
/// that clip about 1% more than the camera does and passes the ones that clip like the camera).
|
||||
const CLIP_TOLERANCE: f64 = 0.001;
|
||||
|
||||
/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach.
|
||||
const MIN_STRUCTURE: f64 = 0.85;
|
||||
|
||||
/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance
|
||||
/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3)
|
||||
/// correlates better.
|
||||
fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
|
||||
let (w, h) = (sensor.width, sensor.height);
|
||||
if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h {
|
||||
return false;
|
||||
}
|
||||
let log = |img: &Rgb32f| -> Vec<f64> {
|
||||
let l: Vec<f64> =
|
||||
img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect();
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (mut sum, mut n) = (0.0, 0.0);
|
||||
for yy in y.saturating_sub(2)..(y + 3).min(h) {
|
||||
for xx in x.saturating_sub(2)..(x + 3).min(w) {
|
||||
if let Some(v) = l.get(yy * w + xx) {
|
||||
sum += v;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
sum / f64::max(n, 1.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (a, b) = (log(sensor), log(reference));
|
||||
const M: usize = 3;
|
||||
let corr = |dx: isize, dy: isize| -> f64 {
|
||||
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for y in M..h - M {
|
||||
for x in M..w - M {
|
||||
let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy));
|
||||
let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue };
|
||||
n += 1.0;
|
||||
sa += u;
|
||||
sb += v;
|
||||
saa += u * u;
|
||||
sbb += v * v;
|
||||
sab += u * v;
|
||||
}
|
||||
}
|
||||
let var = (saa - sa * sa / n) * (sbb - sb * sb / n);
|
||||
if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() }
|
||||
};
|
||||
let at_zero = corr(0, 0);
|
||||
let (mut best, mut shift) = (at_zero, (0isize, 0isize));
|
||||
for dy in -(M as isize)..=M as isize {
|
||||
for dx in -(M as isize)..=M as isize {
|
||||
let c = corr(dx, dy);
|
||||
if c > best + 1e-9 {
|
||||
(best, shift) = (c, (dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -397,6 +581,7 @@ fn proxies(
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
size: usize,
|
||||
min_fraction: f64,
|
||||
lens: Option<&LensGeometry>,
|
||||
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
@@ -405,8 +590,13 @@ fn proxies(
|
||||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||||
None,
|
||||
)?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
// a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any
|
||||
if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut reference = decoded.to_working();
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -622,11 +812,11 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom
|
||||
return None;
|
||||
}
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, lens)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
|
||||
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?;
|
||||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||||
}
|
||||
|
||||
@@ -794,7 +984,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||||
/// colours (see [`EDGE_CONTRAST`]).
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -813,7 +1003,8 @@ 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) {
|
||||
let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) };
|
||||
if !colour_pair_above(*input, *output, floors) {
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
@@ -826,9 +1017,15 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
|
||||
|
||||
/// 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 {
|
||||
colour_pair_above(input, output, (0.015, 0.004))
|
||||
}
|
||||
|
||||
/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or
|
||||
/// [`DARK_FLOOR`] for the tone-only fit of a DNG).
|
||||
fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> 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))
|
||||
&& output.iter().all(|v| *v > clo && *v < 0.98)
|
||||
&& (ylo..0.85).contains(&luminance_2020(output))
|
||||
}
|
||||
|
||||
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
|
||||
@@ -965,6 +1162,8 @@ struct Attempt {
|
||||
tone: ToneFit,
|
||||
profile: bool,
|
||||
away_from_edges: bool,
|
||||
/// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG.
|
||||
dark: bool,
|
||||
}
|
||||
|
||||
/// The photo's look and the attempt that produced it: its own matrix (with and without a
|
||||
@@ -981,7 +1180,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
|
||||
order.iter().find_map(|&tone| {
|
||||
profiles.iter().find_map(|&profile| {
|
||||
[false, true].into_iter().find_map(|away_from_edges| {
|
||||
let attempt = Attempt { tone, profile, away_from_edges };
|
||||
let attempt = Attempt { tone, profile, away_from_edges, dark: false };
|
||||
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
|
||||
})
|
||||
})
|
||||
@@ -990,7 +1189,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
|
||||
|
||||
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
|
||||
let colour = if attempt.profile { colour.clone() } else { None };
|
||||
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone)
|
||||
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark)
|
||||
}
|
||||
|
||||
fn fit_pairs_on(
|
||||
@@ -999,10 +1198,11 @@ fn fit_pairs_on(
|
||||
colour: Option<(Mat3, Option<HsvTable>)>,
|
||||
edge_limit: Option<f32>,
|
||||
tone_fit: ToneFit,
|
||||
dark: bool,
|
||||
) -> Option<CameraLook> {
|
||||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||||
// learn a new colour matrix. Still reject monochrome references.
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?;
|
||||
let candidates = match colour {
|
||||
Some(given) => vec![given],
|
||||
None => {
|
||||
@@ -1146,7 +1346,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
|
||||
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
// as for a known colour model: enough signal for the tone, but no monochrome reference
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?;
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?;
|
||||
let related = rank_correlation(&pairs);
|
||||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -1224,7 +1424,7 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, lens)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
@@ -1255,7 +1455,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti
|
||||
|
||||
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
// every pair counts: the JPEG has no colour to require
|
||||
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?;
|
||||
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
|
||||
let related = rank_correlation_ties(&pairs);
|
||||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -1349,6 +1549,42 @@ impl<'a> HueSat<'a> {
|
||||
}
|
||||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||||
}
|
||||
|
||||
/// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through
|
||||
/// [`HsvTable::apply_lanes`], with the guards as selects.
|
||||
#[inline]
|
||||
pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] {
|
||||
let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| {
|
||||
let mut out = [[0.0f32; N]; 3];
|
||||
for (row, out) in m.iter().zip(&mut out) {
|
||||
for l in 0..N {
|
||||
out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l];
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb)));
|
||||
let mut restored = rgb;
|
||||
for l in 0..N {
|
||||
let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]);
|
||||
let (before, after) = (luminance_2020(input), luminance_2020(out));
|
||||
let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite());
|
||||
let w = restore_weight(after / before);
|
||||
for (c, restored) in restored.iter_mut().enumerate() {
|
||||
let full = out[c] * before / after;
|
||||
let blend = input[c] + (full - input[c]) * w;
|
||||
// (`w >= 1` and `w <= 0` exclude each other)
|
||||
restored[l] = if untouched | (w <= 0.0) {
|
||||
input[c]
|
||||
} else if w >= 1.0 {
|
||||
full
|
||||
} else {
|
||||
blend
|
||||
};
|
||||
}
|
||||
}
|
||||
restored
|
||||
}
|
||||
}
|
||||
|
||||
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
|
||||
@@ -1658,7 +1894,7 @@ mod tests {
|
||||
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();
|
||||
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap();
|
||||
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
|
||||
};
|
||||
let expected = fit(&reference);
|
||||
@@ -1715,7 +1951,7 @@ mod tests {
|
||||
*dst = [y * slope; 3];
|
||||
assert_eq!(colour_pair(*src, *dst), i >= 600);
|
||||
}
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).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.
|
||||
@@ -1825,8 +2061,10 @@ mod tests {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: true,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata::default(),
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
|
||||
assert_eq!((proxy.width, proxy.height), (32, 32));
|
||||
@@ -2016,7 +2254,7 @@ mod tests {
|
||||
let mut pool = Vec::new();
|
||||
for seed in 0..4 {
|
||||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||||
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
|
||||
pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0);
|
||||
}
|
||||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||||
@@ -2120,6 +2358,51 @@ mod tests {
|
||||
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
|
||||
}
|
||||
|
||||
/// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey,
|
||||
/// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table.
|
||||
#[test]
|
||||
fn hue_sat_lanes_match_one_by_one_bit_for_bit() {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0;
|
||||
let fitted = (0..72 * 5 * 5)
|
||||
.map(|_| {
|
||||
let r = next();
|
||||
[unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0]
|
||||
})
|
||||
.collect();
|
||||
let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true };
|
||||
let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN];
|
||||
let mut colours = Vec::new();
|
||||
for a in special {
|
||||
for b in special {
|
||||
colours.extend(special.map(|c| [a, b, c]));
|
||||
}
|
||||
}
|
||||
for _ in 0..60_000 {
|
||||
let r = next();
|
||||
let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize];
|
||||
colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset));
|
||||
}
|
||||
for table in [fitted, saturating] {
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
for chunk in colours.chunks(16) {
|
||||
let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c])));
|
||||
let out = hue_sat.apply_lanes(rgb);
|
||||
for (l, p) in chunk.iter().enumerate() {
|
||||
let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p));
|
||||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
|
||||
assert_eq!(restore_weight(0.0), 0.0);
|
||||
@@ -2168,8 +2451,10 @@ mod tests {
|
||||
color,
|
||||
wb_multipliers: Some([2.0, 1.0, 1.5]),
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: Default::default(),
|
||||
metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() },
|
||||
lens_metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2245,7 +2530,7 @@ mod tests {
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
|
||||
(proxies(&raw, &bytes, &t, PROXY, None), proxies(&raw, &bytes, &t, PROXY, Some(&lens)))
|
||||
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -2307,7 +2592,7 @@ mod tests {
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none());
|
||||
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
|
||||
assert!(!t.matrix_is_fallback);
|
||||
@@ -2366,13 +2651,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_camera_outside_the_table_keeps_the_fallback() {
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none() && t.matrix_is_fallback);
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
|
||||
// without a model there is no table row
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
|
||||
raw.metadata.model = None;
|
||||
assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback);
|
||||
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
|
||||
}
|
||||
|
||||
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
|
||||
@@ -2387,7 +2672,7 @@ mod tests {
|
||||
.join("raw");
|
||||
for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
lightcraft_testkit::skip!(Corpus, "{name} absent");
|
||||
continue;
|
||||
};
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
@@ -2398,7 +2683,7 @@ mod tests {
|
||||
let measured = lightcraft_raw::spectral::find(make, model).is_some();
|
||||
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
|
||||
let before = raw.color.clone();
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref());
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, None, lens.as_ref());
|
||||
let spectral = !own && fitted.is_none() && measured;
|
||||
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
|
||||
assert_eq!(raw.color != before, spectral, "{name}");
|
||||
@@ -2492,13 +2777,13 @@ mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/cr3-canon-r100-raw.cr3");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let raw = match lightcraft_raw::decode(&bytes) {
|
||||
Ok(raw) => raw,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
eprintln!("skip CR3 sensor decoder: {why}");
|
||||
lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
|
||||
@@ -2527,7 +2812,7 @@ mod tests {
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
return;
|
||||
}
|
||||
Err(e) => panic!("{}: {e}", path.display()),
|
||||
@@ -2601,7 +2886,7 @@ mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d7500-lossless14.nef");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -2621,7 +2906,7 @@ mod tests {
|
||||
let bytes = match std::fs::read(dir.join(name)) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("skip: {name} absent");
|
||||
lightcraft_testkit::skip!(Corpus, "{name} absent");
|
||||
continue;
|
||||
}
|
||||
Err(e) => panic!("{name}: {e}"),
|
||||
@@ -2642,7 +2927,7 @@ mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -2661,7 +2946,7 @@ mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -2751,6 +3036,36 @@ mod tests {
|
||||
renders_as_shot(&dng);
|
||||
}
|
||||
|
||||
/// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without
|
||||
/// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking
|
||||
/// it from the multipliers again leaves the merged DNG rendering as the tags do.
|
||||
#[test]
|
||||
fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() {
|
||||
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
|
||||
for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] {
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default());
|
||||
raw.wb_multipliers = Some(multipliers);
|
||||
let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices");
|
||||
let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance");
|
||||
// the reciprocal of the multipliers, up to the scale a neutral is given in
|
||||
let scale = neutral[1] * multipliers[1] as f64;
|
||||
for i in 0..3 {
|
||||
assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}");
|
||||
}
|
||||
let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone()));
|
||||
merged.set_as_shot_multipliers(multipliers);
|
||||
let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") };
|
||||
let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None)));
|
||||
for i in 0..3 {
|
||||
assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb);
|
||||
}
|
||||
for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) {
|
||||
assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix);
|
||||
}
|
||||
assert_eq!(a.baseline_exposure, b.baseline_exposure);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -2902,7 +3217,7 @@ mod tests {
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
|
||||
continue;
|
||||
}
|
||||
Err(e) => panic!("{}: {e}", path.display()),
|
||||
@@ -2913,7 +3228,7 @@ mod tests {
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
|
||||
// the look fitted to the JPEG, as before this change
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, None).unwrap();
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
|
||||
@@ -3209,7 +3524,7 @@ mod tests {
|
||||
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
|
||||
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
|
||||
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
|
||||
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false });
|
||||
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
|
||||
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
|
||||
}
|
||||
|
||||
@@ -3472,8 +3787,10 @@ mod tests {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: true,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
|
||||
@@ -3545,4 +3862,145 @@ mod tests {
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
|
||||
/// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right.
|
||||
fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) {
|
||||
let scene = |x: f32, y: f32| -> [f32; 3] {
|
||||
let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32;
|
||||
[l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))]
|
||||
};
|
||||
let camera = |p: [f32; 3]| {
|
||||
let y = luminance_2020(p);
|
||||
p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
|
||||
};
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
let mut reference = sensor.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
sensor.data[y * w + x] = scene(x as f32, y as f32);
|
||||
reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32));
|
||||
}
|
||||
}
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
/// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a
|
||||
/// flat and a differently sized preview.
|
||||
#[test]
|
||||
fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() {
|
||||
let (sensor, reference) = scene_pair(96, 64, 0);
|
||||
assert!(structure_agrees(&sensor, &reference));
|
||||
assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated");
|
||||
assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed");
|
||||
let mut stripes = reference.clone();
|
||||
for (i, p) in stripes.data.iter_mut().enumerate() {
|
||||
*p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] };
|
||||
}
|
||||
assert!(!structure_agrees(&sensor, &stripes), "a broken preview");
|
||||
let mut flat = reference.clone();
|
||||
flat.data.fill([0.2; 3]);
|
||||
assert!(!structure_agrees(&sensor, &flat));
|
||||
assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1));
|
||||
}
|
||||
|
||||
/// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that
|
||||
/// renders mid-grey brighter than the default tone map is followed.
|
||||
#[test]
|
||||
fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() {
|
||||
let (mut sensor, mut reference) = scene_pair(96, 64, 0);
|
||||
for image in [&mut sensor, &mut reference] {
|
||||
for p in image.data.iter_mut().step_by(5) {
|
||||
*p = [p[0] * 1.6, p[1], p[2] * 0.5];
|
||||
}
|
||||
}
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
|
||||
.expect("tone fitted");
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
assert!(look.hue_sat.is_none());
|
||||
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let target = 1.0 - (-2.5f32 * 0.18).exp();
|
||||
assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18));
|
||||
assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18));
|
||||
assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself");
|
||||
}
|
||||
|
||||
/// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the
|
||||
/// camera preview's, in one direction only: clipping what the camera clips is not refused.
|
||||
#[test]
|
||||
fn dng_highlight_gate_compares_near_white_shares() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
*p = [0.02 + 1.5 * i as f32 / 1024.0; 3];
|
||||
}
|
||||
let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0));
|
||||
let mut white = Rgb32f::new(32, 32);
|
||||
white.data.iter_mut().for_each(|p| *p = [1.0; 3]);
|
||||
let mut black = Rgb32f::new(32, 32);
|
||||
black.data.iter_mut().for_each(|p| *p = [0.1; 3]);
|
||||
assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white");
|
||||
assert!(!clips_more(&sensor, &black, &dark, &bright));
|
||||
assert!(!clips_more(&sensor, &black, &bright, &bright));
|
||||
assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much");
|
||||
}
|
||||
|
||||
/// A dark scene's shadows below the usual training floor still reach the tone-only fit.
|
||||
#[test]
|
||||
fn dng_tone_fit_trains_on_dark_pixels() {
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = Rgb32f::new(64, 64);
|
||||
for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() {
|
||||
let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 };
|
||||
*s = [v, v * 1.1, v * 0.9];
|
||||
*r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)];
|
||||
}
|
||||
let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len());
|
||||
let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len());
|
||||
assert!(dark > usual, "{dark} vs {usual}");
|
||||
}
|
||||
|
||||
/// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render.
|
||||
#[test]
|
||||
fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
|
||||
let lens = EmbeddedLens {
|
||||
vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut proxy = Rgb32f::new(96, 64);
|
||||
proxy.data.fill([0.2; 3]);
|
||||
correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal);
|
||||
let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2);
|
||||
assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}");
|
||||
let mut plain = Rgb32f::new(96, 64);
|
||||
plain.data.fill([0.2; 3]);
|
||||
correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal);
|
||||
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
|
||||
}
|
||||
|
||||
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
|
||||
/// identity, no look table; and no other raw format.
|
||||
#[test]
|
||||
fn dng_tone_gates_follow_the_files_own_tags() {
|
||||
use lightcraft_raw::profile::{HsvTable, ToneCurve};
|
||||
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
|
||||
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
|
||||
let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| {
|
||||
let raw = raw_of(format, "Make", "Model", color);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
dng_tone_eligible(&raw, &t)
|
||||
};
|
||||
assert!(eligible(RawFormat::Dng, color.clone()));
|
||||
assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path");
|
||||
assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep");
|
||||
let mut identity = color.clone();
|
||||
identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]);
|
||||
assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone");
|
||||
let mut curved = color.clone();
|
||||
curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]);
|
||||
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
|
||||
let mut looked = color;
|
||||
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
|
||||
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"added": 0}));
|
||||
}
|
||||
@@ -410,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.convertToDng";
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
let mut converted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let mut ids = s.targets(p);
|
||||
let mut ids = s.targets(p)?;
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
@@ -311,7 +311,7 @@ pub(crate) fn reload_ops(
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
@@ -340,7 +340,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.duplicate";
|
||||
let mut made = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
||||
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
||||
let src = Path::new(path);
|
||||
@@ -514,7 +514,7 @@ mod tests {
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
|
||||
@@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option<i64> {
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}),
|
||||
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
None => s.targets(p)?,
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
@@ -811,7 +811,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -838,7 +838,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
clip = lightcraft_develop::extract_groups(&full, &only);
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -890,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let delta = f64_req(p, "delta", c)?;
|
||||
let label = format!("Quick Develop: {}", spec.label);
|
||||
let ops: Vec<Op> = s
|
||||
.targets(p)
|
||||
.targets(p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
@@ -943,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect();
|
||||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||||
for id in ids {
|
||||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||||
@@ -978,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
|
||||
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
|
||||
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
|
||||
let ids = s.targets(p);
|
||||
// `id` names the preset here: the photos are `ids`, else the selection
|
||||
let ids = s.targets(&json!({"ids": p.get("ids")}))?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
|
||||
@@ -132,7 +132,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let detector = detector(s).map_err(|e| e.into_error(C))?;
|
||||
let started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
|
||||
@@ -841,7 +841,7 @@ mod imp {
|
||||
const C: &str = "faces.suggest";
|
||||
let embedder = current(s, C)?;
|
||||
collect(s);
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let model_threshold = embedder.manifest().thresholds.match_cosine;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32);
|
||||
let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| {
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
if let Some(ph) = s.catalog.photo(id) {
|
||||
current.extend(ph.meta.keywords.iter().cloned());
|
||||
}
|
||||
@@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
|
||||
@@ -182,7 +182,7 @@ fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize>
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
@@ -194,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
@@ -213,7 +213,7 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
|
||||
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
|
||||
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
|
||||
let flag = step(old);
|
||||
if flag != old {
|
||||
@@ -258,7 +258,7 @@ fn row_step(p: &Value) -> isize {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
@@ -277,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
@@ -883,7 +883,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let n = mark_deleted(s, &targets, "Delete")?;
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"changed": n}))
|
||||
@@ -912,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let t = s.targets(p)?;
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
select_after_deletion(s, &before);
|
||||
@@ -929,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let strs =
|
||||
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
||||
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
||||
None => None,
|
||||
@@ -1027,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
@@ -1268,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
@@ -1276,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
// looked up, not searched for: adding thousands to a big album stays linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
if held.insert(t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
@@ -1295,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
@@ -1308,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
|
||||
// looked up, not searched for: thousands toggled in a big album stay linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
|
||||
let photos: Vec<PhotoId> = if all_in {
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
|
||||
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
||||
} else {
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
|
||||
};
|
||||
let name = al.name.clone();
|
||||
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
||||
@@ -1454,7 +1458,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
|
||||
},
|
||||
};
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
@@ -1463,6 +1467,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.removePhotos", "album folders don't hold photos"));
|
||||
}
|
||||
let name = al.name.clone();
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
|
||||
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
||||
let removed = al.photos.len().saturating_sub(photos.len());
|
||||
if removed > 0 {
|
||||
|
||||
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
imported.push(json!({"id": id, "name": display, "group": group}));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
// presets that name these profiles use them now
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
s.save_prefs()?;
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"deleted": id}))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::Result;
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p), template, start))
|
||||
Ok((s.targets(p)?, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
@@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
|
||||
let c = "photo.setCaptureTime";
|
||||
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "no photos"));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let (kind, finish) = crate::merge::parse(id, p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
if !bool_or(p, "preview", false) {
|
||||
return s.merge_now(kind, finish, &ids);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "merge.hdrBatch";
|
||||
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
let sets = s.catalog.bracket_sets(&ids);
|
||||
if sets.is_empty() {
|
||||
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
|
||||
|
||||
@@ -34,6 +34,7 @@ mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
mod slideshow;
|
||||
@@ -109,7 +110,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
/// can take (issue #684; Add to My Photos first).
|
||||
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
}
|
||||
/// Why an album command refused a Local-only selection.
|
||||
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
|
||||
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(prefs::specs());
|
||||
v.extend(slideshow::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
|
||||
@@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
|
||||
|
||||
fn in_stack(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let top = p
|
||||
.get("top")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p));
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p)?);
|
||||
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?);
|
||||
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
|
||||
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let stacks = stacks_of(s, &s.targets(p)?);
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
@@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let gap = f64_or(p, "gap", 60.0).max(0.0);
|
||||
let explicit = p.get("ids").is_some();
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let groups = s.catalog.auto_stack_groups(&pool, gap);
|
||||
let photos: usize = groups.iter().map(Vec::len).sum();
|
||||
if bool_or(p, "preview", false) {
|
||||
|
||||
@@ -57,10 +57,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
// the profile it names (issue #1001): one the user imported, or ours for a basic one
|
||||
let mut profile = Value::Null;
|
||||
if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) {
|
||||
let look = it.preset.settings["profile"]["look"].clone();
|
||||
it.unmapped.retain(|u| u != "Look");
|
||||
it.unmapped.extend(crate::profile_refs::report(&look, &r));
|
||||
profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")});
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
Some(id) => {
|
||||
imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile}))
|
||||
}
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
@@ -108,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
@@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let mut jobs: Vec<SmartJob> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
@@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
|
||||
_ => true,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! `print.*`: the Print module as commands.
|
||||
//!
|
||||
//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the
|
||||
//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at
|
||||
//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches
|
||||
//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json.
|
||||
//!
|
||||
//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset`
|
||||
//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear`
|
||||
//! * `print.exportPdf {path, ids?}`
|
||||
//! * `print.template.list|save|apply|delete`
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, f64_req};
|
||||
use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size};
|
||||
use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn rect_json(r: &Rect) -> Value {
|
||||
json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h})
|
||||
}
|
||||
|
||||
/// The state, with what a front end needs to draw it (page size, printable area, grid cells).
|
||||
fn state(s: &Session, photos: usize) -> Value {
|
||||
let l = &s.print.layout;
|
||||
let mut out = json!({
|
||||
"settings": s.print,
|
||||
"papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(),
|
||||
"overlaps": l.has_overlaps(),
|
||||
"photos": photos,
|
||||
});
|
||||
if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) {
|
||||
o.insert("pageWidth".into(), json!(w));
|
||||
o.insert("pageHeight".into(), json!(h));
|
||||
o.insert("printable".into(), rect_json(&area));
|
||||
o.insert("pageCount".into(), json!(l.page_count(photos)));
|
||||
o.insert("cellsPerPage".into(), json!(l.cells_per_page()));
|
||||
if !l.style.is_package() {
|
||||
o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reply(s: &Session) -> Result<Value> {
|
||||
Ok(state(s, s.targets(&Value::Null)?.len()))
|
||||
}
|
||||
|
||||
/// Applies `patch` over `dst`: objects merge key by key, anything else replaces.
|
||||
fn merge(dst: &mut Value, patch: &Value, depth: usize) {
|
||||
match (dst, patch) {
|
||||
(Value::Object(d), Value::Object(p)) if depth < 8 => {
|
||||
for (k, v) in p {
|
||||
match d.get_mut(k) {
|
||||
Some(slot) => merge(slot, v, depth + 1),
|
||||
None => {
|
||||
d.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, p) => *d = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state(s, s.targets(p)?.len()))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.set";
|
||||
let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?;
|
||||
if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)")));
|
||||
}
|
||||
if let Some(l) = patch.get("layout").and_then(Value::as_object)
|
||||
&& let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId"))
|
||||
{
|
||||
return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands")));
|
||||
}
|
||||
let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?;
|
||||
merge(&mut value, p, 0);
|
||||
let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?;
|
||||
// A new paper, orientation or margin may leave cells outside the page: pull them back.
|
||||
if next.layout.page.printable().is_ok() {
|
||||
next.layout.fit_cells().map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.validate().map_err(|e| bad(ID, e))?;
|
||||
s.print = next;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print = PrintSettings::default();
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn cell_id(p: &Value, id: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`"))
|
||||
}
|
||||
|
||||
fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps `r` (a cell being moved or resized) when the call asks for it.
|
||||
fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> {
|
||||
let on = match p.get("snap") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`snap` must be true or false")),
|
||||
};
|
||||
if !on || guides.snap == Snap::Off {
|
||||
return Ok(r);
|
||||
}
|
||||
let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1);
|
||||
let f = if resize { Layout::snap_resize } else { Layout::snap_move };
|
||||
f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn cell_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.add";
|
||||
let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?;
|
||||
if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) {
|
||||
if let Some(page) = p.get("page").and_then(Value::as_u64) {
|
||||
let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?;
|
||||
next.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.move_cell(id, x, y).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
if let Some(v) = p.get("photo").filter(|v| !v.is_null()) {
|
||||
let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?;
|
||||
next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
s.print.layout = next;
|
||||
let cell = s.print.layout.cell(id).cloned();
|
||||
Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages}))
|
||||
}
|
||||
|
||||
fn cell_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.move";
|
||||
let id = cell_id(p, ID)?;
|
||||
let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?;
|
||||
let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.resize";
|
||||
let id = cell_id(p, ID)?;
|
||||
let mut next = s.print.layout.clone();
|
||||
let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let want = Rect::new(
|
||||
opt_f64(p, "x", ID)?.unwrap_or(cur.x),
|
||||
opt_f64(p, "y", ID)?.unwrap_or(cur.y),
|
||||
opt_f64(p, "width", ID)?.unwrap_or(cur.w),
|
||||
opt_f64(p, "height", ID)?.unwrap_or(cur.h),
|
||||
);
|
||||
let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?;
|
||||
let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.delete";
|
||||
let id = cell_id(p, ID)?;
|
||||
s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()}))
|
||||
}
|
||||
|
||||
fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.stack";
|
||||
let id = cell_id(p, ID)?;
|
||||
let how = match p.get("order").and_then(Value::as_str) {
|
||||
Some("toBack") => Stack::ToBack,
|
||||
Some("backward") => Stack::Backward,
|
||||
Some("forward") => Stack::Forward,
|
||||
Some("toFront") => Stack::ToFront,
|
||||
_ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")),
|
||||
};
|
||||
s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.photo";
|
||||
let id = cell_id(p, ID)?;
|
||||
let photo = match p.get("photo") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?;
|
||||
if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() {
|
||||
return Err(bad(ID, format!("no photo {n}")));
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "photo": photo}))
|
||||
}
|
||||
|
||||
fn cell_page(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.page";
|
||||
let id = cell_id(p, ID)?;
|
||||
let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?;
|
||||
s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "page": page}))
|
||||
}
|
||||
|
||||
fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?;
|
||||
Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells}))
|
||||
}
|
||||
|
||||
fn new_page(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?;
|
||||
Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.clear();
|
||||
Ok(json!({"pages": 1, "cells": 0}))
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.exportPdf";
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)")));
|
||||
}
|
||||
if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) {
|
||||
return Err(bad(ID, "ids must be an array of photo ids"));
|
||||
}
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err(bad(ID, "id must be a photo id"));
|
||||
}
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
let ids = s.targets(p)?;
|
||||
let settings = s.print.clone();
|
||||
// a printer profile that can't be used fails the print before anything renders
|
||||
crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?;
|
||||
let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string();
|
||||
let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?;
|
||||
let total = work.len() as u64;
|
||||
let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No);
|
||||
row.progress(0, total);
|
||||
let pages = work
|
||||
.run(&mut |done, name| {
|
||||
row.progress(done as u64, total);
|
||||
row.detail(name);
|
||||
true
|
||||
})
|
||||
.map_err(|e| bad(ID, e))?;
|
||||
let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?;
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
// ---- templates ------------------------------------------------------------------------------
|
||||
|
||||
fn template_list(s: &Session) -> Value {
|
||||
json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn template_name(p: &Value, id: &str) -> Result<String> {
|
||||
clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e))
|
||||
}
|
||||
|
||||
fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.save";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?;
|
||||
match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = t,
|
||||
None => {
|
||||
if s.print_templates.len() >= MAX_TEMPLATES {
|
||||
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
|
||||
}
|
||||
s.print_templates.push(t);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.apply";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = s
|
||||
.print_templates
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?;
|
||||
s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.delete";
|
||||
let name = template_name(p, ID)?;
|
||||
let before = s.print_templates.len();
|
||||
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if s.print_templates.len() == before {
|
||||
return Err(bad(ID, format!("no print template `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "print.get", "Print Settings", [], None,
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
"{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given",
|
||||
always, cell_add),
|
||||
cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move),
|
||||
cmd!(query "print.cell.resize", "Resize Print Cell", [], None,
|
||||
"{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize),
|
||||
cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete),
|
||||
cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack),
|
||||
cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo),
|
||||
cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page),
|
||||
cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout),
|
||||
cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page),
|
||||
cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear),
|
||||
cmd!(query "print.exportPdf", "Print to PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original",
|
||||
always, export_pdf),
|
||||
cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))),
|
||||
cmd!(query "print.template.save", "Save Print Template", [], None,
|
||||
"{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list",
|
||||
always, template_save),
|
||||
cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply),
|
||||
cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete),
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
|
||||
for id in ids {
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
@@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut read = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
match s.read_sidecar_op_with_warnings(id) {
|
||||
Ok(Some((op, from, mut photo_warnings))) => {
|
||||
ops.push(op);
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err("id must be a photo id".into());
|
||||
}
|
||||
let ids = session.targets(p);
|
||||
let ids = session.targets(p).map_err(|e| e.to_string())?;
|
||||
if ids.is_empty() || ids.len() > 1000 {
|
||||
return Err("select between 1 and 1000 photos".into());
|
||||
}
|
||||
|
||||
+114
-12
@@ -185,6 +185,17 @@ impl DestinationMode {
|
||||
}
|
||||
}
|
||||
|
||||
impl Conflict {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"unique" => Some(Self::Unique),
|
||||
"overwrite" => Some(Self::Overwrite),
|
||||
"skip" => Some(Self::Skip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of `app.export` params.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportPreset {
|
||||
@@ -214,6 +225,18 @@ pub enum SharpenFor {
|
||||
Glossy,
|
||||
}
|
||||
|
||||
impl SharpenFor {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"none" => Some(Self::None),
|
||||
"screen" => Some(Self::Screen),
|
||||
"matte" => Some(Self::Matte),
|
||||
"glossy" => Some(Self::Glossy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SharpenAmount {
|
||||
@@ -223,6 +246,17 @@ pub enum SharpenAmount {
|
||||
High,
|
||||
}
|
||||
|
||||
impl SharpenAmount {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"low" => Some(Self::Low),
|
||||
"standard" => Some(Self::Standard),
|
||||
"high" => Some(Self::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a watermark sits.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -537,6 +571,18 @@ pub enum MetadataPolicy {
|
||||
None,
|
||||
}
|
||||
|
||||
impl MetadataPolicy {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"all" => Some(Self::All),
|
||||
"allexceptcamera" => Some(Self::AllExceptCamera),
|
||||
"copyright" => Some(Self::Copyright),
|
||||
"none" => Some(Self::None),
|
||||
_ => 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)]
|
||||
@@ -814,6 +860,10 @@ impl ExportOptions {
|
||||
let s = string(cmd, k, v)?;
|
||||
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
// a choice `from_json` reads with the same `parse`, so what passes here is what gets exported
|
||||
let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> {
|
||||
if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
@@ -869,10 +919,10 @@ impl ExportOptions {
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?,
|
||||
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?,
|
||||
"destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
@@ -880,7 +930,7 @@ impl ExportOptions {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?;
|
||||
}
|
||||
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"colorSpace" => {
|
||||
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
|
||||
@@ -982,13 +1032,13 @@ impl ExportOptions {
|
||||
resize: Self::resize_from_json(p),
|
||||
ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16),
|
||||
limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32),
|
||||
sharpen: enm(p, "sharpen").unwrap_or(d.sharpen),
|
||||
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
|
||||
sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen),
|
||||
sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount),
|
||||
naming: s("naming").map_or(d.naming, str::to_string),
|
||||
start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32),
|
||||
destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode),
|
||||
subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")),
|
||||
conflict: enm(p, "conflict").unwrap_or(d.conflict),
|
||||
conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict),
|
||||
tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("none") => TiffCompression::None,
|
||||
Some("lzw") => TiffCompression::Lzw,
|
||||
@@ -996,7 +1046,7 @@ impl ExportOptions {
|
||||
_ => d.tiff_compression,
|
||||
},
|
||||
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
watermark: match p.get("watermark") {
|
||||
Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }),
|
||||
@@ -2457,7 +2507,7 @@ mod tests {
|
||||
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
|
||||
use ab_glyph::Font;
|
||||
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
|
||||
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
|
||||
lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts");
|
||||
return;
|
||||
};
|
||||
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
|
||||
@@ -2529,7 +2579,7 @@ mod tests {
|
||||
compare(composed, decomposed, crate::fonts::CRAFT_FONTS);
|
||||
}
|
||||
} else {
|
||||
eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts");
|
||||
lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2553,7 +2603,7 @@ mod tests {
|
||||
use ab_glyph::Font;
|
||||
let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS);
|
||||
if fonts.len() < 2 {
|
||||
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
|
||||
lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
|
||||
return;
|
||||
}
|
||||
for c in "日本語の文字".chars() {
|
||||
@@ -3182,6 +3232,58 @@ mod tests {
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o);
|
||||
}
|
||||
|
||||
/// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the
|
||||
/// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and
|
||||
/// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and
|
||||
/// `conflict` had the same gap.
|
||||
#[test]
|
||||
fn accepted_choices_are_the_ones_exported() {
|
||||
use serde_json::json;
|
||||
let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string());
|
||||
let metadata = [
|
||||
("all", MetadataPolicy::All),
|
||||
("allExceptCamera", MetadataPolicy::AllExceptCamera),
|
||||
("copyright", MetadataPolicy::Copyright),
|
||||
("none", MetadataPolicy::None),
|
||||
];
|
||||
for (name, want) in metadata {
|
||||
for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] {
|
||||
assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}");
|
||||
}
|
||||
}
|
||||
for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)]
|
||||
{
|
||||
assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want);
|
||||
}
|
||||
for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] {
|
||||
assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want);
|
||||
}
|
||||
for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] {
|
||||
assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want);
|
||||
}
|
||||
// an explicit value nothing reads as is an error, never the permissive default
|
||||
for v in ["", " none", "nothing", "all-except-camera"] {
|
||||
let e = read("metadata", v).unwrap_err();
|
||||
assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}");
|
||||
}
|
||||
assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high"));
|
||||
assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip"));
|
||||
// absent (or null) keeps the defaults
|
||||
let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap();
|
||||
assert_eq!(
|
||||
(d.metadata, d.sharpen, d.sharpen_amount, d.conflict),
|
||||
(MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique)
|
||||
);
|
||||
// `removeLocation` stays independent of the policy's spelling
|
||||
let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap();
|
||||
assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true));
|
||||
// stored settings (lenient `from_json`) read the same spellings
|
||||
assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None);
|
||||
// and `to_json` still writes what the MCP schema lists
|
||||
let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json();
|
||||
assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip")));
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
@@ -3264,7 +3366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
let mut s = crate::Session::with_demo();
|
||||
|
||||
+75
-25
@@ -159,10 +159,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
}
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
|
||||
}
|
||||
let (w, h) = raw.developed_size();
|
||||
let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32);
|
||||
if raw.orientation.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
@@ -267,19 +265,43 @@ impl CameraColourModel<'_> {
|
||||
/// White balance, matrix, tables and baseline exposure, in place.
|
||||
pub fn apply(&self, img: &mut Rgb32f) {
|
||||
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) {
|
||||
// a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits)
|
||||
const LANES: usize = 16;
|
||||
lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| {
|
||||
for chunk in row.chunks_mut(LANES) {
|
||||
let mut rgb = [[0.0f32; LANES]; 3];
|
||||
for (l, p) in chunk.iter().enumerate() {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
for (i, rgb) in rgb.iter_mut().enumerate() {
|
||||
rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain;
|
||||
}
|
||||
}
|
||||
let out = hue_sat.apply_lanes(rgb);
|
||||
for (l, p) in chunk.iter_mut().enumerate() {
|
||||
*p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
img.map_in_place(|p| self.pixel(&m, p));
|
||||
}
|
||||
|
||||
/// [`Self::apply`] to one pixel; `m` is the matrix as `f32`.
|
||||
fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (wb, gain) = (self.wb, self.gain);
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,8 +430,10 @@ fn load_bytes_now(
|
||||
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
|
||||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
|
||||
let ((xy, t, camera_look), mattes) =
|
||||
rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref()), || dng_mattes(&bytes, &info));
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(
|
||||
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref(), lens_geometry.as_ref()),
|
||||
|| dng_mattes(&bytes, &info),
|
||||
);
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
@@ -479,10 +503,11 @@ fn load_bytes_now(
|
||||
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
|
||||
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
|
||||
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
|
||||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng))
|
||||
.then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let local_tone = local_tone(&raw);
|
||||
let (temp, tint) = scale.as_shot(xy);
|
||||
@@ -1643,7 +1668,7 @@ pub(crate) mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||||
@@ -1810,6 +1835,31 @@ pub(crate) mod tests {
|
||||
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
|
||||
}
|
||||
|
||||
/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the
|
||||
/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included.
|
||||
#[test]
|
||||
fn camera_look_in_lanes_matches_pixel_by_pixel() {
|
||||
let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect();
|
||||
let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true };
|
||||
let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap();
|
||||
let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) };
|
||||
let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN];
|
||||
let img = Rgb32f::from_fn(37, 5, |x, y| {
|
||||
let k = y * 37 + x;
|
||||
if k % 5 == 0 {
|
||||
[special[k % 10], special[k / 10 % 10], special[k / 100 % 10]]
|
||||
} else {
|
||||
[(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05]
|
||||
}
|
||||
});
|
||||
let mut lanes = img.clone();
|
||||
colour.apply(&mut lanes);
|
||||
let m = colour.matrix.to_f32();
|
||||
for (p, got) in img.data.iter().zip(&lanes.data) {
|
||||
assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_white_renders_neutral_through_the_colour_model() {
|
||||
use lightcraft_color::{LUMA_2020, Mat3};
|
||||
@@ -1994,7 +2044,7 @@ pub(crate) mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let (img, _) = load_bytes(&bytes, 1600).unwrap();
|
||||
|
||||
@@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -
|
||||
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
|
||||
crate::files::embedded_lens(raw).or_else(|| {
|
||||
let db = database()?;
|
||||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) };
|
||||
let (w, h) = raw.developed_size();
|
||||
lookup(db, &meta(), w as f64, h as f64, raw.orientation)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ mod pdf;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod print;
|
||||
pub mod print_color;
|
||||
pub mod print_pdf;
|
||||
pub mod profile_refs;
|
||||
pub mod proof;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
@@ -291,6 +295,11 @@ pub struct Session {
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
|
||||
/// with the session, so the app, CLI, control channel and MCP all see the same print.
|
||||
pub print: print::template::PrintSettings,
|
||||
/// Saved print templates (`print.template.*`), persisted in prefs.json.
|
||||
pub print_templates: Vec<print::template::PrintTemplate>,
|
||||
/// Metadata presets (`metadata.*`), persisted in prefs.json.
|
||||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
@@ -444,6 +453,8 @@ impl Session {
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
print: Default::default(),
|
||||
print_templates: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
@@ -500,7 +511,11 @@ impl Session {
|
||||
}
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e {
|
||||
// a shared parameter check ([`Session::targets`]) names the command it ran for
|
||||
EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg },
|
||||
e => e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
|
||||
@@ -1027,8 +1042,12 @@ impl Session {
|
||||
&& !al.is_smart()
|
||||
&& self.filter == Filter::default()
|
||||
{
|
||||
let order = al.photos.clone();
|
||||
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
// each photo's place looked up, not searched for (a big album sorted in O(n log n))
|
||||
let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len());
|
||||
for (i, id) in al.photos.iter().enumerate() {
|
||||
place.entry(*id).or_insert(i);
|
||||
}
|
||||
visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX));
|
||||
if !self.sort.ascending {
|
||||
visible.reverse();
|
||||
}
|
||||
@@ -1167,14 +1186,24 @@ impl Session {
|
||||
(self.visible_gen, self.visible.clone())
|
||||
}
|
||||
|
||||
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
|
||||
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
return a.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
/// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`].
|
||||
/// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list
|
||||
/// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo.
|
||||
pub fn targets(&self, p: &Value) -> Result<Vec<PhotoId>> {
|
||||
let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") };
|
||||
match p.get("ids") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(),
|
||||
Some(_) => return Err(invalid("ids")),
|
||||
}
|
||||
if let Some(id) = p.get("id").and_then(Value::as_u64) {
|
||||
return vec![PhotoId(id)];
|
||||
match p.get("id") {
|
||||
None | Some(Value::Null) => Ok(self.selected()),
|
||||
Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos commands act on when a call names none: the selection, else the active photo.
|
||||
pub fn selected(&self) -> Vec<PhotoId> {
|
||||
if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() }
|
||||
}
|
||||
}
|
||||
@@ -1254,6 +1283,8 @@ mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_presets_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_recheck;
|
||||
|
||||
@@ -209,6 +209,8 @@ struct PrefsFile {
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Print templates.
|
||||
print_templates: Vec<crate::print::template::PrintTemplate>,
|
||||
/// Metadata presets.
|
||||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
@@ -491,6 +493,7 @@ impl Session {
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.print_templates = prefs.print_templates;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
@@ -744,6 +747,7 @@ impl Session {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
print_templates: self.print_templates.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 63;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 64;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -191,6 +191,7 @@ impl MergeJob {
|
||||
metadata: r.metadata,
|
||||
baseline_exposure: r.baseline_exposure,
|
||||
lens: Vec::new(),
|
||||
wb_multipliers: r.wb_multipliers,
|
||||
};
|
||||
f.orient();
|
||||
merged.push(f);
|
||||
|
||||
@@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
for (k, v) in fields {
|
||||
let key = format!("crs:{k}");
|
||||
// the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…)
|
||||
if k == "Look"
|
||||
&& let Lua::Table(_, look) = v
|
||||
{
|
||||
for (f, fv) in look {
|
||||
let val = match fv {
|
||||
Lua::Num(n) => format!("{n}"),
|
||||
Lua::Str(s) => delocalize(s),
|
||||
_ => continue,
|
||||
};
|
||||
props.insert(format!("crs:Look/crs:{f}"), vec![val]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), crate::crs_masks::from_lua(v));
|
||||
}
|
||||
@@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri
|
||||
o.remove("wb");
|
||||
}
|
||||
}
|
||||
// the profile it names, resolved against the user's imported profiles by `preset.import`
|
||||
if let Some(look) = crate::profile_refs::look_ref(props)
|
||||
&& let Some(o) = settings.as_object_mut()
|
||||
{
|
||||
o.insert("profile".into(), serde_json::json!({"look": look}));
|
||||
}
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#,
|
||||
assert_eq!(d.light.exposure, 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_keeps_the_profile_it_names() {
|
||||
let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2,
|
||||
Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#;
|
||||
let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap();
|
||||
assert_eq!(
|
||||
v[0].preset.settings["profile"],
|
||||
serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_process_version_fields_are_approximated() {
|
||||
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
||||
//!
|
||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||
//! background thread with progress and cancellation.
|
||||
//!
|
||||
//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the
|
||||
//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border,
|
||||
//! stroke) are converted to the same encoding.
|
||||
use super::layout::{Layout, Rect, Style};
|
||||
use super::template::PrintSettings;
|
||||
use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster};
|
||||
use crate::Session;
|
||||
use crate::export::{ExportOptions, output_size};
|
||||
use crate::media::RenderJob;
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most photos one print job takes.
|
||||
pub const MAX_PHOTOS: usize = 1000;
|
||||
/// Widest Photo Border, inches.
|
||||
pub const MAX_BORDER_IN: f64 = 2.0;
|
||||
/// Widest Inner Stroke, points.
|
||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||
/// Renders kept for cells that print the same photo at the same size.
|
||||
const RENDER_CACHE: usize = 4;
|
||||
|
||||
/// Lightroom's Image Settings panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ImageSettings {
|
||||
/// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo.
|
||||
pub zoom_to_fill: bool,
|
||||
/// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill).
|
||||
pub rotate_to_fit: bool,
|
||||
/// A border around the photo inside its cell; Picture Package and Custom Package only.
|
||||
pub border_enabled: bool,
|
||||
/// Border width, inches.
|
||||
pub border_width: f64,
|
||||
/// Border colour, sRGB.
|
||||
pub border_color: [u8; 3],
|
||||
/// A stroke drawn inside the photo's edge, every layout style.
|
||||
pub stroke_enabled: bool,
|
||||
/// Stroke width, points.
|
||||
pub stroke_width_pt: f64,
|
||||
/// Stroke colour, sRGB.
|
||||
pub stroke_color: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for ImageSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom_to_fill: false,
|
||||
rotate_to_fit: false,
|
||||
border_enabled: false,
|
||||
border_width: 0.25,
|
||||
border_color: [255; 3],
|
||||
stroke_enabled: false,
|
||||
stroke_width_pt: 1.0,
|
||||
stroke_color: [0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageSettings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v);
|
||||
if !ok(self.border_width, MAX_BORDER_IN) {
|
||||
return Err(format!("border width must be 0 to {MAX_BORDER_IN} in"));
|
||||
}
|
||||
if !ok(self.stroke_width_pt, MAX_STROKE_PT) {
|
||||
return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightroom's Page panel (the parts that are not margins or overlays).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PageStyle {
|
||||
/// Page background colour, sRGB.
|
||||
pub background: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for PageStyle {
|
||||
fn default() -> Self {
|
||||
Self { background: [255; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]).
|
||||
pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
||||
let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0)));
|
||||
let trc = OutputSpace::Rec2020.trc();
|
||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||
}
|
||||
|
||||
// ---- placement ------------------------------------------------------------------------------
|
||||
|
||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// Turned a quarter (clockwise).
|
||||
pub rotated: bool,
|
||||
/// Scaled size after turning: at most the area when fitting, at least the area when filling.
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// The scaled size before turning (the size to render at).
|
||||
pub fn unturned(&self) -> (usize, usize) {
|
||||
if self.rotated { (self.h, self.w) } else { (self.w, self.h) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides turning and scale. `None` for an empty area or a photo without size.
|
||||
pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option<Placement> {
|
||||
if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 {
|
||||
return None;
|
||||
}
|
||||
let (af, bf) = (aw as f64, ah as f64);
|
||||
let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) };
|
||||
let (s0, s1) = (scale(iw, ih), scale(ih, iw));
|
||||
let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) };
|
||||
let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) };
|
||||
let (w, h) = if fill {
|
||||
(((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah))
|
||||
} else {
|
||||
((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah))
|
||||
};
|
||||
Some(Placement { rotated, w, h })
|
||||
}
|
||||
|
||||
/// A rectangle in page pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PxRect {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page.
|
||||
pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option<PxRect> {
|
||||
if !r.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize;
|
||||
let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph));
|
||||
(x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 })
|
||||
}
|
||||
|
||||
/// The cells of every page with the photo each prints, by layout style: Single Image and Contact
|
||||
/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the
|
||||
/// first photo in every cell; Custom Package prints each cell's own photo (else the first).
|
||||
pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result<Vec<Vec<(Rect, PhotoId)>>, String> {
|
||||
let first = photos.first().copied().ok_or("select at least one photo to print")?;
|
||||
if photos.len() > MAX_PHOTOS {
|
||||
return Err(format!("a print takes at most {MAX_PHOTOS} photos"));
|
||||
}
|
||||
match layout.style {
|
||||
Style::SingleImage | Style::ContactSheet => {
|
||||
let grid = layout.grid_rects()?;
|
||||
let per_page = grid.len().max(1);
|
||||
let pages = photos.len().div_ceil(per_page);
|
||||
if pages > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos"));
|
||||
}
|
||||
Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect())
|
||||
}
|
||||
Style::PicturePackage | Style::CustomPackage => {
|
||||
let pages = layout.pages.max(1) as usize;
|
||||
if pages > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
|
||||
}
|
||||
if layout.cells.is_empty() {
|
||||
return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into());
|
||||
}
|
||||
let mut out: Vec<Vec<(Rect, PhotoId)>> = vec![Vec::new(); pages];
|
||||
for c in &layout.cells {
|
||||
let photo = match layout.style {
|
||||
Style::CustomPackage => c.photo.map_or(first, PhotoId),
|
||||
_ => first,
|
||||
};
|
||||
out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pixels ----------------------------------------------------------------------------------
|
||||
|
||||
/// 16-bit interleaved RGB.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image16 {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Image16 {
|
||||
pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> {
|
||||
let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?;
|
||||
let mut rgb = Vec::new();
|
||||
rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?;
|
||||
for _ in 0..w * h {
|
||||
rgb.extend_from_slice(&c);
|
||||
}
|
||||
Ok(Self { w, h, rgb })
|
||||
}
|
||||
|
||||
fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> {
|
||||
let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?;
|
||||
let s = self.rgb.get(i..i.checked_add(3)?)?;
|
||||
Some([*s.first()?, *s.get(1)?, *s.get(2)?])
|
||||
}
|
||||
|
||||
/// Fills a rectangle (clipped to the image).
|
||||
pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) {
|
||||
let x1 = x.saturating_add(w).min(self.w);
|
||||
for row in y..y.saturating_add(h).min(self.h) {
|
||||
let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue };
|
||||
for px in line.as_chunks_mut::<3>().0 {
|
||||
*px = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images.
|
||||
pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) {
|
||||
let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
|
||||
let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
|
||||
for row in 0..h {
|
||||
let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3);
|
||||
let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3);
|
||||
if let (Some(from), Some(to)) = (from, to) {
|
||||
to.copy_from_slice(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quarter turn clockwise.
|
||||
pub fn rotated_cw(&self) -> Result<Image16, String> {
|
||||
let mut out = Image16::filled(self.h, self.w, [0; 3])?;
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let Some(p) = self.px(x, y) else { continue };
|
||||
// (x, y) lands at (h - 1 - y, x).
|
||||
let i = (x * out.w + (self.h - 1 - y)) * 3;
|
||||
if let Some(d) = out.rgb.get_mut(i..i + 3) {
|
||||
d.copy_from_slice(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it.
|
||||
pub fn resized(&self, nw: usize, nh: usize) -> Result<Image16, String> {
|
||||
if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 {
|
||||
return Err("empty image".into());
|
||||
}
|
||||
if (nw, nh) == (self.w, self.h) {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
let wx = axis_weights(self.w, nw);
|
||||
let wy = axis_weights(self.h, nh);
|
||||
// Horizontal pass into 16-bit rows, then the vertical pass.
|
||||
let mut mid = Image16::filled(nw, self.h, [0; 3])?;
|
||||
for y in 0..self.h {
|
||||
let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue };
|
||||
let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
|
||||
for (x, taps) in wx.iter().enumerate() {
|
||||
for c in 0..3 {
|
||||
let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum();
|
||||
if let Some(d) = dst.get_mut(x * 3 + c) {
|
||||
*d = v.round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Image16::filled(nw, nh, [0; 3])?;
|
||||
for (y, taps) in wy.iter().enumerate() {
|
||||
let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum();
|
||||
*d = v.round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1).
|
||||
fn axis_weights(old: usize, new: usize) -> Vec<Vec<(usize, f32)>> {
|
||||
let (o, n) = (old as f64, new as f64);
|
||||
(0..new)
|
||||
.map(|i| {
|
||||
if new < old {
|
||||
let step = o / n;
|
||||
let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step);
|
||||
let first = a.floor() as usize;
|
||||
let last = (b.ceil() as usize).min(old);
|
||||
(first..last)
|
||||
.filter_map(|j| {
|
||||
let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0);
|
||||
(overlap > 0.0).then_some((j, (overlap / step) as f32))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let pos = (i as f64 + 0.5) * o / n - 0.5;
|
||||
let j0 = pos.floor();
|
||||
let f = (pos - j0) as f32;
|
||||
let at = |j: f64| j.clamp(0.0, o - 1.0) as usize;
|
||||
vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything about a cell's look that does not depend on the photo.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CellStyle {
|
||||
pub fill: bool,
|
||||
pub rotate: bool,
|
||||
pub border_px: usize,
|
||||
pub border: [u16; 3],
|
||||
pub stroke_px: usize,
|
||||
pub stroke: [u16; 3],
|
||||
}
|
||||
|
||||
impl CellStyle {
|
||||
/// The style for `settings` at `ppi`; the border applies to the package styles only.
|
||||
pub fn of(settings: &PrintSettings, ppi: f64) -> Self {
|
||||
let img = &settings.image;
|
||||
let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 };
|
||||
let stroke_px =
|
||||
if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 };
|
||||
Self {
|
||||
fill: img.zoom_to_fill,
|
||||
rotate: img.rotate_to_fit,
|
||||
border_px,
|
||||
border: rec2020_from_srgb8(img.border_color),
|
||||
stroke_px,
|
||||
stroke: rec2020_from_srgb8(img.stroke_color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||
/// asked), then the inner stroke on top of it.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
|
||||
let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
|
||||
let (uw, uh) = p.unturned();
|
||||
let mut img = photo.resized(uw, uh)?;
|
||||
if p.rotated {
|
||||
img = img.rotated_cw()?;
|
||||
}
|
||||
// Where the photo lands: centred in the area, cropped to it when it overflows.
|
||||
let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2);
|
||||
let (w, h) = (img.w.min(aw), img.h.min(ah));
|
||||
let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2);
|
||||
if border > 0 {
|
||||
page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border);
|
||||
}
|
||||
page.blit(&img, sx, sy, dx, dy, w, h);
|
||||
let s = style.stroke_px.min(w.min(h) / 2);
|
||||
if s > 0 {
|
||||
page.fill_rect(dx, dy, w, s, style.stroke);
|
||||
page.fill_rect(dx, dy + h - s, w, s, style.stroke);
|
||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- the job ---------------------------------------------------------------------------------
|
||||
|
||||
struct Item {
|
||||
page: usize,
|
||||
name: String,
|
||||
cell: PxRect,
|
||||
key: (u64, usize, usize),
|
||||
job: RenderJob,
|
||||
}
|
||||
|
||||
/// A print job set up from the session: one render per cell, ready to run on another thread.
|
||||
pub struct Prepared {
|
||||
pages: Vec<(usize, usize)>,
|
||||
items: Vec<Item>,
|
||||
ppi: f32,
|
||||
background: [u16; 3],
|
||||
style: CellStyle,
|
||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||
color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Renders (progress steps) the job will run.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Pages the job will produce.
|
||||
pub fn pages(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
/// Renders every photo and composes the pages. `progress(done, name)` is called before each
|
||||
/// render with the photo's file name; returning `false` cancels (no partial result).
|
||||
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Vec<PageRaster>, String> {
|
||||
let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new();
|
||||
let mut done = 0usize;
|
||||
let mut items = self.items.into_iter().peekable();
|
||||
let mut out = Vec::with_capacity(self.pages.len());
|
||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||
let mut page = Image16::filled(w, h, self.background)?;
|
||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||
if !progress(done, &item.name) {
|
||||
return Err("Print cancelled".into());
|
||||
}
|
||||
done += 1;
|
||||
let at = cache.iter().position(|(k, _)| *k == item.key);
|
||||
let at = match at {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
if cache.len() >= RENDER_CACHE {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push((item.key, img));
|
||||
cache.len() - 1
|
||||
}
|
||||
};
|
||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
}
|
||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||
}
|
||||
if !progress(done, "") {
|
||||
return Err("Print cancelled".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result<Image16, String> {
|
||||
let rendered = job.run().rendered?;
|
||||
let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?;
|
||||
// print sharpening for the paper, at the size the photo prints
|
||||
color.sharpen_photo(&mut deep);
|
||||
let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) };
|
||||
if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) {
|
||||
return Err("the render is not Rec. 2020 RGB".into());
|
||||
}
|
||||
Ok(Image16 { w: deep.width, h: deep.height, rgb })
|
||||
}
|
||||
|
||||
/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell.
|
||||
pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result<Prepared, String> {
|
||||
let mut settings = settings.clone();
|
||||
settings.validate()?;
|
||||
let ppi = f64::from(settings.job.effective_ppi());
|
||||
let (pw_in, ph_in) = settings.layout.page_size()?;
|
||||
let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize);
|
||||
let page_px = (pw as u64).saturating_mul(ph as u64);
|
||||
if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS {
|
||||
return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution"));
|
||||
}
|
||||
let plan = plan_cells(&settings.layout, photos)?;
|
||||
if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS {
|
||||
return Err("the print is too large for memory; lower the print resolution or print fewer pages".into());
|
||||
}
|
||||
let style = CellStyle::of(&settings, ppi);
|
||||
let mut items = Vec::new();
|
||||
for (page, cells) in plan.iter().enumerate() {
|
||||
for (rect, id) in cells {
|
||||
let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue };
|
||||
let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?;
|
||||
let name = photo.file_name.clone();
|
||||
let (nw, nh) = output_size(photo, &ExportOptions::default());
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
|
||||
let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
|
||||
let (rw, rh) = p.unturned();
|
||||
let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?;
|
||||
items.push(Item { page, name, cell, key: (id.0, rw, rh), job });
|
||||
}
|
||||
}
|
||||
Ok(Prepared {
|
||||
pages: vec![(pw, ph); plan.len()],
|
||||
items,
|
||||
ppi: ppi as f32,
|
||||
background: rec2020_from_srgb8(settings.page.background),
|
||||
style,
|
||||
color: settings.job.color.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::layout::{GridSpec, Margins};
|
||||
|
||||
const RED: [u16; 3] = [60000, 1000, 1000];
|
||||
const BLUE: [u16; 3] = [1000, 1000, 60000];
|
||||
const WHITE: [u16; 3] = [65535; 3];
|
||||
|
||||
/// A `w` x `h` image whose left half is red and right half blue, with a green top row.
|
||||
fn halves(w: usize, h: usize) -> Image16 {
|
||||
let mut img = Image16::filled(w, h, RED).unwrap();
|
||||
img.fill_rect(w / 2, 0, w - w / 2, h, BLUE);
|
||||
img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]);
|
||||
img
|
||||
}
|
||||
|
||||
fn style() -> CellStyle {
|
||||
CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] }
|
||||
}
|
||||
|
||||
fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] {
|
||||
img.px(x, y).unwrap()
|
||||
}
|
||||
|
||||
fn page() -> Image16 {
|
||||
Image16::filled(100, 100, WHITE).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_fit_fill_and_rotation() {
|
||||
// 4:3 photo into a 2:1 area.
|
||||
let fit = place(400.0, 300.0, 200, 100, false, false).unwrap();
|
||||
assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false));
|
||||
let fill = place(400.0, 300.0, 200, 100, true, false).unwrap();
|
||||
assert_eq!((fill.w, fill.h), (200, 150));
|
||||
// A portrait photo in a landscape area: turning prints it larger.
|
||||
assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 });
|
||||
let turned = place(300.0, 400.0, 400, 300, false, true).unwrap();
|
||||
assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300));
|
||||
assert_eq!(turned.unturned(), (300, 400));
|
||||
// Filling a landscape cell with a portrait photo crops less when turned.
|
||||
let f = place(300.0, 400.0, 400, 300, true, true).unwrap();
|
||||
assert!(f.rotated && f.w >= 400 && f.h >= 300);
|
||||
// Matching orientation never turns.
|
||||
assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated);
|
||||
assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated);
|
||||
// Hostile input.
|
||||
for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] {
|
||||
assert!(place(bad, 10.0, 10, 10, false, false).is_none());
|
||||
assert!(place(10.0, bad, 10, 10, true, true).is_none());
|
||||
}
|
||||
assert!(place(10.0, 10.0, 0, 10, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_centres_the_photo() {
|
||||
let mut p = page();
|
||||
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap();
|
||||
// 40x20 scaled to 80x40, centred vertically at y 30..70.
|
||||
assert_eq!(at(&p, 10, 50), RED);
|
||||
assert_eq!(at(&p, 89, 50), BLUE);
|
||||
assert_eq!(at(&p, 50, 20), WHITE, "above the photo");
|
||||
assert_eq!(at(&p, 50, 75), WHITE, "below the photo");
|
||||
assert_eq!(at(&p, 5, 50), WHITE, "outside the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fill_crops_the_centre() {
|
||||
let mut p = page();
|
||||
let s = CellStyle { fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap();
|
||||
// The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell.
|
||||
for y in [10, 50, 89] {
|
||||
assert_ne!(at(&p, 10, y), WHITE);
|
||||
assert_ne!(at(&p, 89, y), WHITE);
|
||||
}
|
||||
assert_eq!(at(&p, 20, 50), RED);
|
||||
assert_eq!(at(&p, 80, 50), BLUE);
|
||||
// Nothing leaks outside the cell.
|
||||
assert_eq!(at(&p, 9, 50), WHITE);
|
||||
assert_eq!(at(&p, 90, 50), WHITE);
|
||||
assert_eq!(at(&p, 50, 9), WHITE);
|
||||
assert_eq!(at(&p, 50, 90), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_to_fit_turns_clockwise() {
|
||||
// A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell.
|
||||
let mut p = page();
|
||||
let cell = PxRect { x: 10, y: 10, w: 80, h: 40 };
|
||||
draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap();
|
||||
assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle");
|
||||
assert_eq!(at(&p, 45, 30), RED);
|
||||
let mut q = page();
|
||||
draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap();
|
||||
// Turned clockwise the left half (red) becomes the top half and the photo spans the cell.
|
||||
assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12));
|
||||
assert_eq!(at(&q, 12, 48), BLUE);
|
||||
assert_eq!(at(&q, 50, 20), RED);
|
||||
assert_eq!(at(&q, 50, 40), BLUE);
|
||||
assert_eq!(at(&q, 11, 30), at(&q, 11, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_frames_the_photo_inside_the_cell() {
|
||||
let mut p = page();
|
||||
let green = [100, 60000, 100];
|
||||
let s = CellStyle { border_px: 5, border: green, fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap();
|
||||
assert_eq!(at(&p, 10, 40), green);
|
||||
assert_eq!(at(&p, 14, 40), green);
|
||||
assert_eq!(at(&p, 15, 40), RED);
|
||||
assert_eq!(at(&p, 69, 40), green);
|
||||
assert_eq!(at(&p, 64, 40), BLUE);
|
||||
assert_eq!(at(&p, 40, 10), green);
|
||||
assert_eq!(at(&p, 40, 69), green);
|
||||
// A fitted photo gets the border hugging it, not the whole cell.
|
||||
let mut q = page();
|
||||
let s = CellStyle { border_px: 5, border: green, ..style() };
|
||||
draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap();
|
||||
// The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick.
|
||||
assert_eq!(at(&q, 50, 23), green);
|
||||
assert_eq!(at(&q, 50, 22), green);
|
||||
assert_eq!(at(&q, 50, 21), WHITE);
|
||||
// A border wider than the cell leaves the photo visible.
|
||||
let mut r = page();
|
||||
let s = CellStyle { border_px: 500, border: green, ..style() };
|
||||
draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap();
|
||||
assert_ne!(at(&r, 20, 20), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_is_drawn_inside_the_photo_edge() {
|
||||
let mut p = page();
|
||||
let black = [0, 0, 0];
|
||||
let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap();
|
||||
for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] {
|
||||
assert_eq!(at(&p, x, y), black, "({x}, {y})");
|
||||
}
|
||||
assert_eq!(at(&p, 23, 40), RED);
|
||||
assert_eq!(at(&p, 76, 40), BLUE);
|
||||
assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo");
|
||||
// Corners are covered exactly once and a huge stroke stays within the photo.
|
||||
let mut q = page();
|
||||
let s = CellStyle { stroke_px: 1000, stroke: black, ..style() };
|
||||
draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap();
|
||||
assert_eq!(at(&q, 40, 40), black);
|
||||
assert_eq!(at(&q, 19, 40), WHITE);
|
||||
assert_eq!(at(&q, 60, 40), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_are_rec2020_encoded() {
|
||||
assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]);
|
||||
assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]);
|
||||
let grey = rec2020_from_srgb8([128; 3]);
|
||||
assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3);
|
||||
// sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero.
|
||||
let red = rec2020_from_srgb8([255, 0, 0]);
|
||||
assert!(red[0] < 65535 && red[1] > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_preserves_flat_colour_and_edges() {
|
||||
let img = halves(64, 32);
|
||||
for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] {
|
||||
let r = img.resized(w, h).unwrap();
|
||||
assert_eq!((r.w, r.h), (w, h));
|
||||
assert_eq!(r.rgb.len(), w * h * 3);
|
||||
}
|
||||
let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap();
|
||||
assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999]));
|
||||
assert!(img.resized(0, 5).is_err());
|
||||
let r = img.resized(32, 16).unwrap();
|
||||
assert_eq!(at(&r, 4, 8), RED);
|
||||
assert_eq!(at(&r, 28, 8), BLUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_and_blit_are_clipped() {
|
||||
let img = halves(3, 2);
|
||||
let r = img.rotated_cw().unwrap();
|
||||
assert_eq!((r.w, r.h), (2, 3));
|
||||
let mut p = page();
|
||||
p.blit(&img, 0, 0, 99, 99, 10, 10);
|
||||
assert_eq!(at(&p, 99, 99), at(&img, 0, 0));
|
||||
p.blit(&img, 5, 5, 0, 0, 3, 3);
|
||||
p.fill_rect(95, 95, 50, 50, RED);
|
||||
assert_eq!(at(&p, 99, 99), RED);
|
||||
p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED);
|
||||
}
|
||||
|
||||
fn settings(style: Style) -> PrintSettings {
|
||||
let mut s = PrintSettings::default();
|
||||
s.layout.style = style;
|
||||
s.layout.page.paper = "4x6".into();
|
||||
s.layout.page.margins = Margins::uniform(0.5);
|
||||
s.job.ppi = 100.0;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_follow_the_layout_style() {
|
||||
let ids: Vec<PhotoId> = (1..=5).map(PhotoId).collect();
|
||||
let mut s = settings(Style::ContactSheet);
|
||||
s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() };
|
||||
let plan = plan_cells(&s.layout, &ids).unwrap();
|
||||
assert_eq!(plan.iter().map(Vec::len).collect::<Vec<_>>(), vec![4, 1]);
|
||||
assert_eq!(plan[1][0].1, PhotoId(5));
|
||||
let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap();
|
||||
assert_eq!(one.len(), 5);
|
||||
assert!(plan_cells(&s.layout, &[]).is_err());
|
||||
assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err());
|
||||
|
||||
let mut pkg = settings(Style::PicturePackage);
|
||||
assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet");
|
||||
pkg.layout.add_cell(1.0, 1.0).unwrap();
|
||||
pkg.layout.add_cell(2.0, 2.0).unwrap();
|
||||
let plan = plan_cells(&pkg.layout, &ids).unwrap();
|
||||
assert_eq!(plan.len(), 1);
|
||||
assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo");
|
||||
|
||||
let mut custom = settings(Style::CustomPackage);
|
||||
let a = custom.layout.add_cell(1.0, 1.0).unwrap();
|
||||
custom.layout.add_cell(1.0, 1.0).unwrap();
|
||||
custom.layout.set_cell_photo(a, Some(3)).unwrap();
|
||||
let plan = plan_cells(&custom.layout, &ids).unwrap();
|
||||
assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1)));
|
||||
let blank_page = {
|
||||
custom.layout.new_page().unwrap();
|
||||
plan_cells(&custom.layout, &ids).unwrap()
|
||||
};
|
||||
assert_eq!(blank_page.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 0]);
|
||||
}
|
||||
|
||||
fn first_ids(s: &Session, n: usize) -> Vec<PhotoId> {
|
||||
s.catalog.photos().take(n).map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composes_pages_from_real_photos() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 3);
|
||||
let mut set = settings(Style::ContactSheet);
|
||||
set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false };
|
||||
set.page.background = [200, 30, 30];
|
||||
set.image.zoom_to_fill = true;
|
||||
set.image.stroke_enabled = true;
|
||||
set.image.stroke_width_pt = 2.0;
|
||||
set.image.stroke_color = [0, 0, 0];
|
||||
let work = prepare(&mut s, &set, &ids).unwrap();
|
||||
assert_eq!((work.len(), work.pages()), (3, 2));
|
||||
let mut steps = Vec::new();
|
||||
let pages = work
|
||||
.run(&mut |i, name| {
|
||||
steps.push((i, name.to_string()));
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(steps.iter().map(|s| s.0).collect::<Vec<_>>(), vec![0, 1, 2, 3]);
|
||||
assert_eq!(pages.len(), 2);
|
||||
let first = &pages[0];
|
||||
assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0));
|
||||
assert_eq!(first.rgb.len(), 400 * 600 * 3);
|
||||
let bg = rec2020_from_srgb8([200, 30, 30]);
|
||||
let px = |p: &PageRaster, x: usize, y: usize| {
|
||||
let i = (y * p.width as usize + x) * 3;
|
||||
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||
};
|
||||
assert_eq!(px(first, 5, 5), bg, "margin shows the page background");
|
||||
assert_eq!(px(first, 395, 595), bg);
|
||||
// Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour.
|
||||
assert_eq!(px(first, 50, 50), [0, 0, 0]);
|
||||
let centre = px(first, 200, 150);
|
||||
assert_ne!(centre, bg);
|
||||
assert_ne!(centre, [0, 0, 0]);
|
||||
// The second page holds the third photo in the first cell only: the second cell is bare.
|
||||
let second = &pages[1];
|
||||
assert_ne!(px(second, 200, 150), bg);
|
||||
assert_eq!(px(second, 200, 450), bg);
|
||||
// Cancelling gives no pages.
|
||||
let work = prepare(&mut s, &set, &ids).unwrap();
|
||||
assert!(work.run(&mut |i, _| i < 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_renders_each_size_and_border() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 1);
|
||||
let mut set = settings(Style::PicturePackage);
|
||||
set.layout.add_cell(1.5, 1.0).unwrap();
|
||||
set.layout.add_cell(1.5, 1.0).unwrap();
|
||||
set.image.border_enabled = true;
|
||||
set.image.border_width = 0.1;
|
||||
set.image.border_color = [10, 200, 10];
|
||||
let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap();
|
||||
assert_eq!(pages.len(), 1);
|
||||
let green = rec2020_from_srgb8([10, 200, 10]);
|
||||
let cell = set.layout.cells[0].rect;
|
||||
let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize);
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = (y * 400 + x) * 3;
|
||||
pages[0].rgb[i..i + 3].to_vec()
|
||||
};
|
||||
// The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round.
|
||||
let mid = (x0 + 75, y0 + 50);
|
||||
assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle");
|
||||
let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count();
|
||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_hostile_settings_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 1);
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.layout.page.paper = "custom".into();
|
||||
set.layout.page.custom_width = 60.0;
|
||||
set.layout.page.custom_height = 60.0;
|
||||
set.job.ppi = 720.0;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap");
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.job.ppi = f32::NAN;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.image.border_width = f64::NAN;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
set.image.border_width = 0.1;
|
||||
set.image.stroke_width_pt = -1.0;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err());
|
||||
assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err());
|
||||
let set = settings(Style::SingleImage);
|
||||
assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages");
|
||||
// 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap.
|
||||
let mut big = settings(Style::SingleImage);
|
||||
big.job.ppi = 720.0;
|
||||
assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
//! The Print module: page layout, composition of pages and PDF output.
|
||||
//!
|
||||
//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its
|
||||
//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages
|
||||
//! as a PDF.
|
||||
pub mod compose;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
pub mod template;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lowest and highest print resolution, pixels per inch.
|
||||
pub const MIN_PPI: f32 = 72.0;
|
||||
pub const MAX_PPI: f32 = 720.0;
|
||||
/// Pages a single print may have.
|
||||
pub const MAX_JOB_PAGES: usize = 100;
|
||||
/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60).
|
||||
pub const MAX_PAGE_PIXELS: u64 = 100_000_000;
|
||||
/// Most pixels a print job may hold across all its pages (6 bytes each in memory).
|
||||
pub const MAX_JOB_PIXELS: u64 = 200_000_000;
|
||||
|
||||
/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in
|
||||
/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's).
|
||||
/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageRaster {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub ppi: f32,
|
||||
pub rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour
|
||||
/// management, print adjustment, print sharpening and depth of [`crate::print_color`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PrintJob {
|
||||
/// Print resolution in pixels per inch, 72 to 720.
|
||||
pub ppi: f32,
|
||||
/// Profile, rendering intent, black point compensation, print adjustment, print sharpening.
|
||||
pub color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
|
||||
impl Default for PrintJob {
|
||||
fn default() -> Self {
|
||||
Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintJob {
|
||||
/// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN).
|
||||
pub fn effective_ppi(&self) -> f32 {
|
||||
if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 }
|
||||
}
|
||||
|
||||
/// Rejects a resolution outside the supported range (commands and templates, which should
|
||||
/// say so rather than silently clamp).
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) {
|
||||
return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}"));
|
||||
}
|
||||
self.color.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ppi_is_clamped_and_validated() {
|
||||
assert_eq!(PrintJob::default().effective_ppi(), 240.0);
|
||||
for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] {
|
||||
assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e);
|
||||
}
|
||||
assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok());
|
||||
for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] {
|
||||
assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err());
|
||||
}
|
||||
let j: PrintJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(j, PrintJob::default());
|
||||
assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err());
|
||||
// the colour settings ride along, defaults filled in, and are validated with the job
|
||||
let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap();
|
||||
assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative);
|
||||
assert!(j.color.black_point_compensation && j.validate().is_ok());
|
||||
let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap();
|
||||
assert!(j.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target
|
||||
//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its
|
||||
//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded
|
||||
//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer
|
||||
//! profile is also named as the document's output intent ([`crate::print_pdf`]).
|
||||
use super::{MAX_JOB_PAGES, PageRaster, PrintJob};
|
||||
use crate::print_color::PrintColorJob;
|
||||
use crate::print_pdf::ImageData;
|
||||
use lightcraft_color::transfer::encode_srgb8;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_pipeline::OutputSpace;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
/// Largest PDF produced, bytes.
|
||||
const MAX_PDF_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped).
|
||||
pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> {
|
||||
let (w, h) = (page.width as usize, page.height as usize);
|
||||
let n = w.checked_mul(h).ok_or("page too large")?;
|
||||
if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? {
|
||||
return Err("a page's pixel data does not match its size".into());
|
||||
}
|
||||
let trc = OutputSpace::Rec2020.trc();
|
||||
// 16-bit Rec. 2020 code → linear light, once per code value.
|
||||
let lut: Vec<f32> = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect();
|
||||
let m = REC2020.to_space(&SRGB).to_f32();
|
||||
let mut out = Rgba8::new(w, h);
|
||||
for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) {
|
||||
let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0));
|
||||
let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]);
|
||||
*dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255];
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in
|
||||
/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed
|
||||
/// in turn, so memory holds one page's device samples at a time.
|
||||
pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> {
|
||||
let first = pages.first().ok_or("nothing to print")?;
|
||||
if pages.len() > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
|
||||
}
|
||||
if !(first.ppi.is_finite() && first.ppi > 0.0) {
|
||||
return Err("a page has no resolution".into());
|
||||
}
|
||||
let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0;
|
||||
let size = (points(first.width), points(first.height));
|
||||
let color = PrintColorJob::new(&job.color)?;
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
let mut bytes = 0usize;
|
||||
for page in pages {
|
||||
if (page.width, page.height) != (first.width, first.height) {
|
||||
return Err("all pages of a PDF must be the same size".into());
|
||||
}
|
||||
let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?;
|
||||
if let ImageData::Flate { data, .. } = &image.data {
|
||||
bytes = bytes.saturating_add(data.len());
|
||||
}
|
||||
if bytes > MAX_PDF_BYTES {
|
||||
return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into());
|
||||
}
|
||||
out.push((size, image));
|
||||
}
|
||||
color.pdf(out, title)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::compose::rec2020_from_srgb8;
|
||||
|
||||
fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster {
|
||||
let c = rec2020_from_srgb8(srgb);
|
||||
PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_survive_the_round_trip() {
|
||||
for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] {
|
||||
let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0];
|
||||
for k in 0..3 {
|
||||
assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}");
|
||||
}
|
||||
}
|
||||
assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err());
|
||||
assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_pages_have_the_paper_size() {
|
||||
let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])];
|
||||
let bytes = write(&pages, &PrintJob::default(), "t").unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.starts_with("%PDF-"));
|
||||
assert!(text.contains("/Count 2"));
|
||||
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt");
|
||||
assert!(text.contains("/Width 425 /Height 550"));
|
||||
// managed by the printer: sRGB, 16 bits, tagged, no output intent
|
||||
assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents"));
|
||||
assert!(write(&[], &PrintJob::default(), "t").is_err());
|
||||
let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])];
|
||||
assert!(write(&mixed, &PrintJob::default(), "t").is_err());
|
||||
let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) };
|
||||
assert!(write(&[bad], &PrintJob::default(), "t").is_err());
|
||||
let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) };
|
||||
assert!(write(&[zero], &PrintJob::default(), "t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() {
|
||||
use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile};
|
||||
let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let icc = dir.join("Lustre Paper.icc");
|
||||
std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap();
|
||||
let color =
|
||||
PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
|
||||
let job = PrintJob { color, ..Default::default() };
|
||||
let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]);
|
||||
assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)"));
|
||||
// a profile that isn't one fails the print and says which file
|
||||
std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap();
|
||||
let junk = PrintJob {
|
||||
color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err();
|
||||
assert!(err.contains("Junk"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! The settings a print is made from, and (with the saved-template commands) what a print
|
||||
//! template stores.
|
||||
use super::PrintJob;
|
||||
use super::compose::{ImageSettings, PageStyle};
|
||||
use super::layout::{Guides, Layout};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Layout, image settings, page, guides and job: the whole Print module state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PrintSettings {
|
||||
pub layout: Layout,
|
||||
pub image: ImageSettings,
|
||||
pub page: PageStyle,
|
||||
pub guides: Guides,
|
||||
pub job: PrintJob,
|
||||
}
|
||||
|
||||
impl PrintSettings {
|
||||
/// Checks every part; run on anything that did not come from the setters.
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
self.layout.validate()?;
|
||||
self.image.validate()?;
|
||||
self.guides.validate()?;
|
||||
self.job.validate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest template name, characters.
|
||||
pub const MAX_NAME: usize = 80;
|
||||
/// Most templates kept.
|
||||
pub const MAX_TEMPLATES: usize = 200;
|
||||
|
||||
/// A saved print template: the layout, image settings, page and job of a print, without the
|
||||
/// photos assigned to cells and without the guides (a view preference). The settings are kept as
|
||||
/// JSON so a template written by another version never makes the preferences file unreadable;
|
||||
/// [`PrintTemplate::settings`] checks them when they are used.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrintTemplate {
|
||||
pub name: String,
|
||||
pub settings: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters.
|
||||
pub fn clean_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) {
|
||||
return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters"));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
impl PrintTemplate {
|
||||
/// A template of `settings` (validated first) named `name`.
|
||||
pub fn new(name: &str, settings: &PrintSettings) -> Result<Self, String> {
|
||||
let mut s = settings.clone();
|
||||
s.validate()?;
|
||||
for c in &mut s.layout.cells {
|
||||
c.photo = None;
|
||||
}
|
||||
let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?;
|
||||
if let Some(o) = value.as_object_mut() {
|
||||
o.remove("guides");
|
||||
}
|
||||
Ok(Self { name: clean_name(name)?, settings: value })
|
||||
}
|
||||
|
||||
/// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors.
|
||||
/// `guides` are those of the print the template is applied to.
|
||||
pub fn settings(&self, guides: &Guides) -> Result<PrintSettings, String> {
|
||||
let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?;
|
||||
s.guides = *guides;
|
||||
s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::layout::Style;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_template_keeps_the_look_not_the_photos_or_guides() {
|
||||
let mut s = PrintSettings::default();
|
||||
s.layout.style = Style::CustomPackage;
|
||||
let id = s.layout.add_cell(3.0, 2.0).unwrap();
|
||||
s.layout.set_cell_photo(id, Some(77)).unwrap();
|
||||
s.image.zoom_to_fill = true;
|
||||
s.page.background = [10, 20, 30];
|
||||
s.job.ppi = 300.0;
|
||||
s.guides.grid_size = 1.0;
|
||||
let t = PrintTemplate::new(" Proof sheet ", &s).unwrap();
|
||||
assert_eq!(t.name, "Proof sheet");
|
||||
assert!(t.settings.get("guides").is_none());
|
||||
let back = t.settings(&Guides::default()).unwrap();
|
||||
assert_eq!(back.layout.cells.len(), 1);
|
||||
assert_eq!(back.layout.cells[0].photo, None);
|
||||
assert!(back.image.zoom_to_fill);
|
||||
assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0));
|
||||
assert_eq!(back.guides, Guides::default(), "the guides are the caller's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_templates_are_rejected() {
|
||||
for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] {
|
||||
assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}");
|
||||
}
|
||||
let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err();
|
||||
assert!(bad(json!("text")));
|
||||
assert!(bad(json!({"surprise": 1})));
|
||||
assert!(bad(json!({"job": {"ppi": 1e30}})));
|
||||
assert!(bad(json!({"layout": {"pages": 0}})));
|
||||
assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}})));
|
||||
assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}})));
|
||||
assert!(bad(json!({"image": {"borderWidth": -1}})));
|
||||
assert!(!bad(json!({})));
|
||||
let mut s = PrintSettings::default();
|
||||
s.job.ppi = f32::NAN;
|
||||
assert!(PrintTemplate::new("t", &s).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! The print job's colour: which profile composed pages are converted to and how, print sharpening
|
||||
//! for the paper, and the print-ready PDF they end up in.
|
||||
//!
|
||||
//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile
|
||||
//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's
|
||||
//! own values here and names the profile as the document's output intent, so the driver must not
|
||||
//! convert again.
|
||||
//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`].
|
||||
//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened
|
||||
//! at its print size before it goes on the page, with the export sharpener.
|
||||
//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits
|
||||
//! for smaller files.
|
||||
//!
|
||||
//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
|
||||
use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform};
|
||||
use lightcraft_codecs::{NamedSpace, proof::ProofIntent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep};
|
||||
use crate::print_pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement};
|
||||
|
||||
/// The colour space a printer that manages colour receives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ManagedSpace {
|
||||
#[default]
|
||||
Srgb,
|
||||
AdobeRgb,
|
||||
}
|
||||
|
||||
/// Where the print's colours go.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum PrintProfile {
|
||||
/// Managed by Printer: the page goes in this space and the driver converts it.
|
||||
Managed { space: ManagedSpace },
|
||||
/// A printer / paper profile (an `.icc` / `.icm` file): converted here.
|
||||
Icc { path: PathBuf },
|
||||
}
|
||||
|
||||
impl Default for PrintProfile {
|
||||
fn default() -> Self {
|
||||
PrintProfile::Managed { space: ManagedSpace::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintIntent {
|
||||
#[default]
|
||||
Perceptual,
|
||||
Relative,
|
||||
}
|
||||
|
||||
/// Print sharpening strength (Off leaves the photos as rendered).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintSharpen {
|
||||
Off,
|
||||
Low,
|
||||
#[default]
|
||||
Standard,
|
||||
High,
|
||||
}
|
||||
|
||||
/// The paper print sharpening is tuned for.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintMedia {
|
||||
Matte,
|
||||
#[default]
|
||||
Glossy,
|
||||
}
|
||||
|
||||
/// The print job's colour settings, as saved with print templates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrintColorSettings {
|
||||
pub profile: PrintProfile,
|
||||
pub intent: PrintIntent,
|
||||
/// Map the page's black to the paper's darkest (relative colorimetric).
|
||||
pub black_point_compensation: bool,
|
||||
/// Print adjustment, −100..100.
|
||||
pub brightness: f32,
|
||||
/// Print adjustment, −100..100.
|
||||
pub contrast: f32,
|
||||
pub sharpen: PrintSharpen,
|
||||
pub media: PrintMedia,
|
||||
/// 16 bits per component in the PDF (else 8).
|
||||
pub sixteen_bit: bool,
|
||||
}
|
||||
|
||||
impl Default for PrintColorSettings {
|
||||
fn default() -> Self {
|
||||
PrintColorSettings {
|
||||
profile: PrintProfile::default(),
|
||||
intent: PrintIntent::default(),
|
||||
black_point_compensation: true,
|
||||
brightness: 0.0,
|
||||
contrast: 0.0,
|
||||
sharpen: PrintSharpen::default(),
|
||||
media: PrintMedia::default(),
|
||||
sixteen_bit: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintColorSettings {
|
||||
/// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty
|
||||
/// profile path); whether the profile file is usable is checked when the job is built.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] {
|
||||
if !(v.is_finite() && (-100.0..=100.0).contains(&v)) {
|
||||
return Err(format!("{name} must be a number from -100 to 100"));
|
||||
}
|
||||
}
|
||||
if let PrintProfile::Icc { path } = &self.profile
|
||||
&& path.as_os_str().is_empty()
|
||||
{
|
||||
return Err("the printer profile needs a path".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sharpen one photo for the paper, at the size it prints (before it goes on the page).
|
||||
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
|
||||
let amount = match self.sharpen {
|
||||
PrintSharpen::Off => return,
|
||||
PrintSharpen::Low => SharpenAmount::Low,
|
||||
PrintSharpen::Standard => SharpenAmount::Standard,
|
||||
PrintSharpen::High => SharpenAmount::High,
|
||||
};
|
||||
let target = match self.media {
|
||||
PrintMedia::Matte => SharpenFor::Matte,
|
||||
PrintMedia::Glossy => SharpenFor::Glossy,
|
||||
};
|
||||
output_sharpen_deep(photo, target, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a printer profile, refusing files that can't be one (the parse happens later).
|
||||
fn read_profile(path: &Path) -> Result<Vec<u8>, String> {
|
||||
let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
|
||||
let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len();
|
||||
if len > MAX_PROFILE_BYTES as u64 {
|
||||
return Err(format!("the printer profile {shown} is too large"));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))
|
||||
}
|
||||
|
||||
/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know.
|
||||
#[derive(Debug)]
|
||||
pub struct PrintColorJob {
|
||||
pub settings: PrintColorSettings,
|
||||
transform: PrintTransform,
|
||||
/// The printing condition named in the PDF (only for a printer profile).
|
||||
intent: Option<OutputIntent>,
|
||||
}
|
||||
|
||||
impl PrintColorJob {
|
||||
/// Build the job's conversion. Errors name the profile and what's wrong with it.
|
||||
pub fn new(settings: &PrintColorSettings) -> Result<PrintColorJob, String> {
|
||||
let s = settings.clone();
|
||||
let color = PrintColor {
|
||||
intent: match s.intent {
|
||||
PrintIntent::Perceptual => ProofIntent::Perceptual,
|
||||
PrintIntent::Relative => ProofIntent::Relative,
|
||||
},
|
||||
black_point_compensation: s.black_point_compensation,
|
||||
brightness: s.brightness,
|
||||
contrast: s.contrast,
|
||||
};
|
||||
let (target, name) = match &s.profile {
|
||||
PrintProfile::Managed { space } => {
|
||||
(PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None)
|
||||
}
|
||||
PrintProfile::Icc { path } => {
|
||||
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
(PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name))
|
||||
}
|
||||
};
|
||||
let transform = PrintTransform::new(&target, &color).map_err(|e| match &name {
|
||||
Some(n) => format!("the printer profile {n}: {e}"),
|
||||
None => e,
|
||||
})?;
|
||||
let intent = name.map(|file| OutputIntent {
|
||||
icc: Arc::clone(transform.icc()),
|
||||
channels: transform.channels() as u8,
|
||||
condition: transform.description().map(str::to_owned).unwrap_or(file),
|
||||
});
|
||||
Ok(PrintColorJob { settings: s, transform, intent })
|
||||
}
|
||||
|
||||
/// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]).
|
||||
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
|
||||
self.settings.sharpen_photo(photo);
|
||||
}
|
||||
|
||||
/// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers
|
||||
/// it, in the target's colours.
|
||||
pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result<PdfImage, String> {
|
||||
let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3));
|
||||
if width == 0 || height == 0 || want != Some(rgb.len() as u64) {
|
||||
return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len()));
|
||||
}
|
||||
let device = self.transform.apply(rgb)?;
|
||||
let channels = self.transform.channels() as u8;
|
||||
let icc = Some(Arc::clone(self.transform.icc()));
|
||||
Ok(if self.settings.sixteen_bit {
|
||||
PdfImage::from_u16(width, height, channels, &device, icc)
|
||||
} else {
|
||||
let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect();
|
||||
PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc }
|
||||
})
|
||||
}
|
||||
|
||||
/// The print-ready document: each page image covers its page (`size` in points).
|
||||
pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result<Vec<u8>, String> {
|
||||
let pages: Vec<PdfPage> = pages
|
||||
.into_iter()
|
||||
.map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] })
|
||||
.collect();
|
||||
let title = (!title.trim().is_empty()).then(|| title.to_owned());
|
||||
crate::print_pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() })
|
||||
}
|
||||
|
||||
/// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey).
|
||||
pub fn channels(&self) -> usize {
|
||||
self.transform.channels()
|
||||
}
|
||||
|
||||
/// The profile's description (or file name) for the job's summary.
|
||||
pub fn profile_name(&self) -> Option<&str> {
|
||||
self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description())
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo rendered for the page as the layout gets it from the export renderer.
|
||||
pub fn is_print_ready(photo: &DeepImage) -> bool {
|
||||
photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::print::print_adjust;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A small, flat page of one 16-bit colour.
|
||||
fn page(w: u32, h: u32, v: [u16; 3]) -> Vec<u16> {
|
||||
(0..w * h).flat_map(|_| v).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_and_round_trip() {
|
||||
let d = PrintColorSettings::default();
|
||||
assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb });
|
||||
assert!(d.black_point_compensation && d.sixteen_bit);
|
||||
let s = PrintColorSettings {
|
||||
profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() },
|
||||
intent: PrintIntent::Relative,
|
||||
brightness: 12.0,
|
||||
sharpen: PrintSharpen::High,
|
||||
media: PrintMedia::Matte,
|
||||
..d.clone()
|
||||
};
|
||||
let json = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(json["profile"]["kind"], "icc");
|
||||
assert_eq!(json["intent"], "relative");
|
||||
assert_eq!(serde_json::from_value::<PrintColorSettings>(json).unwrap(), s);
|
||||
// partial and unknown-free JSON fills in the defaults
|
||||
let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap();
|
||||
assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d });
|
||||
assert!(serde_json::from_str::<PrintColorSettings>(r#"{"profile":{"kind":"cmyk"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() {
|
||||
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
|
||||
let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap();
|
||||
assert_eq!((img.channels, img.width, img.height), (3, 4, 2));
|
||||
let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") };
|
||||
assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white");
|
||||
let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]"));
|
||||
assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent");
|
||||
assert!(s.contains("/Title (Prints)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_profile_converts_and_becomes_the_output_intent() {
|
||||
let dir = scratch("icc");
|
||||
let path = dir.join("Studio Glossy.icc");
|
||||
std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
|
||||
let settings =
|
||||
PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
|
||||
let job = PrintColorJob::new(&settings).unwrap();
|
||||
assert_eq!(job.channels(), 3);
|
||||
let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap();
|
||||
assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. }));
|
||||
let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]);
|
||||
assert!(!s.contains("/Title"), "an empty title is left out");
|
||||
assert!(job.profile_name().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_errors_name_the_file() {
|
||||
let dir = scratch("bad");
|
||||
let garbage = dir.join("Broken Paper.icc");
|
||||
std::fs::write(&garbage, [0u8; 300]).unwrap();
|
||||
let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err();
|
||||
assert!(err.contains("Broken Paper"), "{err}");
|
||||
let missing =
|
||||
PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err();
|
||||
assert!(missing.contains("nope.icc"), "{missing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_adjustment_reaches_the_page() {
|
||||
let mid = [32768u16; 3];
|
||||
let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap();
|
||||
let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap();
|
||||
let first = |i: &PdfImage| match &i.data {
|
||||
ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]),
|
||||
ImageData::Jpeg(_) | ImageData::Flate { .. } => 0,
|
||||
};
|
||||
assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain));
|
||||
assert!(print_adjust(0.5, 60.0, 0.0) > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_sharpening_follows_the_setting() {
|
||||
// a hard vertical edge in a 16-bit Rec2020 photo
|
||||
let (w, h) = (8usize, 4usize);
|
||||
let samples: Vec<u16> = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect();
|
||||
let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) };
|
||||
assert!(is_print_ready(&photo));
|
||||
let sharpened = |s: PrintSharpen| {
|
||||
let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap();
|
||||
let mut p = photo.clone();
|
||||
job.sharpen_photo(&mut p);
|
||||
p
|
||||
};
|
||||
assert_eq!(sharpened(PrintSharpen::Off), photo);
|
||||
let edge = |p: &DeepImage| match &p.samples {
|
||||
DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]),
|
||||
DeepSamples::F32(_) => (0, 0),
|
||||
};
|
||||
let (dark, light) = edge(&sharpened(PrintSharpen::High));
|
||||
assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_refuses_out_of_range_settings() {
|
||||
assert!(PrintColorSettings::default().validate().is_ok());
|
||||
for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] {
|
||||
assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}");
|
||||
}
|
||||
assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err());
|
||||
assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_pages_are_errors() {
|
||||
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
|
||||
assert!(job.page_image(0, 1, &[]).is_err());
|
||||
assert!(job.page_image(2, 2, &[0; 11]).is_err());
|
||||
assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err());
|
||||
let img = job.page_image(1, 1, &[0; 3]).unwrap();
|
||||
assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err());
|
||||
assert!(job.pdf(vec![], "x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
//! A small PDF writer for print output: pages of a given size in points, each with raster images
|
||||
//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in
|
||||
//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as
|
||||
//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent:
|
||||
//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers
|
||||
//! expect it in the catalog. Profiles shared by several images are written once.
|
||||
//!
|
||||
//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams,
|
||||
//! deterministic (no dates or IDs), so the same pages always give the same bytes.
|
||||
//!
|
||||
//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces,
|
||||
//! §8.9 images, §14.11.5 output intents). Own design.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Pages per document: a print job beyond this is a mistake, not a book.
|
||||
pub const MAX_PAGES: usize = 1000;
|
||||
/// Images per page.
|
||||
pub const MAX_IMAGES_PER_PAGE: usize = 256;
|
||||
/// Largest page side in points (200 inches): more than any printer's roll.
|
||||
pub const MAX_PAGE_POINTS: f64 = 14_400.0;
|
||||
/// Largest image, in pixels per side.
|
||||
pub const MAX_IMAGE_SIDE: u32 = 65_535;
|
||||
|
||||
/// How an image's samples are stored.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImageData {
|
||||
/// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed
|
||||
/// losslessly in the file.
|
||||
Raw { bits: u8, samples: Vec<u8> },
|
||||
/// A baseline JPEG stream, copied into the file as it is.
|
||||
Jpeg(Vec<u8>),
|
||||
/// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component.
|
||||
Flate { bits: u8, data: Vec<u8> },
|
||||
}
|
||||
|
||||
/// A raster image and the colour space its samples are in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK).
|
||||
pub channels: u8,
|
||||
pub data: ImageData,
|
||||
/// The ICC profile the samples are in; without one the image is device colour.
|
||||
pub icc: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl PdfImage {
|
||||
/// An image from interleaved 16-bit samples.
|
||||
pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option<Arc<Vec<u8>>>) -> PdfImage {
|
||||
let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect();
|
||||
PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc }
|
||||
}
|
||||
|
||||
/// The same image with its samples compressed now (checked first), so a long print doesn't
|
||||
/// hold every page's uncompressed samples until the file is written.
|
||||
pub fn deflated(self) -> Result<PdfImage, String> {
|
||||
self.check()?;
|
||||
let data = match self.data {
|
||||
ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) },
|
||||
other => other,
|
||||
};
|
||||
Ok(PdfImage { data, ..self })
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), String> {
|
||||
if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE {
|
||||
return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height));
|
||||
}
|
||||
if !matches!(self.channels, 1 | 3 | 4) {
|
||||
return Err(format!("images have 1, 3 or 4 components, not {}", self.channels));
|
||||
}
|
||||
match &self.data {
|
||||
ImageData::Raw { bits, samples } => {
|
||||
if !matches!(bits, 8 | 16) {
|
||||
return Err(format!("images have 8 or 16 bits per component, not {bits}"));
|
||||
}
|
||||
let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8);
|
||||
if samples.len() as u64 != want {
|
||||
return Err(format!("the image needs {want} bytes of samples, not {}", samples.len()));
|
||||
}
|
||||
}
|
||||
ImageData::Jpeg(data) => {
|
||||
if !data.starts_with(&[0xFF, 0xD8]) {
|
||||
return Err("the image isn't a JPEG stream".into());
|
||||
}
|
||||
}
|
||||
ImageData::Flate { bits, data } => {
|
||||
if !matches!(bits, 8 | 16) || data.is_empty() {
|
||||
return Err("a compressed image has 8 or 16 bits per component and some data".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an image goes on its page, in points from the page's bottom-left corner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// One page: its size in points and the images on it, drawn in order (later ones on top).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfPage {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub images: Vec<(PdfImage, Placement)>,
|
||||
}
|
||||
|
||||
/// The printing condition the document is made for (PDF/X-style output intent).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OutputIntent {
|
||||
/// The printer / paper profile.
|
||||
pub icc: Arc<Vec<u8>>,
|
||||
/// Its components: 1, 3 or 4.
|
||||
pub channels: u8,
|
||||
/// A name for the condition (the profile's description).
|
||||
pub condition: String,
|
||||
}
|
||||
|
||||
/// Document-level settings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PdfInfo {
|
||||
pub title: Option<String>,
|
||||
pub output_intent: Option<OutputIntent>,
|
||||
}
|
||||
|
||||
fn finite(v: f64) -> bool {
|
||||
v.is_finite()
|
||||
}
|
||||
|
||||
/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros.
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.4}", if finite(v) { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark.
|
||||
fn text(s: &str) -> String {
|
||||
if s.bytes().all(|b| (0x20..0x7f).contains(&b)) {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('(');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
} else {
|
||||
let mut out = String::from("<FEFF");
|
||||
for unit in s.encode_utf16() {
|
||||
out.push_str(&format!("{unit:04X}"));
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
out: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Reserve an object number (filled later by [`Writer::object`]).
|
||||
fn reserve(&mut self) -> usize {
|
||||
self.offsets.push(0);
|
||||
self.offsets.len()
|
||||
}
|
||||
|
||||
fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) {
|
||||
if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) {
|
||||
*slot = self.out.len();
|
||||
}
|
||||
self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes());
|
||||
if let Some(data) = stream {
|
||||
self.out.extend_from_slice(b"\nstream\n");
|
||||
self.out.extend_from_slice(data);
|
||||
self.out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
self.out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Vec<u8> {
|
||||
miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
|
||||
}
|
||||
|
||||
/// Write a document. Errors name the first page or image that can't be written.
|
||||
pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> {
|
||||
if pages.is_empty() {
|
||||
return Err("there are no pages to write".into());
|
||||
}
|
||||
if pages.len() > MAX_PAGES {
|
||||
return Err(format!("a document holds at most {MAX_PAGES} pages"));
|
||||
}
|
||||
for (n, page) in pages.iter().enumerate() {
|
||||
let n = n + 1;
|
||||
if !(finite(page.width) && finite(page.height))
|
||||
|| page.width <= 0.0
|
||||
|| page.height <= 0.0
|
||||
|| page.width > MAX_PAGE_POINTS
|
||||
|| page.height > MAX_PAGE_POINTS
|
||||
{
|
||||
return Err(format!("page {n} has no usable size"));
|
||||
}
|
||||
if page.images.len() > MAX_IMAGES_PER_PAGE {
|
||||
return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images"));
|
||||
}
|
||||
for (image, at) in &page.images {
|
||||
image.check().map_err(|e| format!("page {n}: {e}"))?;
|
||||
if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 {
|
||||
return Err(format!("page {n}: an image has no usable placement"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(intent) = &info.output_intent
|
||||
&& !matches!(intent.channels, 1 | 3 | 4)
|
||||
{
|
||||
return Err("the output intent's profile has 1, 3 or 4 components".into());
|
||||
}
|
||||
|
||||
let mut w = Writer { out: Vec::new(), offsets: Vec::new() };
|
||||
w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n");
|
||||
let catalog = w.reserve();
|
||||
let pages_id = w.reserve();
|
||||
let info_id = w.reserve();
|
||||
|
||||
// ICC profiles, each written once (keyed by content) with its component count
|
||||
let mut profiles: HashMap<Vec<u8>, usize> = HashMap::new();
|
||||
let mut profile = |w: &mut Writer, icc: &Arc<Vec<u8>>, n: u8| -> usize {
|
||||
if let Some(&id) = profiles.get(icc.as_slice()) {
|
||||
return id;
|
||||
}
|
||||
let id = w.reserve();
|
||||
let data = deflate(icc);
|
||||
let alternate = match n {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data));
|
||||
profiles.insert(icc.to_vec(), id);
|
||||
id
|
||||
};
|
||||
|
||||
let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels));
|
||||
|
||||
let mut kids = Vec::with_capacity(pages.len());
|
||||
for page in pages {
|
||||
let page_id = w.reserve();
|
||||
let mut xobjects = String::new();
|
||||
let mut content = String::new();
|
||||
for (k, (image, at)) in page.images.iter().enumerate() {
|
||||
let space = match &image.icc {
|
||||
Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)),
|
||||
None => match image.channels {
|
||||
1 => "/DeviceGray".into(),
|
||||
4 => "/DeviceCMYK".into(),
|
||||
_ => "/DeviceRGB".into(),
|
||||
},
|
||||
};
|
||||
let id = w.reserve();
|
||||
let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data {
|
||||
ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()),
|
||||
ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()),
|
||||
ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()),
|
||||
};
|
||||
w.object(
|
||||
id,
|
||||
&format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>",
|
||||
image.width,
|
||||
image.height,
|
||||
data.len()
|
||||
),
|
||||
Some(&data),
|
||||
);
|
||||
xobjects.push_str(&format!("/Im{k} {id} 0 R "));
|
||||
content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y)));
|
||||
}
|
||||
let content_id = w.reserve();
|
||||
let stream = deflate(content.as_bytes());
|
||||
w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream));
|
||||
w.object(
|
||||
page_id,
|
||||
&format!(
|
||||
"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>",
|
||||
num(page.width),
|
||||
num(page.height)
|
||||
),
|
||||
None,
|
||||
);
|
||||
kids.push(format!("{page_id} 0 R"));
|
||||
}
|
||||
w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None);
|
||||
|
||||
let intents = match (&info.output_intent, intent_id) {
|
||||
(Some(intent), Some(id)) => format!(
|
||||
" /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]",
|
||||
text(&intent.condition)
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None);
|
||||
let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default();
|
||||
w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None);
|
||||
|
||||
let xref = w.out.len();
|
||||
let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1);
|
||||
for offset in &w.offsets {
|
||||
table.push_str(&format!("{offset:010} 00000 n \n"));
|
||||
}
|
||||
table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1));
|
||||
w.out.extend_from_slice(table.as_bytes());
|
||||
Ok(w.out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::NamedSpace;
|
||||
|
||||
/// The body of object `id` (dictionary and raw stream bytes), found through the xref table.
|
||||
fn object(pdf: &[u8], id: usize) -> (String, Vec<u8>) {
|
||||
// byte offsets: the streams are binary, so search the bytes, not a lossy string
|
||||
let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
|
||||
let tail = std::str::from_utf8(&pdf[start..]).unwrap();
|
||||
let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap();
|
||||
let table = std::str::from_utf8(&pdf[xref..]).unwrap();
|
||||
let line = table.lines().nth(2 + id).unwrap();
|
||||
let offset: usize = line[..10].parse().unwrap();
|
||||
let head = format!("{id} 0 obj\n");
|
||||
assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}");
|
||||
let rest = &pdf[offset + head.len()..];
|
||||
let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap();
|
||||
let body = &rest[..end];
|
||||
match body.windows(8).position(|w| w == b"\nstream\n") {
|
||||
Some(p) => {
|
||||
let data = &body[p + 8..body.len() - "\nendstream".len()];
|
||||
(String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec())
|
||||
}
|
||||
None => (String::from_utf8_lossy(body).into_owned(), vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn inflate(data: &[u8]) -> Vec<u8> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()
|
||||
}
|
||||
|
||||
/// Object ids of every image XObject, in file order.
|
||||
fn images(pdf: &[u8]) -> Vec<usize> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn reference(dict: &str, key: &str) -> usize {
|
||||
let at = dict.find(key).unwrap() + key.len();
|
||||
dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
fn srgb() -> Arc<Vec<u8>> {
|
||||
Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb))
|
||||
}
|
||||
|
||||
fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage {
|
||||
PdfPage { width: 612.0, height: 792.0, images }
|
||||
}
|
||||
|
||||
fn full(w: f64, h: f64) -> Placement {
|
||||
Placement { x: 0.0, y: 0.0, width: w, height: h }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_16_bit_icc_image_round_trips() {
|
||||
let samples: Vec<u16> = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect();
|
||||
let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb()));
|
||||
let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-1.6\n"));
|
||||
assert!(pdf.ends_with(b"%%EOF\n"));
|
||||
let ids = images(&pdf);
|
||||
assert_eq!(ids.len(), 1);
|
||||
let (dict, data) = object(&pdf, ids[0]);
|
||||
assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}");
|
||||
let back: Vec<u16> = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect();
|
||||
assert_eq!(back, samples);
|
||||
let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased"));
|
||||
assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}");
|
||||
assert_eq!(inflate(&icc), *srgb());
|
||||
assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_sizes_placements_and_a_shared_profile() {
|
||||
let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb()));
|
||||
let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 };
|
||||
let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }];
|
||||
let pdf = write(&pages, &PdfInfo::default()).unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/Type /Pages /Count 2"), "two pages");
|
||||
assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]"));
|
||||
// the sRGB profile is written once for all three images
|
||||
assert_eq!(s.matches("/N 3 /Alternate").count(), 1);
|
||||
let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect();
|
||||
assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}");
|
||||
// placements reach the content streams
|
||||
let contents: Vec<String> = s
|
||||
.match_indices("/Contents ")
|
||||
.map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::<usize>().unwrap())
|
||||
.map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap())
|
||||
.collect();
|
||||
assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n");
|
||||
assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_intent_and_title() {
|
||||
let cmyk_like = srgb();
|
||||
let info = PdfInfo {
|
||||
title: Some("Garden – prints (2)".into()),
|
||||
output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }),
|
||||
};
|
||||
let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like));
|
||||
let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap();
|
||||
let (catalog, _) = object(&pdf, 1);
|
||||
assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}");
|
||||
assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}");
|
||||
// the intent and the image share one profile object
|
||||
let dest = reference(&catalog, "/DestOutputProfile");
|
||||
assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest);
|
||||
let (info_dict, _) = object(&pdf, 3);
|
||||
assert!(info_dict.contains("/Title <FEFF"), "non-ASCII titles are UTF-16: {info_dict}");
|
||||
assert!(info_dict.contains("2013"), "the en dash: {info_dict}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_grey_jpeg_and_device_images() {
|
||||
let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9];
|
||||
let imgs = vec![
|
||||
(
|
||||
PdfImage { width: 1, height: 1, channels: 4, data: ImageData::Raw { bits: 8, samples: vec![0, 10, 20, 30] }, icc: None },
|
||||
full(1.0, 1.0),
|
||||
),
|
||||
(PdfImage { width: 2, height: 1, channels: 1, data: ImageData::Raw { bits: 8, samples: vec![0, 255] }, icc: None }, full(1.0, 1.0)),
|
||||
(PdfImage { width: 4, height: 4, channels: 3, data: ImageData::Jpeg(jpeg.clone()), icc: None }, full(1.0, 1.0)),
|
||||
];
|
||||
let pdf = write(&[page(imgs)], &PdfInfo::default()).unwrap();
|
||||
let ids = images(&pdf);
|
||||
let dicts: Vec<_> = ids.iter().map(|&id| object(&pdf, id)).collect();
|
||||
assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8"));
|
||||
assert!(dicts[1].0.contains("/ColorSpace /DeviceGray"));
|
||||
assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB"));
|
||||
assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressing_early_writes_the_same_file() {
|
||||
let samples: Vec<u16> = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect();
|
||||
let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb()));
|
||||
let early = raw.clone().deflated().unwrap();
|
||||
assert!(matches!(early.data, ImageData::Flate { bits: 16, .. }));
|
||||
let at = full(100.0, 75.0);
|
||||
let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap();
|
||||
let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
let mut short = PdfImage::from_u16(4, 3, 3, &samples, None);
|
||||
short.width = 5;
|
||||
assert!(short.deflated().is_err(), "checked before compressing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_documents_are_errors() {
|
||||
let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None);
|
||||
let at = full(10.0, 10.0);
|
||||
let bad = |pages: Vec<PdfPage>| write(&pages, &PdfInfo::default()).is_err();
|
||||
assert!(bad(vec![]));
|
||||
assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])]));
|
||||
assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])]));
|
||||
let mut short = ok();
|
||||
short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] };
|
||||
assert!(bad(vec![page(vec![(short, at)])]));
|
||||
let mut empty = ok();
|
||||
empty.width = 0;
|
||||
assert!(bad(vec![page(vec![(empty, at)])]));
|
||||
let mut odd = ok();
|
||||
odd.channels = 2;
|
||||
assert!(bad(vec![page(vec![(odd, at)])]));
|
||||
let mut not_jpeg = ok();
|
||||
not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]);
|
||||
assert!(bad(vec![page(vec![(not_jpeg, at)])]));
|
||||
let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect();
|
||||
assert!(bad(too_many));
|
||||
let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) };
|
||||
assert!(write(&[page(vec![])], &info).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_strings() {
|
||||
assert_eq!(num(612.0), "612");
|
||||
assert_eq!(num(72.5), "72.5");
|
||||
assert_eq!(num(-0.00001), "0");
|
||||
assert_eq!(num(f64::NAN), "0");
|
||||
assert_eq!(num(1.0 / 3.0), "0.3333");
|
||||
assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)");
|
||||
assert_eq!(text("é"), "<FEFF00E9>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Profiles named by imported presets (issue #1001).
|
||||
//!
|
||||
//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group,
|
||||
//! UUID, amount), and many presets get most of their look from that profile. On import the
|
||||
//! reference is kept in the preset's settings as `profile.look`, and resolved against the
|
||||
//! profiles the user has imported (by name, the group breaking ties), so the preset applies the
|
||||
//! profile like the other app does. The six basic profiles of the other app map to our own basic
|
||||
//! profiles; its other built-in profiles can't be used here and the import says so.
|
||||
//!
|
||||
//! References are resolved again whenever profiles are imported or deleted, so a pack works in
|
||||
//! either order. Only the user's own files are read; no profile content ships with LightCraft.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::lut_profiles::LutProfile;
|
||||
use crate::crs::Props;
|
||||
|
||||
/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`):
|
||||
/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]).
|
||||
pub fn look_ref(props: &Props) -> Option<Value> {
|
||||
let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
let name = field("Name")?;
|
||||
// stored as a fraction (1 = 100 %), 0–2
|
||||
let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0));
|
||||
let mut look = json!({"name": name, "amount": amount});
|
||||
if let Some(g) = field("Group") {
|
||||
look["group"] = json!(g);
|
||||
}
|
||||
if let Some(u) = field("UUID") {
|
||||
look["uuid"] = json!(u.to_ascii_lowercase());
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// What a preset's profile reference resolved to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Resolved {
|
||||
/// A profile the user imported (`lut:…`).
|
||||
Imported(String),
|
||||
/// One of our built-in profiles standing in for the other app's basic profile of that name.
|
||||
BuiltIn(String),
|
||||
/// One of the other app's own creative or camera profiles: not available here.
|
||||
OtherAppBuiltIn,
|
||||
/// A third-party profile the user hasn't imported (yet).
|
||||
NotImported,
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
}
|
||||
|
||||
/// The other app's basic profiles and ours that stand in for them.
|
||||
const BASIC: &[(&str, &str)] = &[
|
||||
("adobe color", "lc.color"),
|
||||
("adobe neutral", "lc.neutral"),
|
||||
("adobe vivid", "lc.vivid"),
|
||||
("adobe landscape", "lc.landscape"),
|
||||
("adobe portrait", "lc.portrait"),
|
||||
("adobe monochrome", "lc.mono"),
|
||||
];
|
||||
|
||||
/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered
|
||||
/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`).
|
||||
fn other_app_builtin(name: &str) -> bool {
|
||||
let n = norm(name);
|
||||
if n.starts_with("adobe ") || n.starts_with("camera ") {
|
||||
return true;
|
||||
}
|
||||
["artistic ", "b&w ", "modern ", "vintage "]
|
||||
.iter()
|
||||
.any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit())))
|
||||
}
|
||||
|
||||
/// Resolve `look` (a `profile.look` value) against the imported profiles.
|
||||
pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved {
|
||||
let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default());
|
||||
let group = look.get("group").and_then(Value::as_str).map(norm);
|
||||
if !name.is_empty() {
|
||||
let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect();
|
||||
let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first());
|
||||
if let Some(p) = pick {
|
||||
return Resolved::Imported(p.id.clone());
|
||||
}
|
||||
}
|
||||
if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) {
|
||||
return Resolved::BuiltIn((*ours).to_string());
|
||||
}
|
||||
if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported }
|
||||
}
|
||||
|
||||
/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its
|
||||
/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile)
|
||||
/// and whether `settings` changed.
|
||||
pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> {
|
||||
let profile = settings.get_mut("profile")?.as_object_mut()?;
|
||||
let look = profile.get("look")?.clone();
|
||||
let r = resolve(&look, luts);
|
||||
let before = (profile.get("id").cloned(), profile.get("amount").cloned());
|
||||
match &r {
|
||||
Resolved::Imported(id) | Resolved::BuiltIn(id) => {
|
||||
profile.insert("id".into(), json!(id));
|
||||
profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0)));
|
||||
}
|
||||
// the photo keeps its own profile
|
||||
Resolved::OtherAppBuiltIn | Resolved::NotImported => {
|
||||
profile.remove("id");
|
||||
profile.remove("amount");
|
||||
}
|
||||
}
|
||||
let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned());
|
||||
Some((r, changed))
|
||||
}
|
||||
|
||||
/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names).
|
||||
pub fn report(look: &Value, r: &Resolved) -> Option<String> {
|
||||
let name = look.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
match r {
|
||||
Resolved::Imported(_) | Resolved::BuiltIn(_) => None,
|
||||
Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")),
|
||||
Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve every user preset's profile reference again (after profiles were imported or deleted).
|
||||
/// Only the presets that change are touched, so an unchanged list isn't saved again.
|
||||
pub fn refresh(s: &mut Session) {
|
||||
let luts = s.lut_profiles.clone();
|
||||
let stale: Vec<usize> = s
|
||||
.presets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| !p.builtin)
|
||||
.filter(|(_, p)| {
|
||||
let mut settings = p.settings.clone();
|
||||
apply(&mut settings, &luts).is_some_and(|(_, changed)| changed)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let presets = &mut *s.presets;
|
||||
for i in stale {
|
||||
if let Some(p) = presets.get_mut(i) {
|
||||
apply(&mut p.settings, &luts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lut(id: &str, name: &str, group: &str) -> LutProfile {
|
||||
LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_by_name_with_the_group_breaking_ties() {
|
||||
let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")];
|
||||
let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0});
|
||||
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into()));
|
||||
let look = json!({"name": "kodak portra 400", "amount": 100.0});
|
||||
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group");
|
||||
assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_app_profiles_map_to_ours_or_are_reported() {
|
||||
assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into()));
|
||||
for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] {
|
||||
assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}");
|
||||
}
|
||||
assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported);
|
||||
// an imported profile wins over the mapping
|
||||
assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sets_and_clears_the_profile() {
|
||||
let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}});
|
||||
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false)));
|
||||
let luts = [lut("lut:mb1", "mb1 profile", "Imported")];
|
||||
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true)));
|
||||
assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0)));
|
||||
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved");
|
||||
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted");
|
||||
assert!(s["profile"].get("id").is_none());
|
||||
assert_eq!(apply(&mut json!({"light": {}}), &luts), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_ref_reads_name_group_uuid_and_amount() {
|
||||
let x = 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:Exposure2012="0">
|
||||
<crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C">
|
||||
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&W Profiles</rdf:li></rdf:Alt></crs:Group>
|
||||
</rdf:Description></crs:Look>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let props = lightcraft_meta::parse_xmp(x).unwrap().properties;
|
||||
assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"})));
|
||||
assert_eq!(look_ref(&Props::new()), None);
|
||||
}
|
||||
}
|
||||
@@ -1583,3 +1583,35 @@ fn rotate_carries_the_crop_with_the_image() {
|
||||
assert_eq!(c.geometry.rect, left_half);
|
||||
assert_eq!(c.aspect, Some((2, 3)));
|
||||
}
|
||||
|
||||
/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes
|
||||
/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets
|
||||
/// out still means the selection.
|
||||
#[test]
|
||||
fn malformed_photo_targets_are_refused_not_widened() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap();
|
||||
let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating;
|
||||
let before: Vec<u8> = ids.iter().map(|i| rating(&s, *i)).collect();
|
||||
for bad in [
|
||||
json!({"rating": 5, "id": "5"}),
|
||||
json!({"rating": 5, "ids": [ids[2], "x"]}),
|
||||
json!({"rating": 5, "ids": ids[2].to_string()}),
|
||||
json!({"rating": 5, "id": -1}),
|
||||
] {
|
||||
let err = s.execute("photo.rate", &bad).unwrap_err();
|
||||
assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}");
|
||||
assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::<Vec<_>>(), before, "{bad} changed a rating");
|
||||
}
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted");
|
||||
// no targets, or a null one: the selection
|
||||
s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap();
|
||||
assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2]));
|
||||
// a preset's id is not a photo target
|
||||
let preset = s.presets.first().unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": preset})).unwrap();
|
||||
}
|
||||
|
||||
@@ -579,6 +579,30 @@ fn remove_location_holds_for_original_and_dng_exports() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data
|
||||
/// (it used to be read as `all` and carry both).
|
||||
#[test]
|
||||
fn metadata_none_in_any_case_exports_no_metadata() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
|
||||
let export = |s: &mut Session, extra: serde_json::Value| {
|
||||
let mut p = json!({"format": "jpeg", "longEdge": 64});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes)
|
||||
};
|
||||
let all = export(&mut s, json!({}));
|
||||
assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}");
|
||||
for v in ["none", "NONE", "None"] {
|
||||
let m = export(&mut s, json!({"metadata": v}));
|
||||
assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}");
|
||||
}
|
||||
let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true}));
|
||||
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
|
||||
}
|
||||
|
||||
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
|
||||
#[test]
|
||||
fn remove_location_refuses_a_raw_that_embeds_gps() {
|
||||
|
||||
@@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() {
|
||||
/// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass.
|
||||
fn demo_with_yunet() -> Option<Session> {
|
||||
let Some(file) = std::env::var_os("LC_YUNET_MODEL") else {
|
||||
eprintln!("LC_YUNET_MODEL is not set: skipping");
|
||||
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set");
|
||||
return None;
|
||||
};
|
||||
let dir = temp("yunet");
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) {
|
||||
/// committed) and says whether it did: the parts of a test that need to find faces run only then.
|
||||
pub(crate) fn install_yunet(s: &mut Session) -> bool {
|
||||
let Some(file) = std::env::var_os("LC_YUNET_MODEL") else {
|
||||
eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part");
|
||||
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked");
|
||||
return false;
|
||||
};
|
||||
let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true}));
|
||||
|
||||
@@ -1680,3 +1680,71 @@ fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a
|
||||
/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and
|
||||
/// reports the ones it can't use.
|
||||
#[test]
|
||||
fn presets_apply_the_imported_profile_they_name() {
|
||||
let src = temp_dir("lookref");
|
||||
let preset = |file: &str, name: &str, look: &str| {
|
||||
let x = 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:PresetType="Normal" crs:Name="{name}" crs:UUID="{file}" crs:Exposure2012="0" crs:GrainAmount="20">
|
||||
<crs:Look><rdf:Description crs:Name="{look}" crs:Amount="0.6" crs:UUID="1234ABCD">
|
||||
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Film Pack</rdf:li></rdf:Alt></crs:Group>
|
||||
</rdf:Description></crs:Look>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
std::fs::write(src.join(format!("{file}.xmp")), x).unwrap();
|
||||
};
|
||||
preset("a1", "Portra Soft", "Portra 400");
|
||||
preset("a2", "Mono", "Adobe Monochrome");
|
||||
preset("a3", "Artsy", "Artistic 01");
|
||||
let mut s = Session::with_demo();
|
||||
let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap();
|
||||
assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}");
|
||||
assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"}));
|
||||
assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"]));
|
||||
let photo = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
|
||||
let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone();
|
||||
let apply = |s: &mut Session, n: &str| {
|
||||
let id = pid(s, n);
|
||||
s.execute("preset.apply", &json!({"id": id})).unwrap();
|
||||
s.develop_of(photo).unwrap()
|
||||
};
|
||||
// not imported yet: only grain comes across, the photo keeps its profile
|
||||
let d = apply(&mut s, "Portra Soft");
|
||||
assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0));
|
||||
// the basic Adobe profiles are ours
|
||||
assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono");
|
||||
s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap();
|
||||
assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours");
|
||||
// importing the profile afterwards makes the preset use it, at the preset's amount
|
||||
let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n");
|
||||
for b in 0..2 {
|
||||
for g in 0..2 {
|
||||
for r in 0..2 {
|
||||
cube.push_str(&format!("{r} {g} {b}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap();
|
||||
std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap();
|
||||
let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap();
|
||||
let lut = r["imported"][0]["id"].as_str().unwrap().to_string();
|
||||
let d = apply(&mut s, "Portra Soft");
|
||||
assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0));
|
||||
// a preset imported after the profile resolves straight away
|
||||
preset("a4", "Portra Hard", "portra 400");
|
||||
let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}");
|
||||
// deleting the profile leaves the photo's own profile alone again
|
||||
s.execute("profile.deleteImported", &json!({"id": lut})).unwrap();
|
||||
s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap();
|
||||
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -440,6 +440,21 @@ fn scale_100k_library_queries() {
|
||||
m("undo", &mut || {
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
// a 20k-photo album shown in its own order (capture-date sort, no filter)
|
||||
let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids: Vec<u64> = (0..n).rev().step_by(5).map(|i| i + 1).collect();
|
||||
m("add 20k to an album", &mut || {
|
||||
s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap();
|
||||
});
|
||||
m("album of 20k (album order)", &mut || {
|
||||
s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap();
|
||||
let _ = s.visible_cloned();
|
||||
});
|
||||
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
||||
let half: Vec<u64> = ids.iter().copied().step_by(2).collect();
|
||||
m("remove 10k from the album", &mut || {
|
||||
s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap();
|
||||
});
|
||||
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
eprintln!("slowest: {:?}", &worst[..3]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The `print.*` commands: state, cells, templates and the PDF.
|
||||
|
||||
use lightcraft_catalog::MemStore;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::library::LibraryStores;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn fails(s: &mut Session, id: &str, p: Value) -> String {
|
||||
s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string()
|
||||
}
|
||||
|
||||
fn package(s: &mut Session) {
|
||||
run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_set_and_reset() {
|
||||
let mut s = Session::with_demo();
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["settings"]["layout"]["style"], "singleImage");
|
||||
assert_eq!(g["pageWidth"], 8.5);
|
||||
assert_eq!(g["printable"]["x"], 0.25);
|
||||
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
|
||||
assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4"));
|
||||
assert_eq!(g["gridCells"].as_array().unwrap().len(), 1);
|
||||
|
||||
let g = run(
|
||||
&mut s,
|
||||
"print.set",
|
||||
json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}),
|
||||
);
|
||||
assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0);
|
||||
assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges");
|
||||
assert_eq!(g["gridCells"].as_array().unwrap().len(), 6);
|
||||
assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3);
|
||||
assert_eq!(g["settings"]["image"]["zoomToFill"], true);
|
||||
// the page count follows the photos: 6 cells per page
|
||||
let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]}));
|
||||
assert_eq!(g["pageCount"], 2);
|
||||
|
||||
let g = run(&mut s, "print.reset", json!({}));
|
||||
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_settings_are_refused_and_change_nothing() {
|
||||
let mut s = Session::with_demo();
|
||||
let before = run(&mut s, "print.get", json!({}));
|
||||
for p in [
|
||||
json!({"typo": 1}),
|
||||
json!({"layout": {"typo": 1}}),
|
||||
json!({"layout": {"style": "poster"}}),
|
||||
json!({"layout": {"cells": []}}),
|
||||
json!({"layout": {"pages": 3}}),
|
||||
json!({"layout": {"page": {"paper": "poster"}}}),
|
||||
json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}),
|
||||
json!({"layout": {"page": {"margins": {"top": 1e9}}}}),
|
||||
json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}),
|
||||
json!({"layout": {"grid": {"columns": 0}}}),
|
||||
json!({"layout": {"grid": {"columns": 9999}}}),
|
||||
json!({"layout": {"spacing": -1}}),
|
||||
json!({"image": {"borderWidth": 99}}),
|
||||
json!({"image": {"strokeWidthPt": -2}}),
|
||||
json!({"image": {"borderColor": [1, 2]}}),
|
||||
json!({"image": {"zoomToFill": "yes"}}),
|
||||
json!({"guides": {"gridSize": 0}}),
|
||||
json!({"guides": {"units": "furlong"}}),
|
||||
json!({"job": {"ppi": 1e30}}),
|
||||
json!({"job": {"ppi": 10}}),
|
||||
json!({"job": null}),
|
||||
] {
|
||||
fails(&mut s, "print.set", p);
|
||||
}
|
||||
assert_eq!(run(&mut s, "print.get", json!({})), before);
|
||||
assert!(s.execute("print.set", &json!([1])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_commands() {
|
||||
let mut s = Session::with_demo();
|
||||
// grid styles have no cells
|
||||
fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2}));
|
||||
package(&mut s);
|
||||
let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
|
||||
let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap();
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(g["overlaps"], false);
|
||||
// move, clamped inside the margins
|
||||
let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0}));
|
||||
assert_eq!(r["rect"]["x"], 8.25 - 3.0);
|
||||
assert_eq!(r["rect"]["y"], 0.25);
|
||||
// resize keeps what is not given
|
||||
let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0}));
|
||||
assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0));
|
||||
// snapping to the other cell's edge
|
||||
let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone();
|
||||
let left = ra["x"].as_f64().unwrap();
|
||||
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true}));
|
||||
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}");
|
||||
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0}));
|
||||
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}");
|
||||
run(&mut s, "print.set", json!({"guides": {"snap": "off"}}));
|
||||
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true}));
|
||||
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off");
|
||||
// stacking
|
||||
let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"}));
|
||||
assert_eq!(r["order"], json!([b, a]));
|
||||
fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"}));
|
||||
// photos and pages
|
||||
let photo = s.catalog.photos().next().unwrap().id.0;
|
||||
run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo}));
|
||||
fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999}));
|
||||
run(&mut s, "print.layout.newPage", json!({}));
|
||||
run(&mut s, "print.cell.page", json!({"id": b, "page": 1}));
|
||||
fails(&mut s, "print.cell.page", json!({"id": b, "page": 7}));
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["pageCount"], 2);
|
||||
// delete, clear
|
||||
run(&mut s, "print.cell.delete", json!({"id": b}));
|
||||
fails(&mut s, "print.cell.delete", json!({"id": b}));
|
||||
fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0}));
|
||||
fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0}));
|
||||
run(&mut s, "print.layout.clear", json!({}));
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_cell_numbers_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
package(&mut s);
|
||||
for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] {
|
||||
fails(&mut s, "print.cell.add", json!({"width": w, "height": h}));
|
||||
}
|
||||
fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2}));
|
||||
fails(&mut s, "print.cell.add", json!({"width": 2}));
|
||||
let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap();
|
||||
fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0}));
|
||||
fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4}));
|
||||
fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"}));
|
||||
fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"}));
|
||||
fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1}));
|
||||
let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300}));
|
||||
assert_eq!(r["rect"]["x"], 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_fills_pages_without_overlap() {
|
||||
let mut s = Session::with_demo();
|
||||
run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}}));
|
||||
for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] {
|
||||
run(&mut s, "print.cell.add", json!({"width": w, "height": h}));
|
||||
}
|
||||
let r = run(&mut s, "print.layout.autoLayout", json!({}));
|
||||
assert!(r["pages"].as_u64().unwrap() >= 2);
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["overlaps"], false);
|
||||
let again = run(&mut s, "print.layout.autoLayout", json!({}));
|
||||
assert_eq!(again["cells"], r["cells"], "deterministic");
|
||||
// an oversize cell that arrived by a paper change is pulled in, not left outside the page
|
||||
run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}}));
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
for c in g["settings"]["layout"]["cells"].as_array().unwrap() {
|
||||
assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page's MediaBox width and height in points.
|
||||
fn media_box(pdf: &[u8]) -> (f64, f64) {
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
(nums[2], nums[3])
|
||||
}
|
||||
|
||||
fn temp_pdf(name: &str) -> String {
|
||||
std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_prints_the_current_settings() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
run(
|
||||
&mut s,
|
||||
"print.set",
|
||||
json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}),
|
||||
);
|
||||
let path = temp_pdf("export");
|
||||
let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
|
||||
assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3)));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap());
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.starts_with("%PDF-") && text.contains("/Count 2"));
|
||||
// A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827)
|
||||
let (w, h) = media_box(&bytes);
|
||||
assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt");
|
||||
// the row in the activity stack is gone when the print is done
|
||||
assert!(s.activity.list().is_empty());
|
||||
fails(&mut s, "print.exportPdf", json!({}));
|
||||
fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1}));
|
||||
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]}));
|
||||
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]}));
|
||||
// a package with no cells has nothing to print
|
||||
run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}}));
|
||||
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_with_a_printer_profile() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(1).map(|p| p.id.0).collect();
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let icc = dir.join("Fine Art Matte.icc");
|
||||
std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap();
|
||||
let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10});
|
||||
run(
|
||||
&mut s,
|
||||
"print.set",
|
||||
json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}),
|
||||
);
|
||||
assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte");
|
||||
let path = temp_pdf("icc");
|
||||
run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
|
||||
let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned();
|
||||
assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16"));
|
||||
// out-of-range adjustments are refused when set; a missing profile fails before rendering
|
||||
fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}}));
|
||||
run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}}));
|
||||
let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
|
||||
assert!(err.contains("gone.icc"), "{err}");
|
||||
assert!(s.activity.list().is_empty(), "nothing started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_package_prints_each_cells_photo() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}));
|
||||
let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
|
||||
let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]}));
|
||||
run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]}));
|
||||
let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]}));
|
||||
assert_eq!(r["pages"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_save_apply_delete_and_persist() {
|
||||
let files = MemStore::new();
|
||||
let open = |files: &MemStore| {
|
||||
let mut s = Session::with_demo();
|
||||
let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
s
|
||||
};
|
||||
let mut s = open(&files);
|
||||
run(
|
||||
&mut s,
|
||||
"print.set",
|
||||
json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}),
|
||||
);
|
||||
let r = run(&mut s, "print.template.save", json!({"name": "Proofs"}));
|
||||
assert_eq!(r["templates"][0]["name"], "Proofs");
|
||||
assert!(r["templates"][0]["settings"].get("guides").is_none());
|
||||
run(&mut s, "print.template.save", json!({"name": "proofs"}));
|
||||
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces");
|
||||
|
||||
run(&mut s, "print.reset", json!({}));
|
||||
run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}}));
|
||||
let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"}));
|
||||
assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4);
|
||||
assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30]));
|
||||
assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were");
|
||||
|
||||
// it survives reopening the library
|
||||
let mut again = open(&files);
|
||||
assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs");
|
||||
run(&mut again, "print.template.apply", json!({"name": "Proofs"}));
|
||||
|
||||
fails(&mut s, "print.template.save", json!({"name": " "}));
|
||||
fails(&mut s, "print.template.save", json!({}));
|
||||
fails(&mut s, "print.template.apply", json!({"name": "Nope"}));
|
||||
fails(&mut s, "print.template.delete", json!({"name": "Nope"}));
|
||||
run(&mut s, "print.template.delete", json!({"name": "proofs"}));
|
||||
assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
|
||||
assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_template_in_the_preferences_does_not_break_the_library() {
|
||||
let files = MemStore::new();
|
||||
let hostile = json!({"printTemplates": [
|
||||
{"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}},
|
||||
{"name": "Huge", "settings": {"job": {"ppi": 1e300}}},
|
||||
{"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}}
|
||||
], "cacheMb": 123});
|
||||
files.set("prefs.json", serde_json::to_vec(&hostile).unwrap());
|
||||
let mut s = Session::with_demo();
|
||||
let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged");
|
||||
assert_eq!(s.cache_mb, 123);
|
||||
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3);
|
||||
fails(&mut s, "print.template.apply", json!({"name": "Bad"}));
|
||||
fails(&mut s, "print.template.apply", json!({"name": "Huge"}));
|
||||
run(&mut s, "print.template.apply", json!({"name": "Fine"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_print_command_is_registered_with_a_description() {
|
||||
for id in [
|
||||
"print.get",
|
||||
"print.set",
|
||||
"print.reset",
|
||||
"print.cell.add",
|
||||
"print.cell.move",
|
||||
"print.cell.resize",
|
||||
"print.cell.delete",
|
||||
"print.cell.stack",
|
||||
"print.cell.photo",
|
||||
"print.cell.page",
|
||||
"print.layout.autoLayout",
|
||||
"print.layout.newPage",
|
||||
"print.layout.clear",
|
||||
"print.exportPdf",
|
||||
"print.template.list",
|
||||
"print.template.save",
|
||||
"print.template.apply",
|
||||
"print.template.delete",
|
||||
] {
|
||||
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing"));
|
||||
assert!(c.params.len() > 3, "{id}");
|
||||
}
|
||||
}
|
||||
@@ -584,8 +584,10 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xm
|
||||
},
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
|
||||
}
|
||||
@@ -666,7 +668,7 @@ fn convert_raw_to_dng() {
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1));
|
||||
if !corpus.exists() {
|
||||
eprintln!("skip: {} absent", corpus.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display());
|
||||
return;
|
||||
}
|
||||
let nef = dir.join("shot.nef");
|
||||
@@ -928,7 +930,7 @@ fn dng_export_compression_choices() {
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||||
if !corpus.exists() {
|
||||
eprintln!("skip: {} absent", corpus.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
@@ -973,7 +975,7 @@ fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() {
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut opened = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert!(!has_matrix(&opened.color));
|
||||
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None);
|
||||
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None);
|
||||
assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}");
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::time::Instant;
|
||||
#[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"]
|
||||
fn a_preview_reads_only_the_header_and_the_preview() {
|
||||
let Ok(path) = std::env::var("LC_PREVIEW_RAW") else {
|
||||
eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>");
|
||||
lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW=<path to a raw file>");
|
||||
return;
|
||||
};
|
||||
let file_len = std::fs::metadata(&path).expect("the file exists").len();
|
||||
|
||||
@@ -22,4 +22,6 @@ sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
|
||||
fn detector() -> Option<Detector> {
|
||||
let Some(bytes) = model() else {
|
||||
eprintln!("LC_YUNET_MODEL is not set: skipping");
|
||||
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set");
|
||||
return None;
|
||||
};
|
||||
Some(Detector::new(&bytes).unwrap())
|
||||
|
||||
@@ -29,5 +29,7 @@ default = []
|
||||
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
lightcraft-scenes = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
|
||||
@@ -907,7 +907,7 @@ mod tests {
|
||||
match runner(net, tile) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
eprintln!("skipped: no GPU runner here ({e})");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3;
|
||||
fn gpu() -> bool {
|
||||
let ok = lightcraft_gpu::available();
|
||||
if !ok {
|
||||
eprintln!("skipped: no GPU adapter");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) {
|
||||
#[test]
|
||||
fn failed_gpu_renders_fall_back_with_a_reason() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
assert_eq!(lightcraft_gpu::unavailable_reason(), None);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of
|
||||
//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs;
|
||||
//! xtask runs it after the workspace tests, so it costs no extra build:
|
||||
//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`.
|
||||
//!
|
||||
//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`,
|
||||
//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build
|
||||
//! contains.
|
||||
//! - `LIGHTCRAFT_GPU_PROBE=<backend>` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that
|
||||
//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so
|
||||
//! a driver that crashes while it is enumerated (issue #136) costs only its own line.
|
||||
//!
|
||||
//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use lightcraft_gpu::backend::{self, Backends};
|
||||
|
||||
const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)];
|
||||
|
||||
#[test]
|
||||
#[ignore = "the capability probe of `cargo xtask ci`"]
|
||||
fn gpu_probe() {
|
||||
let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) {
|
||||
None => pipeline(),
|
||||
Some(name) => adapters(&name),
|
||||
};
|
||||
// its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one
|
||||
println!("\ngpu_probe: {line}");
|
||||
}
|
||||
|
||||
fn names(set: Backends) -> String {
|
||||
let list: Vec<String> = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect();
|
||||
format!("[{}]", list.join(","))
|
||||
}
|
||||
|
||||
fn pipeline() -> String {
|
||||
let available = lightcraft_gpu::available();
|
||||
let opt = |s: Option<String>| s.map_or_else(|| "null".to_owned(), |s| json_str(&s));
|
||||
let compiled = wgpu::Instance::enabled_backend_features();
|
||||
format!(
|
||||
"{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}",
|
||||
opt(lightcraft_gpu::adapter_name()),
|
||||
opt(lightcraft_gpu::unavailable_reason()),
|
||||
names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled),
|
||||
names(compiled),
|
||||
)
|
||||
}
|
||||
|
||||
fn adapters(name: &str) -> String {
|
||||
let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)"));
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = *set;
|
||||
desc.backend_options = backend::backend_options();
|
||||
desc.flags = backend::instance_flags_from_env(desc.flags);
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapters: Vec<String> = pollster::block_on(instance.enumerate_adapters(*set))
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
format!(
|
||||
"{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}",
|
||||
json_str(&i.name),
|
||||
json_str(&format!("{:?}", i.device_type)),
|
||||
i.vendor,
|
||||
i.device,
|
||||
json_str(&i.driver),
|
||||
json_str(&i.driver_info),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(","))
|
||||
}
|
||||
|
||||
/// A JSON string literal.
|
||||
fn json_str(s: &str) -> String {
|
||||
let mut out = String::from("\"");
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache};
|
||||
#[test]
|
||||
fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let limit = 64u64 << 20;
|
||||
|
||||
@@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() {
|
||||
let req = RenderRequest::fit(w, h);
|
||||
let has_gpu = lightcraft_gpu::available();
|
||||
if !has_gpu {
|
||||
eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason());
|
||||
let why = lightcraft_gpu::unavailable_reason().unwrap_or_default();
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked");
|
||||
}
|
||||
assert!(!lightcraft_gpu::shutting_down());
|
||||
assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet");
|
||||
|
||||
+229
-51
@@ -6,7 +6,7 @@ use lightcraft_geom::Orientation;
|
||||
use lightcraft_meta::Metadata;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
use lightcraft_raw::{ColorData, Opcode};
|
||||
use lightcraft_raw::{ColorData, Opcode, RawImage};
|
||||
|
||||
use crate::{MergeError, Result};
|
||||
|
||||
@@ -19,6 +19,21 @@ pub enum FrameColor {
|
||||
Linear { to_xyz_d50: Mat3 },
|
||||
}
|
||||
|
||||
impl FrameColor {
|
||||
/// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the
|
||||
/// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the
|
||||
/// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has
|
||||
/// no white balance to set, and multipliers that aren't finite and above zero change nothing.
|
||||
pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) {
|
||||
if let FrameColor::Camera(c) = self
|
||||
&& multipliers.iter().all(|v| v.is_finite() && *v > 0.0)
|
||||
{
|
||||
c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v)));
|
||||
c.as_shot_white_xy = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One input photo.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Frame {
|
||||
@@ -40,6 +55,9 @@ pub struct Frame {
|
||||
/// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for
|
||||
/// other sources and once [`Frame::orient`] has turned the pixels.
|
||||
pub lens: Vec<Opcode>,
|
||||
/// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such
|
||||
/// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say.
|
||||
pub wb_multipliers: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
@@ -58,6 +76,7 @@ impl Frame {
|
||||
metadata: Metadata::default(),
|
||||
baseline_exposure: 0.0,
|
||||
lens: Vec::new(),
|
||||
wb_multipliers: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +106,29 @@ pub fn exif_exposure(m: &Metadata) -> Option<f64> {
|
||||
Some((t * iso / (n * n)).log2())
|
||||
}
|
||||
|
||||
/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of
|
||||
/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced
|
||||
/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without
|
||||
/// usable multipliers don't vote; `None` when none does.
|
||||
pub fn consensus_multipliers<'a>(frames: impl IntoIterator<Item = &'a Frame>) -> Option<[f32; 3]> {
|
||||
let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new());
|
||||
for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) {
|
||||
if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) {
|
||||
r_over_g.push(f64::from(r) / f64::from(g));
|
||||
b_over_g.push(f64::from(b) / f64::from(g));
|
||||
}
|
||||
}
|
||||
Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32])
|
||||
}
|
||||
|
||||
/// The median of `v` (the mean of the middle two when the count is even); `None` when empty.
|
||||
fn median(mut v: Vec<f64>) -> Option<f64> {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let upper = *v.get(v.len() / 2)?;
|
||||
let lower = *v.get(v.len().checked_sub(1)? / 2)?;
|
||||
Some((lower + upper) / 2.0)
|
||||
}
|
||||
|
||||
/// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the
|
||||
/// file's white level is optimistic. Returned slightly below the level.
|
||||
pub fn detect_clip(img: &Rgb32f) -> f32 {
|
||||
@@ -125,56 +167,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
|
||||
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()))?;
|
||||
// Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
|
||||
// the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
|
||||
// The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
|
||||
let (aw, ah) = (raw.active_area.width, raw.active_area.height);
|
||||
let crop = raw.develop_crop(aw, ah);
|
||||
let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
|
||||
.filter(|op| op.is_lens_correction())
|
||||
.filter_map(|op| op.cropped(aw, ah, crop))
|
||||
.map(|op| match op {
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }
|
||||
}
|
||||
op => op,
|
||||
})
|
||||
.collect();
|
||||
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()))?;
|
||||
for p in image.data.iter_mut() {
|
||||
*p = p.map(|v| v.max(0.0));
|
||||
}
|
||||
let clip = detect_clip(&image);
|
||||
if let Some(m) = max_edge
|
||||
&& image.width.max(image.height) > m
|
||||
{
|
||||
image = fit(&image, m, m, Filter::Box);
|
||||
}
|
||||
let mut f = Frame {
|
||||
image,
|
||||
clip,
|
||||
exposure,
|
||||
raw: true,
|
||||
color: FrameColor::Camera(Box::new(raw.color.clone())),
|
||||
orientation: raw.orientation,
|
||||
metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() },
|
||||
baseline_exposure: raw.color.baseline_exposure,
|
||||
lens,
|
||||
};
|
||||
if f.exposure.is_none() {
|
||||
f.exposure = exif_exposure(&f.metadata);
|
||||
}
|
||||
if orient {
|
||||
f.orient();
|
||||
}
|
||||
return Ok(f);
|
||||
let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour);
|
||||
}
|
||||
let opts = match max_edge {
|
||||
Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32),
|
||||
@@ -198,6 +192,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
|
||||
metadata,
|
||||
baseline_exposure: 0.0,
|
||||
lens: Vec::new(),
|
||||
wb_multipliers: None,
|
||||
};
|
||||
if orient {
|
||||
f.orient();
|
||||
@@ -205,6 +200,67 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags,
|
||||
/// its as-shot white-balance multipliers and its orientation.
|
||||
fn raw_frame(
|
||||
mut raw: RawImage,
|
||||
bytes: &[u8],
|
||||
metadata: Metadata,
|
||||
exposure: Option<f64>,
|
||||
max_edge: Option<usize>,
|
||||
orient: bool,
|
||||
colour: &RawColour,
|
||||
) -> Result<Frame> {
|
||||
// Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
|
||||
// the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
|
||||
// The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
|
||||
let (aw, ah) = (raw.active_area.width, raw.active_area.height);
|
||||
let crop = raw.develop_crop(aw, ah);
|
||||
let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
|
||||
.filter(|op| op.is_lens_correction())
|
||||
.filter_map(|op| op.cropped(aw, ah, crop))
|
||||
.map(|op| match op {
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false },
|
||||
op => op,
|
||||
})
|
||||
.collect();
|
||||
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()))?;
|
||||
for p in image.data.iter_mut() {
|
||||
*p = p.map(|v| v.max(0.0));
|
||||
}
|
||||
let clip = detect_clip(&image);
|
||||
if let Some(m) = max_edge
|
||||
&& image.width.max(image.height) > m
|
||||
{
|
||||
image = fit(&image, m, m, Filter::Box);
|
||||
}
|
||||
let mut f = Frame {
|
||||
image,
|
||||
clip,
|
||||
exposure,
|
||||
raw: true,
|
||||
color: FrameColor::Camera(Box::new(raw.color.clone())),
|
||||
orientation: raw.orientation,
|
||||
metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() },
|
||||
baseline_exposure: raw.color.baseline_exposure,
|
||||
lens,
|
||||
wb_multipliers: raw.wb_multipliers,
|
||||
};
|
||||
if f.exposure.is_none() {
|
||||
f.exposure = exif_exposure(&f.metadata);
|
||||
}
|
||||
if orient {
|
||||
f.orient();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -230,4 +286,126 @@ mod tests {
|
||||
}
|
||||
assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers.
|
||||
fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage {
|
||||
use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect};
|
||||
let (w, h) = (16usize, 12usize);
|
||||
RawImage {
|
||||
format: RawFormat::Cr2,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 1,
|
||||
data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()),
|
||||
cfa: Cfa::bayer("RGGB"),
|
||||
bits: 12,
|
||||
black: BlackLevel::uniform(64.0),
|
||||
white: vec![1023.0],
|
||||
active_area: Rect::new(0, 0, w, h),
|
||||
crop: Rect::new(0, 0, w, h),
|
||||
orientation: Orientation::Normal,
|
||||
color: ColorData::default(),
|
||||
wb_multipliers,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
lens_metadata: Default::default(),
|
||||
pixel_aspect: 1.0,
|
||||
metadata: Metadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_of(raw: RawImage, colour: &RawColour) -> Frame {
|
||||
raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap()
|
||||
}
|
||||
|
||||
/// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so
|
||||
/// nothing downstream could tell a merged DNG what the camera had balanced to.
|
||||
#[test]
|
||||
fn a_raw_frame_carries_the_as_shot_multipliers() {
|
||||
let wb = [2.05, 1.0, 1.62];
|
||||
let f = frame_of(mosaic(Some(wb)), &|_, _| None);
|
||||
assert!(f.raw);
|
||||
assert_eq!(f.wb_multipliers, Some(wb));
|
||||
// colour tags the caller works out leave them alone
|
||||
let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() };
|
||||
let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone()));
|
||||
assert_eq!(f.wb_multipliers, Some(wb));
|
||||
assert_eq!(f.color, FrameColor::Camera(Box::new(own)));
|
||||
// a file that gives none gives none
|
||||
assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None);
|
||||
assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None);
|
||||
}
|
||||
|
||||
fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame {
|
||||
Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) }
|
||||
}
|
||||
|
||||
fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> {
|
||||
let frames: Vec<Frame> = wb.iter().map(|m| voter(*m)).collect();
|
||||
consensus_multipliers(&frames)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_consensus_is_the_median_of_the_voters() {
|
||||
// one voter is its own median
|
||||
assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6]));
|
||||
// an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order
|
||||
let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])];
|
||||
assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6]));
|
||||
// an outlier doesn't pull it
|
||||
let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])];
|
||||
assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6]));
|
||||
// an even count takes the mean of the middle two
|
||||
let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])];
|
||||
assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even));
|
||||
let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])];
|
||||
assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two));
|
||||
// green is the unit even when a file's multipliers aren't normalised to it
|
||||
assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_without_usable_multipliers_do_not_vote() {
|
||||
assert_eq!(consensus(&[]), None);
|
||||
assert_eq!(consensus(&[None, None]), None);
|
||||
let bad = |m: [f32; 3]| consensus(&[Some(m)]);
|
||||
assert_eq!(bad([0.0, 1.0, 1.5]), None);
|
||||
assert_eq!(bad([2.0, 0.0, 1.5]), None);
|
||||
assert_eq!(bad([2.0, 1.0, -1.5]), None);
|
||||
assert_eq!(bad([f32::NAN, 1.0, 1.5]), None);
|
||||
assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None);
|
||||
assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None);
|
||||
// mixed: the good ones decide, the missing and the unusable ones are not counted as anything
|
||||
let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])];
|
||||
assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed));
|
||||
assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipliers_become_the_reciprocal_as_shot_neutral() {
|
||||
use lightcraft_color::{D50, Xy};
|
||||
let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() };
|
||||
let mut color = FrameColor::Camera(Box::new(tagged.clone()));
|
||||
color.set_as_shot_multipliers([2.0, 1.0, 1.6]);
|
||||
let FrameColor::Camera(c) = &color else { panic!("still camera colour") };
|
||||
assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)]));
|
||||
assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral");
|
||||
assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes");
|
||||
// multipliers that aren't usable change nothing
|
||||
for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] {
|
||||
let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() }));
|
||||
let before = color.clone();
|
||||
color.set_as_shot_multipliers(bad);
|
||||
assert_eq!(color, before, "{bad:?}");
|
||||
}
|
||||
// linear colour has no white balance to set
|
||||
let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color;
|
||||
let before = linear.clone();
|
||||
linear.set_as_shot_multipliers([2.0, 1.0, 1.6]);
|
||||
assert_eq!(linear, before);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -28,7 +28,7 @@ use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::align::{self, Alignment};
|
||||
use crate::frame::{Frame, FrameColor};
|
||||
use crate::frame::{Frame, FrameColor, consensus_multipliers};
|
||||
use crate::{MergeError, Progress, Result, check};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -87,6 +87,9 @@ pub struct HdrResult {
|
||||
pub baseline_exposure: f64,
|
||||
/// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]).
|
||||
pub lens: Vec<lightcraft_raw::Opcode>,
|
||||
/// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in
|
||||
/// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own.
|
||||
pub wb_multipliers: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few
|
||||
@@ -273,6 +276,12 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
None => Plane::new(0, 0),
|
||||
};
|
||||
let rf = &frames[reference];
|
||||
// the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none
|
||||
let wb_multipliers = consensus_multipliers(&frames);
|
||||
let mut color = rf.color.clone();
|
||||
if let Some(multipliers) = wb_multipliers {
|
||||
color.set_as_shot_multipliers(multipliers);
|
||||
}
|
||||
Ok(HdrResult {
|
||||
radiance: out,
|
||||
ghost,
|
||||
@@ -280,12 +289,13 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
ev,
|
||||
ev_exif,
|
||||
alignments,
|
||||
color: rf.color.clone(),
|
||||
color,
|
||||
raw: rf.raw,
|
||||
orientation: rf.orientation,
|
||||
metadata: rf.metadata.clone(),
|
||||
baseline_exposure: rf.baseline_exposure,
|
||||
lens: rf.lens.clone(),
|
||||
wb_multipliers,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the
|
||||
//! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame.
|
||||
//! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`,
|
||||
//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is
|
||||
//! still camera RGB.
|
||||
//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera
|
||||
//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the
|
||||
//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]).
|
||||
//! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB,
|
||||
//! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1).
|
||||
//! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the
|
||||
@@ -111,8 +112,10 @@ pub fn write_linear_dng(
|
||||
color: cd,
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() },
|
||||
metadata: meta,
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half };
|
||||
lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string()))
|
||||
|
||||
@@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use project::Projection;
|
||||
|
||||
use crate::frame::{Frame, FrameColor};
|
||||
use crate::frame::{Frame, FrameColor, consensus_multipliers};
|
||||
use crate::linalg;
|
||||
use crate::{MergeError, Progress, Result, check};
|
||||
use blend::{Blender, Buf, push_pull};
|
||||
@@ -376,6 +376,11 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
|
||||
image = finish::fill_edges(&image, &alpha);
|
||||
}
|
||||
let r = &frames[used[0]];
|
||||
// the panorama's white balance is the median of the frames that are in it, not whichever frame came first
|
||||
let mut color = r.color.clone();
|
||||
if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) {
|
||||
color.set_as_shot_multipliers(multipliers);
|
||||
}
|
||||
Ok(PanoResult {
|
||||
image,
|
||||
alpha,
|
||||
@@ -390,7 +395,7 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
|
||||
native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6,
|
||||
focal: f_out,
|
||||
origin: (ox, oy),
|
||||
color: r.color.clone(),
|
||||
color,
|
||||
metadata: r.metadata.clone(),
|
||||
raw: r.raw,
|
||||
baseline_exposure: r.baseline_exposure,
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144).
|
||||
//!
|
||||
//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has
|
||||
//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast.
|
||||
|
||||
use lightcraft_color::{D50, Xy};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip};
|
||||
use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr};
|
||||
use lightcraft_merge::output::color_data;
|
||||
use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch};
|
||||
use lightcraft_merge::{DngSamples, no_progress, write_linear_dng};
|
||||
use lightcraft_meta::Metadata;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_raw::ColorData;
|
||||
|
||||
type Multipliers = Option<[f32; 3]>;
|
||||
|
||||
/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw
|
||||
/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out.
|
||||
fn camera_without_white_balance() -> ColorData {
|
||||
let srgb = FrameColor::Linear {
|
||||
to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()),
|
||||
};
|
||||
ColorData { as_shot_neutral: None, ..color_data(&srgb) }
|
||||
}
|
||||
|
||||
fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame {
|
||||
let clip = detect_clip(&image);
|
||||
Frame {
|
||||
image,
|
||||
clip,
|
||||
exposure: None,
|
||||
raw: true,
|
||||
color: FrameColor::Camera(Box::new(color.clone())),
|
||||
orientation: Orientation::Normal,
|
||||
metadata: Metadata::default(),
|
||||
baseline_exposure: 0.0,
|
||||
lens: Vec::new(),
|
||||
wb_multipliers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping views of one scene, one frame per entry of `wb`, in order.
|
||||
fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec<Frame> {
|
||||
let n = wb.len();
|
||||
let yaws: Vec<f64> = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect();
|
||||
let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap();
|
||||
views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect()
|
||||
}
|
||||
|
||||
fn pano(frames: Vec<Frame>) -> PanoResult {
|
||||
stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap()
|
||||
}
|
||||
|
||||
fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> {
|
||||
match &res.color {
|
||||
FrameColor::Camera(c) => c.as_shot_neutral,
|
||||
FrameColor::Linear { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reciprocal(m: [f64; 3]) -> [f64; 3] {
|
||||
m.map(|v| 1.0 / v)
|
||||
}
|
||||
|
||||
fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) {
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}");
|
||||
}
|
||||
|
||||
/// The panorama as a DNG and read back with the repo's own raw reader.
|
||||
fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage {
|
||||
let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16, &[]).unwrap();
|
||||
lightcraft_raw::decode(&bytes).unwrap()
|
||||
}
|
||||
|
||||
const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6];
|
||||
|
||||
#[test]
|
||||
fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() {
|
||||
// three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames
|
||||
let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)];
|
||||
let res = pano(shoot(&camera_without_white_balance(), &wb));
|
||||
assert_eq!(res.used, [0, 1, 2]);
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
let FrameColor::Camera(c) = &res.color else { panic!("camera colour") };
|
||||
assert_eq!(c.as_shot_white_xy, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() {
|
||||
let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])];
|
||||
let res = pano(shoot(&camera_without_white_balance(), &wb));
|
||||
assert_eq!(res.used.len(), 4);
|
||||
let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0);
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6);
|
||||
// two frames: the mean of both
|
||||
let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]));
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_without_multipliers_do_not_vote() {
|
||||
// one frame of three has none (a DNG, say): the other two decide
|
||||
let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])];
|
||||
let res = pano(shoot(&camera_without_white_balance(), &wb));
|
||||
assert_eq!(res.used.len(), 3);
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6);
|
||||
// a first frame without them doesn't hide the others'
|
||||
let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)];
|
||||
let res = pano(shoot(&camera_without_white_balance(), &wb));
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
// unusable values don't count either
|
||||
let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)];
|
||||
let res = pano(shoot(&camera_without_white_balance(), &wb));
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_outside_the_panorama_does_not_vote() {
|
||||
let color = camera_without_white_balance();
|
||||
let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]);
|
||||
// four photos of other scenes, balanced very differently: counted, they would be the majority
|
||||
let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4);
|
||||
frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8]))));
|
||||
assert_eq!(frames.len(), 7);
|
||||
let res = pano(frames);
|
||||
assert_eq!(res.used, [0, 1, 2], "the others don't connect");
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_any_multipliers_the_colour_is_the_first_frames_as_before() {
|
||||
for source in [
|
||||
camera_without_white_balance(),
|
||||
ColorData::default(),
|
||||
ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() },
|
||||
] {
|
||||
let frames = shoot(&source, &[None, None, None]);
|
||||
let first = frames[0].color.clone();
|
||||
let res = pano(frames);
|
||||
assert_eq!(res.color, first);
|
||||
assert_eq!(neutral_of(&res), source.as_shot_neutral);
|
||||
}
|
||||
// a DNG's own AsShotWhiteXY survives when no frame brings multipliers
|
||||
let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() };
|
||||
assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() {
|
||||
// a neutral the app worked out for the first frame, and a white point of the source: both give way
|
||||
let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() };
|
||||
let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]));
|
||||
assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
let FrameColor::Camera(c) = &res.color else { panic!("camera colour") };
|
||||
assert_eq!(c.as_shot_white_xy, None);
|
||||
// everything else of the first frame's colour is as it was
|
||||
assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dng_says_the_median_white_balance_as_as_shot_neutral() {
|
||||
let source = camera_without_white_balance();
|
||||
let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)];
|
||||
let frames = shoot(&source, &wb);
|
||||
let median = consensus_multipliers(&frames).unwrap();
|
||||
let res = pano(frames);
|
||||
let raw = dng_of(&res);
|
||||
// AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational
|
||||
let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG");
|
||||
assert_close(got, reciprocal(median.map(f64::from)), 2e-6);
|
||||
assert_close(got, [0.5, 1.0, 0.625], 2e-6);
|
||||
assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other");
|
||||
// what a viewer starts from: the white and the camera transform the source's multipliers give
|
||||
let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median));
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}");
|
||||
let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want));
|
||||
assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb);
|
||||
// the colour model and the picture are the source's, and the same without the colour matrices
|
||||
assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some());
|
||||
assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3));
|
||||
let bare = dng_of(&pano(shoot(&ColorData::default(), &wb)));
|
||||
assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() {
|
||||
let res = pano(shoot(&ColorData::default(), &[None, None, None]));
|
||||
let raw = dng_of(&res);
|
||||
assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None));
|
||||
}
|
||||
|
||||
/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers.
|
||||
fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec<Frame> {
|
||||
let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0);
|
||||
evs.iter()
|
||||
.zip(wb)
|
||||
.map(|(ev, m)| {
|
||||
let k = 2f32.powf(*ev as f32);
|
||||
let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m);
|
||||
frame.exposure = Some(*ev);
|
||||
frame
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hdr(frames: Vec<Frame>) -> HdrResult {
|
||||
merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap()
|
||||
}
|
||||
|
||||
fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> {
|
||||
match &res.color {
|
||||
FrameColor::Camera(c) => c.as_shot_neutral,
|
||||
FrameColor::Linear { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() {
|
||||
let source = camera_without_white_balance();
|
||||
let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])];
|
||||
let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb));
|
||||
assert_eq!(res.wb_multipliers, Some(DAYLIGHT));
|
||||
assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
// the DNG says it, to the precision of a TIFF rational
|
||||
let bytes =
|
||||
write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half, &res.lens).unwrap();
|
||||
let raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6);
|
||||
// an even count takes the mean of the middle two; frames without multipliers do not vote
|
||||
let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]));
|
||||
assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6);
|
||||
let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])]));
|
||||
assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6]));
|
||||
assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() {
|
||||
let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() };
|
||||
let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]);
|
||||
let reference = frames[1].color.clone();
|
||||
let res = hdr(frames);
|
||||
assert_eq!((res.reference, res.wb_multipliers), (1, None));
|
||||
assert_eq!(res.color, reference);
|
||||
}
|
||||
@@ -29,7 +29,11 @@ default = ["jxl"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
zune-jpegxl = { workspace = true }
|
||||
# Pinned CC0 corpus identities; not a product dependency.
|
||||
sha2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
# Peak-heap regression tests (tests/sr2_heap.rs); not a product dependency.
|
||||
dhat = { workspace = true }
|
||||
|
||||
@@ -227,6 +227,8 @@ mod tests {
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Default::default(),
|
||||
lens_metadata: Default::default(),
|
||||
pixel_aspect: 1.0,
|
||||
};
|
||||
let path = dir.path("valid.dng");
|
||||
std::fs::write(&path, write_dng(&raw, &DngWriteOptions::default()).expect("encode generated fixture")).expect("write fixture");
|
||||
|
||||
@@ -24,7 +24,7 @@ impl RawImage {
|
||||
/// and B; Bayer: `k` even, so that every block has the same layout)?
|
||||
pub fn can_bin(&self, k: usize) -> bool {
|
||||
let Some(cfa) = &self.cfa else { return false };
|
||||
if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) {
|
||||
if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) {
|
||||
return false;
|
||||
}
|
||||
// every window position relative to the pattern
|
||||
@@ -163,8 +163,10 @@ mod tests {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Metadata::default(),
|
||||
lens_metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,8 +287,10 @@ mod tests {
|
||||
color,
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: Default::default(),
|
||||
metadata: Default::default(),
|
||||
lens_metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity.
|
||||
//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties).
|
||||
//!
|
||||
//! Work is done in overlapping tiles (bounded memory, rayon-parallel).
|
||||
//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written
|
||||
//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the
|
||||
//! homogeneity 1 pixel around the tile, which reads Lab 2 pixels around, the candidates read the greens 3
|
||||
//! pixels around, and those read the mosaic 5 pixels around, inside the `MARGIN`.
|
||||
|
||||
use super::Mosaic;
|
||||
use crate::Rgb32f;
|
||||
@@ -23,14 +26,19 @@ pub(crate) fn ahd(m: &Mosaic) -> Rgb32f {
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
// one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy)
|
||||
out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| {
|
||||
let (y0, th) = (ty * TILE, band.len() / w);
|
||||
for x0 in (0..w).step_by(TILE) {
|
||||
let tw = TILE.min(w - x0);
|
||||
let px = tile(m, x0, y0, tw, th);
|
||||
for y in 0..th {
|
||||
band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]);
|
||||
// one row of tiles per task, its tiles in parallel too, written straight into the picture
|
||||
out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| {
|
||||
let (y0, th) = (ty * TILE, band.len() / w.max(1));
|
||||
let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE))
|
||||
.into_par_iter()
|
||||
.map(|tx| {
|
||||
let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE));
|
||||
(x0, tw, tile(m, x0, y0, tw, th))
|
||||
})
|
||||
.collect();
|
||||
for (x0, tw, px) in tiles {
|
||||
for (row, src) in band.chunks_exact_mut(w).zip(px.chunks_exact(tw)) {
|
||||
row[x0..x0 + tw].copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -57,110 +65,100 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]>
|
||||
let (ox, oy) = (x0 as isize - MARGIN as isize, y0 as isize - MARGIN as isize);
|
||||
let mut mos = vec![0f32; n];
|
||||
let mut col = vec![0u8; n];
|
||||
for y in 0..bh {
|
||||
for x in 0..bw {
|
||||
mos[y * bw + x] = m.get(ox + x as isize, oy + y as isize);
|
||||
col[y * bw + x] = m.color(ox + x as isize, oy + y as isize);
|
||||
for (y, (mrow, crow)) in mos.chunks_exact_mut(bw).zip(col.chunks_exact_mut(bw)).enumerate() {
|
||||
let ry = super::reflect(oy + y as isize, m.h);
|
||||
if ox >= 0 && ox as usize + bw <= m.w {
|
||||
// inside the image: the row as it is, and its colours repeating with the pattern
|
||||
let x0 = ry * m.w + ox as usize;
|
||||
mrow.copy_from_slice(&m.data[x0..x0 + bw]);
|
||||
let period: Vec<u8> = (0..m.cfa.width).map(|k| m.cfa.color_at(ox as usize + k, ry)).collect();
|
||||
for (c, &p) in crow.iter_mut().zip(period.iter().cycle()) {
|
||||
*c = p;
|
||||
}
|
||||
} else {
|
||||
for x in 0..bw {
|
||||
mrow[x] = m.get(ox + x as isize, oy + y as isize);
|
||||
crow[x] = m.color(ox + x as isize, oy + y as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cl = |x: isize, y: isize| -> usize { y.clamp(0, bh as isize - 1) as usize * bw + x.clamp(0, bw as isize - 1) as usize };
|
||||
// flat indices of the pixels `d` or more from the buffer's edge, row by row
|
||||
let inner = |d: usize| (d..bh - d).flat_map(move |y| y * bw + d..y * bw + bw - d);
|
||||
// 1. directional greens
|
||||
let mut gh = vec![0f32; n];
|
||||
let mut gv = vec![0f32; n];
|
||||
for y in 0..bh as isize {
|
||||
for x in 0..bw as isize {
|
||||
let i = cl(x, y);
|
||||
let c = mos[i];
|
||||
if col[i] == 1 {
|
||||
gh[i] = c;
|
||||
gv[i] = c;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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);
|
||||
for i in inner(MARGIN - 3) {
|
||||
let c = mos[i];
|
||||
if col[i] == 1 {
|
||||
gh[i] = c;
|
||||
gv[i] = c;
|
||||
continue;
|
||||
}
|
||||
let (l, r) = (mos[i - 1], mos[i + 1]);
|
||||
let est = (l + r) * 0.5 + (2.0 * c - mos[i - 2] - mos[i + 2]) * 0.25;
|
||||
gh[i] = super::clamp_between(est, l, r);
|
||||
let (u, d) = (mos[i - bw], mos[i + bw]);
|
||||
let est = (u + d) * 0.5 + (2.0 * c - mos[i - 2 * bw] - mos[i + 2 * bw]) * 0.25;
|
||||
gv[i] = super::clamp_between(est, u, d);
|
||||
}
|
||||
// 2. full candidates
|
||||
let complete = |g: &[f32]| -> Vec<[f32; 3]> {
|
||||
// 2. full candidates, and 3. their Lab
|
||||
// (`p` with channel `c` set to `v`, in registers: writing channels through an index and reading the pixel
|
||||
// back for its Lab stalled on store forwarding)
|
||||
let set = |p: [f32; 3], c: u8, v: f32| [if c == 0 { v } else { p[0] }, if c == 1 { v } else { p[1] }, if c >= 2 { v } else { p[2] }];
|
||||
let complete = |g: &[f32]| -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
|
||||
let mut rgb = vec![[0f32; 3]; n];
|
||||
for y in 0..bh as isize {
|
||||
for x in 0..bw as isize {
|
||||
let i = cl(x, y);
|
||||
let own = col[i] as usize;
|
||||
let gvv = g[i];
|
||||
let d = |dx: isize, dy: isize| {
|
||||
let j = cl(x + dx, y + dy);
|
||||
mos[j] - g[j]
|
||||
};
|
||||
let p = &mut rgb[i];
|
||||
p[1] = gvv;
|
||||
if own == 1 {
|
||||
let ch = col[cl(x + 1, y)] as usize;
|
||||
let cv = col[cl(x, y + 1)] as usize;
|
||||
p[ch] = gvv + (d(-1, 0) + d(1, 0)) * 0.5;
|
||||
p[cv] = gvv + (d(0, -1) + d(0, 1)) * 0.5;
|
||||
} else {
|
||||
p[own] = mos[i];
|
||||
p[2 - own] = gvv + (d(-1, -1) + d(1, -1) + d(-1, 1) + d(1, 1)) * 0.25;
|
||||
}
|
||||
}
|
||||
let mut lab_of = vec![[0f32; 3]; n];
|
||||
for i in inner(MARGIN - 2) {
|
||||
let own = col[i];
|
||||
let gvv = g[i];
|
||||
let d = |j: usize| mos[j] - g[j];
|
||||
let p = if own == 1 {
|
||||
let p = set([0.0, gvv, 0.0], col[i + 1], gvv + (d(i - 1) + d(i + 1)) * 0.5);
|
||||
set(p, col[i + bw], gvv + (d(i - bw) + d(i + bw)) * 0.5)
|
||||
} else {
|
||||
let p = set([0.0, gvv, 0.0], own, mos[i]);
|
||||
set(p, 2 - own, gvv + (d(i - bw - 1) + d(i - bw + 1) + d(i + bw - 1) + d(i + bw + 1)) * 0.25)
|
||||
};
|
||||
rgb[i] = p;
|
||||
lab_of[i] = lab(p);
|
||||
}
|
||||
rgb
|
||||
(rgb, lab_of)
|
||||
};
|
||||
let rh = complete(&gh);
|
||||
let rv = complete(&gv);
|
||||
// 3. Lab + homogeneity
|
||||
let lh: Vec<[f32; 3]> = rh.iter().map(|&p| lab(p)).collect();
|
||||
let lv: Vec<[f32; 3]> = rv.iter().map(|&p| lab(p)).collect();
|
||||
let (rh, lh) = complete(&gh);
|
||||
let (rv, lv) = complete(&gv);
|
||||
// homogeneity
|
||||
let dl = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).abs();
|
||||
let dc = |a: &[f32; 3], b: &[f32; 3]| (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
|
||||
let mut hh = vec![0u8; n];
|
||||
let mut hv = vec![0u8; n];
|
||||
for y in 1..bh as isize - 1 {
|
||||
for x in 1..bw as isize - 1 {
|
||||
let i = cl(x, y);
|
||||
let (l, r, u, d) = (cl(x - 1, y), cl(x + 1, y), cl(x, y - 1), cl(x, y + 1));
|
||||
let eps_l = dl(&lh[i], &lh[l]).max(dl(&lh[i], &lh[r])).min(dl(&lv[i], &lv[u]).max(dl(&lv[i], &lv[d])));
|
||||
let eps_c = dc(&lh[i], &lh[l]).max(dc(&lh[i], &lh[r])).min(dc(&lv[i], &lv[u]).max(dc(&lv[i], &lv[d])));
|
||||
let mut ch = 0;
|
||||
let mut cv = 0;
|
||||
for j in [l, r, u, d] {
|
||||
if dl(&lh[i], &lh[j]) <= eps_l && dc(&lh[i], &lh[j]) <= eps_c {
|
||||
ch += 1;
|
||||
}
|
||||
if dl(&lv[i], &lv[j]) <= eps_l && dc(&lv[i], &lv[j]) <= eps_c {
|
||||
cv += 1;
|
||||
}
|
||||
}
|
||||
hh[i] = ch;
|
||||
hv[i] = cv;
|
||||
}
|
||||
for i in inner(MARGIN - 1) {
|
||||
// left, right, up, down
|
||||
let (l, r, u, d) = (i - 1, i + 1, i - bw, i + bw);
|
||||
let (a, b) = (lh[i], lv[i]);
|
||||
let dlh = [dl(&a, &lh[l]), dl(&a, &lh[r]), dl(&a, &lh[u]), dl(&a, &lh[d])];
|
||||
let dch = [dc(&a, &lh[l]), dc(&a, &lh[r]), dc(&a, &lh[u]), dc(&a, &lh[d])];
|
||||
let dlv = [dl(&b, &lv[l]), dl(&b, &lv[r]), dl(&b, &lv[u]), dl(&b, &lv[d])];
|
||||
let dcv = [dc(&b, &lv[l]), dc(&b, &lv[r]), dc(&b, &lv[u]), dc(&b, &lv[d])];
|
||||
let eps_l = dlh[0].max(dlh[1]).min(dlv[2].max(dlv[3]));
|
||||
let eps_c = dch[0].max(dch[1]).min(dcv[2].max(dcv[3]));
|
||||
hh[i] = (0..4).map(|k| u8::from(dlh[k] <= eps_l && dch[k] <= eps_c)).sum();
|
||||
hv[i] = (0..4).map(|k| u8::from(dlv[k] <= eps_l && dcv[k] <= eps_c)).sum();
|
||||
}
|
||||
// 4. choose
|
||||
let mut out = Vec::with_capacity(tw * th);
|
||||
for y in MARGIN..MARGIN + th {
|
||||
for x in MARGIN..MARGIN + tw {
|
||||
let (mut sh, mut sv) = (0u32, 0u32);
|
||||
for dy in -1isize..=1 {
|
||||
for dx in -1isize..=1 {
|
||||
let j = cl(x as isize + dx, y as isize + dy);
|
||||
sh += hh[j] as u32;
|
||||
sv += hv[j] as u32;
|
||||
}
|
||||
}
|
||||
let i = y * bw + x;
|
||||
out.push(if sh > sv {
|
||||
rh[i]
|
||||
} else if sv > sh {
|
||||
rv[i]
|
||||
} else {
|
||||
[(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5]
|
||||
});
|
||||
for i in inner(MARGIN) {
|
||||
let (mut sh, mut sv) = (0u32, 0u32);
|
||||
for j in [i - bw - 1, i - bw, i - bw + 1, i - 1, i, i + 1, i + bw - 1, i + bw, i + bw + 1] {
|
||||
sh += hh[j] as u32;
|
||||
sv += hv[j] as u32;
|
||||
}
|
||||
out.push(if sh > sv {
|
||||
rh[i]
|
||||
} else if sv > sh {
|
||||
rv[i]
|
||||
} else {
|
||||
[(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5]
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -338,15 +338,34 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
color: color_data(ifd0, raw, tiff.order),
|
||||
wb_multipliers: None,
|
||||
linearized,
|
||||
pixel_aspect: default_scale_aspect(raw.f64s(t::DEFAULT_SCALE).as_deref()),
|
||||
opcodes,
|
||||
metadata,
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Pixel height over width from `DefaultScale` (horizontal, vertical): only a clear 1.5 to 4 ratio counts (the rows stretch; a wider-than-tall pixel is left alone).
|
||||
fn default_scale_aspect(scale: Option<&[f64]>) -> f32 {
|
||||
match scale {
|
||||
Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn default_scale_makes_tall_pixels() {
|
||||
assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0);
|
||||
assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0);
|
||||
assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0);
|
||||
assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0);
|
||||
assert_eq!(default_scale_aspect(None), 1.0);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row
|
||||
|
||||
@@ -538,8 +538,10 @@ pub fn write_rendered_dng(
|
||||
color,
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: crate::OpcodeLists::default(),
|
||||
metadata: metadata.clone(),
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let order = ByteOrder::Little;
|
||||
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft
|
||||
//! applies.
|
||||
//!
|
||||
//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only
|
||||
//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by
|
||||
//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere
|
||||
//! presence of a table. Everything else here is metadata for diagnostics and later validation work.
|
||||
|
||||
use crate::Rect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or
|
||||
/// whose tables are read straight into opcodes.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LensMetadata {
|
||||
/// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their
|
||||
/// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them.
|
||||
#[serde(default)]
|
||||
pub sony: Option<SonyLensCorrections>,
|
||||
}
|
||||
|
||||
/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table.
|
||||
///
|
||||
/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032`
|
||||
/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration),
|
||||
/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting
|
||||
/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings
|
||||
/// in every file inspected.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SonyLensCorrections {
|
||||
/// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not
|
||||
/// established. Opaque signed data.
|
||||
pub shading: SonyComponent<[i16; 16]>,
|
||||
/// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue,
|
||||
/// relative to green; that is not validated here.
|
||||
pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>,
|
||||
/// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation).
|
||||
pub distortion: SonyComponent<[i16; 16]>,
|
||||
/// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare.
|
||||
pub sr2_read: bool,
|
||||
/// The framing and coding the tables have to be interpreted against.
|
||||
pub geometry: SonyLensGeometry,
|
||||
}
|
||||
|
||||
/// One correction component: the camera's setting, its table, and whether LightCraft applies it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SonyComponent<T> {
|
||||
pub setting: SonyField<SonySetting>,
|
||||
/// The table's samples after the leading sample count, which must equal their number.
|
||||
pub table: SonyField<T>,
|
||||
pub status: SonyLensStatus,
|
||||
}
|
||||
|
||||
/// One field read from the raw IFD and from the `SR2SubIFD`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SonyField<T> {
|
||||
/// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD
|
||||
/// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is
|
||||
/// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes
|
||||
/// the image parsed (its first occurrence, within the reader's limits).
|
||||
pub value: Option<T>,
|
||||
pub source: Option<SonySource>,
|
||||
pub raw_ifd: SonyCopy,
|
||||
pub sr2: SonyCopy,
|
||||
/// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ.
|
||||
pub copies_agree: Option<bool>,
|
||||
/// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used,
|
||||
/// as the TIFF reader does (the raw IFD's: the first one whose values it could read).
|
||||
#[serde(default)]
|
||||
pub raw_ifd_repeated: bool,
|
||||
#[serde(default)]
|
||||
pub sr2_repeated: bool,
|
||||
}
|
||||
|
||||
/// Where a value was read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SonySource {
|
||||
/// The plain raw image IFD.
|
||||
RawIfd,
|
||||
/// The encrypted `SR2SubIFD`.
|
||||
Sr2SubIfd,
|
||||
}
|
||||
|
||||
/// How one copy of a field was found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SonyCopy {
|
||||
/// Not in that directory (or the `SR2SubIFD` could not be read).
|
||||
Absent,
|
||||
/// Present with exactly the supported type and layout.
|
||||
Valid,
|
||||
/// Present, but refused.
|
||||
Rejected(SonyFieldError),
|
||||
}
|
||||
|
||||
/// Why a present field was refused.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SonyFieldError {
|
||||
/// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting.
|
||||
FieldType(u16),
|
||||
/// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting).
|
||||
Count(u64),
|
||||
/// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the
|
||||
/// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting.
|
||||
CountPrefix(i16),
|
||||
/// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`).
|
||||
OutsideData,
|
||||
/// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a
|
||||
/// total value budget), so the image doesn't use it either.
|
||||
PastReaderLimits,
|
||||
/// A setting stored as a negative SSHORT.
|
||||
NegativeSetting(i16),
|
||||
}
|
||||
|
||||
/// A camera correction setting: the stored number and its documented meaning.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SonySetting {
|
||||
pub value: u16,
|
||||
pub meaning: SonySettingMeaning,
|
||||
}
|
||||
|
||||
/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole;
|
||||
/// they are not read as bit masks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SonySettingMeaning {
|
||||
/// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent.
|
||||
Off,
|
||||
/// Shading 257 or 272, CA 1, distortion 1.
|
||||
Auto,
|
||||
/// Distortion 17: Auto, fixed by the lens.
|
||||
ForcedAuto,
|
||||
/// "No correction params available": shading 511, CA 255, distortion 255.
|
||||
Unavailable,
|
||||
/// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// Whether a component is applied, and if not, why.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SonyLensStatus {
|
||||
/// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing).
|
||||
Applied,
|
||||
/// No usable table.
|
||||
NoTable,
|
||||
/// Decoded only: no independent validation covers this component's rendering semantics on any body (shading,
|
||||
/// lateral CA).
|
||||
NotValidatedComponent,
|
||||
/// Decoded only: the component is validated on other camera models, not on this one.
|
||||
NotValidatedModel,
|
||||
/// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a
|
||||
/// table found only in the `SR2SubIFD`.
|
||||
NotValidatedMode,
|
||||
/// Validated scope, but the table is all zeros: a valid no-op, nothing to apply.
|
||||
Neutral,
|
||||
/// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate
|
||||
/// crop, or a fitted map that misses the table or folds).
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SonyLensGeometry {
|
||||
/// The stored raw frame, including any padding (lossless L files are padded).
|
||||
pub storage_width: usize,
|
||||
pub storage_height: usize,
|
||||
/// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded.
|
||||
pub sony_crop: Option<Rect>,
|
||||
/// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded.
|
||||
pub default_crop: Option<Rect>,
|
||||
/// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size).
|
||||
pub image_crop: Rect,
|
||||
/// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when
|
||||
/// the file has one, else the image crop. Clipped to the frame.
|
||||
pub reference: Rect,
|
||||
/// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6).
|
||||
pub compression: u16,
|
||||
/// Downsized lossless M/S data (linear YCbCr, not sensor CFA).
|
||||
pub linear_ycbcr: bool,
|
||||
/// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated.
|
||||
pub native_3_2: bool,
|
||||
}
|
||||
|
||||
impl<T> SonyField<T> {
|
||||
/// Whether either directory holds the tag (well-formed or not).
|
||||
pub fn is_present(&self) -> bool {
|
||||
self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent
|
||||
}
|
||||
}
|
||||
+53
-4
@@ -35,6 +35,7 @@ pub mod highlight;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
pub mod kelvin_presets;
|
||||
pub mod lens_metadata;
|
||||
pub mod ljpeg;
|
||||
pub(crate) mod llvc;
|
||||
pub mod opcodes;
|
||||
@@ -51,6 +52,7 @@ mod wb_estimate;
|
||||
|
||||
pub use demosaic::{Method, demosaic};
|
||||
pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng};
|
||||
pub use lens_metadata::LensMetadata;
|
||||
pub use lightcraft_color::Mat3;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use lightcraft_meta::Metadata;
|
||||
@@ -430,13 +432,30 @@ pub struct RawInfo {
|
||||
pub wb_multipliers: Option<[f32; 3]>,
|
||||
pub opcodes: OpcodeLists,
|
||||
pub metadata: Metadata,
|
||||
/// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]).
|
||||
#[serde(default)]
|
||||
pub lens_metadata: LensMetadata,
|
||||
/// See [`RawImage::pixel_aspect`].
|
||||
#[serde(default = "square_pixels")]
|
||||
pub pixel_aspect: f32,
|
||||
}
|
||||
|
||||
fn square_pixels() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square.
|
||||
pub fn stretched_height(height: usize, aspect: f32) -> usize {
|
||||
if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) }
|
||||
}
|
||||
|
||||
impl RawInfo {
|
||||
/// Size of the developed image before orientation: the default crop, else the active area.
|
||||
/// Size of the developed image before orientation: the default crop, else the active area, with the rows
|
||||
/// stretched for non-square pixels.
|
||||
pub fn developed_size(&self) -> (usize, usize) {
|
||||
let c = self.crop.clipped(self.active_area.width, self.active_area.height);
|
||||
if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }
|
||||
let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) };
|
||||
(w, stretched_height(h, self.pixel_aspect))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,8 +680,16 @@ pub struct RawImage {
|
||||
pub wb_multipliers: Option<[f32; 3]>,
|
||||
/// Whether a `LinearizationTable` was applied to `data`.
|
||||
pub linearized: bool,
|
||||
/// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is
|
||||
/// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by
|
||||
/// this factor.
|
||||
pub pixel_aspect: f32,
|
||||
pub opcodes: OpcodeLists,
|
||||
pub metadata: Metadata,
|
||||
/// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only
|
||||
/// validated components are in `opcodes`.
|
||||
#[serde(default)]
|
||||
pub lens_metadata: LensMetadata,
|
||||
}
|
||||
|
||||
/// Black-subtracted, white-normalised data covering the active area.
|
||||
@@ -694,6 +721,8 @@ impl RawImage {
|
||||
wb_multipliers: self.wb_multipliers,
|
||||
opcodes: self.opcodes.clone(),
|
||||
metadata: self.metadata.clone(),
|
||||
lens_metadata: self.lens_metadata.clone(),
|
||||
pixel_aspect: self.pixel_aspect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +742,8 @@ impl RawImage {
|
||||
wb_multipliers: self.wb_multipliers,
|
||||
opcodes: self.opcodes,
|
||||
metadata: self.metadata,
|
||||
lens_metadata: self.lens_metadata,
|
||||
pixel_aspect: self.pixel_aspect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,10 +847,13 @@ impl RawImage {
|
||||
pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f {
|
||||
opcodes::apply_list3(&self.opcodes.list3, &mut rgb);
|
||||
let c = self.develop_crop(rgb.width, rgb.height);
|
||||
if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height {
|
||||
let rgb =
|
||||
if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { rgb } else { rgb.into_crop(c.x, c.y, c.width, c.height) };
|
||||
let h = stretched_height(rgb.height, self.pixel_aspect);
|
||||
if h == rgb.height {
|
||||
return rgb;
|
||||
}
|
||||
rgb.into_crop(c.x, c.y, c.width, c.height)
|
||||
lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell)
|
||||
}
|
||||
|
||||
/// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default
|
||||
@@ -878,8 +912,10 @@ mod tests {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Metadata::default(),
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let direct = raw.develop(Method::Bilinear).unwrap();
|
||||
let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear));
|
||||
@@ -894,6 +930,17 @@ mod tests {
|
||||
// a crop that pokes out is clipped to the picture
|
||||
whole.crop = Rect::new(6, 4, 10, 10);
|
||||
assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2));
|
||||
|
||||
// tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so
|
||||
let mut tall = raw.clone();
|
||||
tall.pixel_aspect = 2.0;
|
||||
let d = tall.develop(Method::Bilinear).unwrap();
|
||||
assert_eq!((d.width, d.height), (5, 8));
|
||||
assert_eq!(tall.info().developed_size(), (5, 8));
|
||||
assert_eq!(raw.info().developed_size(), (5, 4));
|
||||
assert!(!tall.can_bin(2));
|
||||
assert_eq!(stretched_height(1324, 2.0), 2648);
|
||||
assert_eq!(stretched_height(1324, 1.0), 1324);
|
||||
}
|
||||
|
||||
/// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to
|
||||
@@ -920,6 +967,8 @@ mod tests {
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Metadata::default(),
|
||||
lens_metadata: Default::default(),
|
||||
pixel_aspect: 1.0,
|
||||
};
|
||||
assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image");
|
||||
assert!(raw.normalized().is_err());
|
||||
|
||||
+114
-49
@@ -14,6 +14,7 @@ fn corrupt(why: &str) -> RawError {
|
||||
}
|
||||
|
||||
/// One bit, failing when the reader has run past the `source_bits` of its half.
|
||||
#[inline(always)]
|
||||
fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> {
|
||||
let b = bits.get(1);
|
||||
if bits.consumed_bits() > source_bits {
|
||||
@@ -24,12 +25,14 @@ fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> {
|
||||
|
||||
/// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`.
|
||||
fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> {
|
||||
let mut n = 0;
|
||||
while bit(bits, source_bits)? == 0 {
|
||||
n += 1;
|
||||
if n > MAX_DPT {
|
||||
return Err(corrupt("DPT code exceeds MAX_DPT"));
|
||||
}
|
||||
// the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way)
|
||||
let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1));
|
||||
bits.skip((n + 1).min(MAX_DPT + 1));
|
||||
if bits.consumed_bits() > source_bits {
|
||||
return Err(corrupt("past the end of its half"));
|
||||
}
|
||||
if n > MAX_DPT {
|
||||
return Err(corrupt("DPT code exceeds MAX_DPT"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -42,7 +45,7 @@ pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: us
|
||||
let sets = width.div_ceil(4);
|
||||
let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8);
|
||||
let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64);
|
||||
let mut out: Vec<i32> = Vec::new();
|
||||
let mut out: Vec<i32> = Vec::with_capacity(sets * 4);
|
||||
loop {
|
||||
// DPT value decoding (Figure 6.6).
|
||||
if cnt == 0 {
|
||||
@@ -180,43 +183,80 @@ pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) {
|
||||
(low, n - low)
|
||||
}
|
||||
|
||||
/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band.
|
||||
fn at(v: &[i32], i: isize) -> i32 {
|
||||
let last = v.len() as isize - 1;
|
||||
if last < 0 {
|
||||
return 0;
|
||||
}
|
||||
v.get(i.clamp(0, last) as usize).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Saturating narrowing: hostile coefficients clamp instead of overflowing.
|
||||
fn sat(v: i64) -> i32 {
|
||||
v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
|
||||
}
|
||||
|
||||
/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes).
|
||||
fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result<Vec<i32>> {
|
||||
let (n, p) = (l.len() + h.len(), isize::from(phase));
|
||||
if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 {
|
||||
return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into()));
|
||||
/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension);
|
||||
/// `zeros` for an empty band.
|
||||
#[inline]
|
||||
fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] {
|
||||
if n == 0 {
|
||||
return zeros;
|
||||
}
|
||||
let even: Vec<i32> = l
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2)))
|
||||
.collect();
|
||||
let mut x = vec![0; n];
|
||||
let (lo, hi) = (usize::from(phase), 1 - usize::from(phase));
|
||||
for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) {
|
||||
*xv = e;
|
||||
}
|
||||
for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() {
|
||||
*xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1));
|
||||
}
|
||||
Ok(x)
|
||||
let i = i.clamp(0, n as isize - 1) as usize;
|
||||
v.get(i * w..(i + 1) * w).unwrap_or(zeros)
|
||||
}
|
||||
|
||||
/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back.
|
||||
/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a
|
||||
/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column
|
||||
/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`].
|
||||
fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result<Vec<i32>> {
|
||||
use rayon::prelude::*;
|
||||
let w = w.max(1);
|
||||
let (nl, nh) = (l.len() / w, h.len() / w);
|
||||
let (n, p) = (nl + nh, isize::from(phase));
|
||||
if band_rows(n, phase) != (nl, nh) || phase > 1 {
|
||||
return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into()));
|
||||
}
|
||||
let zeros = vec![0; w];
|
||||
let mut even = vec![0; nl * w];
|
||||
even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| {
|
||||
let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros));
|
||||
for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) {
|
||||
*e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2));
|
||||
}
|
||||
});
|
||||
let mut out = vec![0; n * w];
|
||||
let lo = usize::from(phase);
|
||||
out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| {
|
||||
if t % 2 == lo {
|
||||
if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) {
|
||||
o.copy_from_slice(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let k = (t - (1 - lo)) / 2;
|
||||
let Some(hv) = h.get(k * w..(k + 1) * w) else { return };
|
||||
let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros));
|
||||
for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) {
|
||||
*o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1));
|
||||
}
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is
|
||||
/// scratch space. The caller has checked the band sizes.
|
||||
fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec<i32>, out: &mut [i32]) {
|
||||
let (nl, nh, p) = (l.len(), h.len(), isize::from(phase));
|
||||
let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) };
|
||||
even.clear();
|
||||
even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2))));
|
||||
let even = &*even;
|
||||
let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) };
|
||||
let (lo, hi) = (usize::from(phase), 1 - usize::from(phase));
|
||||
for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) {
|
||||
*o = e;
|
||||
}
|
||||
for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() {
|
||||
*o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by
|
||||
/// row), else along each row.
|
||||
fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> {
|
||||
if !low.ok() || !high.ok() {
|
||||
return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into()));
|
||||
@@ -228,20 +268,22 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane>
|
||||
let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) };
|
||||
let n = nl + nh;
|
||||
let (w, h) = if vertical { (across_l, n) } else { (n, across_l) };
|
||||
let mut out = Plane::zeros(w, h);
|
||||
let get = |p: &Plane, i: usize| -> Vec<i32> {
|
||||
if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() }
|
||||
};
|
||||
for i in 0..across_l {
|
||||
let x = inverse_1d(&get(low, i), &get(high, i), phase)?;
|
||||
if vertical {
|
||||
for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) {
|
||||
*o = v;
|
||||
}
|
||||
} else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) {
|
||||
r.copy_from_slice(&x);
|
||||
}
|
||||
if across_l == 0 {
|
||||
return Ok(Plane::zeros(w, h));
|
||||
}
|
||||
if vertical {
|
||||
return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? });
|
||||
}
|
||||
if band_rows(n, phase) != (nl, nh) || phase > 1 {
|
||||
return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into()));
|
||||
}
|
||||
use rayon::prelude::*;
|
||||
let mut out = Plane::zeros(w, h);
|
||||
out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| {
|
||||
if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) {
|
||||
lift_row(l, hi, phase, even, r);
|
||||
}
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -448,6 +490,29 @@ mod tests {
|
||||
assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]);
|
||||
}
|
||||
#[test]
|
||||
fn horizontal_pass_gives_the_vertical_passs_samples() {
|
||||
// two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns
|
||||
let mut s = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
(s % 8191) as i32 - 4095
|
||||
};
|
||||
for n in 1..12usize {
|
||||
for phase in 0..2u8 {
|
||||
let ((nl, nh), rows) = (band_rows(n, phase), 3);
|
||||
let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() };
|
||||
let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() };
|
||||
let across = inverse_53_1d_cols(&low, &high, phase).unwrap();
|
||||
for r in 0..rows {
|
||||
let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap();
|
||||
assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn inverse_53_rejects_mismatched_bands() {
|
||||
assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err());
|
||||
assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err());
|
||||
|
||||
@@ -123,6 +123,177 @@ impl HsvTable {
|
||||
};
|
||||
hsv_to_rgb(hue + shift, s2, v2)
|
||||
}
|
||||
|
||||
/// [`Self::apply`] to `N` colours at once (`rgb[channel][lane]`), bit for bit: the same operations, with the
|
||||
/// branches on the data (largest channel, hue sector, wrap-around) turned into selects so the compiler
|
||||
/// vectorises them. The table reads, `powf` and the rare `rem_euclid` run lane by lane.
|
||||
pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] {
|
||||
let [r, g, b] = rgb;
|
||||
if !self.fits_lanes() {
|
||||
let mut out = rgb;
|
||||
for l in 0..N {
|
||||
[out[0][l], out[1][l], out[2][l]] = self.apply([r[l], g[l], b[l]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// `active`: the colours the table applies to (`apply` returns black, NaN and infinite ones as they are)
|
||||
let (mut max, mut active, mut first) = ([0.0f32; N], [true; N], [false; N]);
|
||||
let (mut d, mut ratio, mut second, mut third) = ([0.0f32; N], [0.0f32; N], [0.0f32; N], [0.0f32; N]);
|
||||
for l in 0..N {
|
||||
let m = r[l].max(g[l]).max(b[l]);
|
||||
active[l] = !(m.is_nan() | (m <= 0.0) | !m.is_finite());
|
||||
d[l] = m - r[l].min(g[l]).min(b[l]);
|
||||
// hue_of, for each largest channel
|
||||
ratio[l] = (g[l] - b[l]) / d[l];
|
||||
second[l] = (b[l] - r[l]) / d[l] + 2.0;
|
||||
third[l] = (r[l] - g[l]) / d[l] + 4.0;
|
||||
first[l] = active[l] & (d[l] > 0.0) & (m == r[l]);
|
||||
max[l] = m;
|
||||
}
|
||||
let ratio = wrap_lanes(ratio, 6.0, first);
|
||||
let (mut hue, mut sat) = ([0.0f32; N], [0.0f32; N]);
|
||||
for l in 0..N {
|
||||
let h = if max[l] == r[l] {
|
||||
ratio[l]
|
||||
} else if max[l] == g[l] {
|
||||
second[l]
|
||||
} else {
|
||||
third[l]
|
||||
};
|
||||
let h = h * 60.0;
|
||||
hue[l] = if d[l] <= 0.0 { 0.0 } else { h };
|
||||
sat[l] = d[l] / max[l];
|
||||
}
|
||||
let mut v_index = max;
|
||||
if self.srgb_value {
|
||||
for l in 0..N {
|
||||
if active[l] {
|
||||
v_index[l] = linear_to_srgb(max[l].min(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
let [shift, ss, vs] = self.lookup_lanes(&hue, &sat, &v_index, active);
|
||||
let (mut s2, mut v2, mut curve) = ([0.0f32; N], [0.0f32; N], [false; N]);
|
||||
for l in 0..N {
|
||||
s2[l] = limited_select(sat[l], ss[l]);
|
||||
v2[l] = if self.srgb_value { max[l] } else { limited_select(max[l], vs[l]) };
|
||||
curve[l] = self.srgb_value & active[l] & !((max[l] <= 1.0) & (vs[l] == 1.0));
|
||||
}
|
||||
for l in 0..N {
|
||||
if curve[l] {
|
||||
v2[l] = if max[l] <= 1.0 { srgb_to_linear((v_index[l] * vs[l]).min(1.0)) } else { max[l] * srgb_to_linear(vs[l].min(1.0)).min(1.0) };
|
||||
}
|
||||
}
|
||||
// hsv_to_rgb
|
||||
let hue = wrap_lanes(std::array::from_fn(|l| hue[l] + shift[l]), 360.0, active);
|
||||
let mut out = rgb;
|
||||
for l in 0..N {
|
||||
let hp = hue[l] / 60.0;
|
||||
// `(hp as u32).min(5) as f32` for every `hp`, NaN included
|
||||
let step = |k: f32| if hp >= k { 1.0 } else { 0.0 };
|
||||
let sector = step(1.0) + step(2.0) + step(3.0) + step(4.0) + step(5.0);
|
||||
let f = hp - sector;
|
||||
let (s, v) = (s2[l], v2[l]);
|
||||
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
|
||||
let red = if (sector == 0.0) | (sector == 5.0) {
|
||||
v
|
||||
} else if sector == 1.0 {
|
||||
q
|
||||
} else if sector == 4.0 {
|
||||
t
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let green = if sector == 0.0 {
|
||||
t
|
||||
} else if sector <= 2.0 {
|
||||
v
|
||||
} else if sector == 3.0 {
|
||||
q
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let blue = if sector <= 1.0 {
|
||||
p
|
||||
} else if sector == 2.0 {
|
||||
t
|
||||
} else if sector == 5.0 {
|
||||
q
|
||||
} else {
|
||||
v
|
||||
};
|
||||
out[0][l] = if active[l] { red } else { r[l] };
|
||||
out[1][l] = if active[l] { green } else { g[l] };
|
||||
out[2][l] = if active[l] { blue } else { b[l] };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether [`Self::lookup_lanes`] can do this table's index arithmetic in `f32`: every index below 2²³.
|
||||
fn fits_lanes(&self) -> bool {
|
||||
let entries = self.hue_divisions.checked_mul(self.sat_divisions).and_then(|n| n.checked_mul(self.val_divisions.max(1)));
|
||||
self.hue_divisions >= 1 && self.sat_divisions >= 1 && entries.is_some_and(|n| n <= 1 << 23)
|
||||
}
|
||||
|
||||
/// [`Self::lookup`] at `N` points (`(shift, saturation scale, value scale)[lane]`), bit for bit for the lanes
|
||||
/// `wanted`, on a table that [`Self::fits_lanes`]. The indices are computed as `f32` (exact integers below 2²³:
|
||||
/// `x as usize` is a saturating conversion that doesn't vectorise).
|
||||
fn lookup_lanes<const N: usize>(&self, h: &[f32; N], s: &[f32; N], v: &[f32; N], wanted: [bool; N]) -> [[f32; N]; 3] {
|
||||
const EXACT: f32 = 8_388_608.0; // 2²³: ulp 1 from here
|
||||
// `x as usize` for `0 ≤ x < 2²³`: rounded to an integer, minus 1 if it rounded up; NaN is 0
|
||||
let floor = |x: f32| {
|
||||
let nearest = (x + EXACT) - EXACT;
|
||||
let floor = if nearest > x { nearest - 1.0 } else { nearest };
|
||||
if x.is_nan() { 0.0 } else { floor }
|
||||
};
|
||||
let (hn, sn, vn) = (self.hue_divisions as f32, self.sat_divisions as f32, self.val_divisions as f32);
|
||||
let axis = |x: f32, n: f32| {
|
||||
let f = x.clamp(0.0, 1.0) * (n - 1.0);
|
||||
let i0 = floor(f).min((n - 2.0).max(0.0));
|
||||
let i1 = (i0 + 1.0).min(n - 1.0);
|
||||
(i0, i1, (f - i0).clamp(0.0, 1.0))
|
||||
};
|
||||
let h = wrap_lanes(*h, 360.0, wanted);
|
||||
// the corners' indices (v, h, s), s fastest, and the weights between them
|
||||
let (mut at, mut t) = ([[0u32; N]; 8], [[0.0f32; N]; 3]);
|
||||
for l in 0..N {
|
||||
let hf = (h[l] / 360.0 * hn).clamp(0.0, hn);
|
||||
let h0 = floor(hf).min(hn - 1.0);
|
||||
// (h0 + 1) % hn
|
||||
let h1 = if h0 + 1.0 == hn { 0.0 } else { h0 + 1.0 };
|
||||
let (s0, s1, ts) = axis(s[l], sn);
|
||||
let (v0, v1, tv) = axis(v[l], vn);
|
||||
let (v0, v1, tv) = if self.val_divisions > 1 { (v0, v1, tv) } else { (0.0, 0.0, 0.0) };
|
||||
for (k, (vi, hi)) in [(v0, h0), (v0, h1), (v1, h0), (v1, h1)].into_iter().enumerate() {
|
||||
let row = (vi * hn + hi) * sn;
|
||||
// an exact integer below 2²³ plus 2²³ has it as its mantissa
|
||||
at[2 * k][l] = (row + s0 + EXACT).to_bits().wrapping_sub(EXACT.to_bits());
|
||||
at[2 * k + 1][l] = (row + s1 + EXACT).to_bits().wrapping_sub(EXACT.to_bits());
|
||||
}
|
||||
[t[0][l], t[1][l], t[2][l]] = [(hf - h0).clamp(0.0, 1.0), ts, tv];
|
||||
}
|
||||
let mut corner = [[[0.0f32; N]; 3]; 8];
|
||||
for l in 0..N {
|
||||
for (corner, at) in corner.iter_mut().zip(&at) {
|
||||
[corner[0][l], corner[1][l], corner[2][l]] = self.data.get(at[l] as usize).copied().unwrap_or([0.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let lerp = |a: &[f32; N], b: &[f32; N], t: &[f32; N]| {
|
||||
let mut out = [0.0f32; N];
|
||||
for l in 0..N {
|
||||
out[l] = a[l] + (b[l] - a[l]) * t[l];
|
||||
}
|
||||
out
|
||||
};
|
||||
let [th, ts, tv] = &t;
|
||||
let mut out = [[0.0f32; N]; 3];
|
||||
for (c, out) in out.iter_mut().enumerate() {
|
||||
let plane = |k: usize| lerp(&lerp(&corner[k][c], &corner[k + 1][c], ts), &lerp(&corner[k + 2][c], &corner[k + 3][c], ts), th);
|
||||
// `lookup`'s `v0 == v1` for every lane: `axis` gives `v1 = v0 + 1` whenever there are two values or more
|
||||
*out = if self.val_divisions > 1 { lerp(&plane(0), &plane(4), tv) } else { plane(0) };
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1).
|
||||
@@ -145,6 +316,37 @@ fn wrap(x: f32, p: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`wrap`] of `N` values, bit for bit for the lanes `wanted`: branch-free but for the `rem_euclid` fallback.
|
||||
#[inline]
|
||||
fn wrap_lanes<const N: usize>(x: [f32; N], p: f32, wanted: [bool; N]) -> [f32; N] {
|
||||
let (mut out, mut slow) = ([0.0f32; N], [false; N]);
|
||||
for l in 0..N {
|
||||
let (first, below, second) = ((x[l] >= 0.0) & (x[l] < p), (x[l] > -p) & (x[l] < 0.0), (x[l] >= p) & (x[l] < 2.0 * p));
|
||||
let (up, down) = (x[l] + p, x[l] - p);
|
||||
out[l] = if first {
|
||||
x[l]
|
||||
} else if below {
|
||||
up
|
||||
} else {
|
||||
down
|
||||
};
|
||||
slow[l] = wanted[l] & !(first | below | second);
|
||||
}
|
||||
for l in 0..N {
|
||||
if slow[l] {
|
||||
out[l] = x[l].rem_euclid(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`limited`] without a branch.
|
||||
#[inline]
|
||||
fn limited_select(x: f32, k: f32) -> f32 {
|
||||
let (below, above) = ((x * k).min(1.0), x * k.min(1.0));
|
||||
if x <= 1.0 { below } else { above }
|
||||
}
|
||||
|
||||
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
|
||||
if d <= 0.0 {
|
||||
return 0.0;
|
||||
@@ -330,6 +532,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every combination of awkward components (zero, negative, tiny, around 1, huge, NaN, ±infinity), and random
|
||||
/// colours from black to above 1: grey, each channel largest, hues either side of the wrap-around at 0°.
|
||||
fn awkward_colours() -> Vec<[f32; 3]> {
|
||||
let special = [0.0, -0.0, 1e-30, -1e-30, 1e-45, 0.003_130_8, 0.5, -0.5, 1.0, 1.0f32.next_up(), 2.0, 1e30, -1e30];
|
||||
let special = [&special[..], &[f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]].concat();
|
||||
let mut out = Vec::new();
|
||||
for a in &special {
|
||||
for b in &special {
|
||||
out.extend(special.iter().map(|c| [*a, *b, *c]));
|
||||
}
|
||||
}
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
for _ in 0..40_000 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let scale = [1.0, 1e-4, 8.0, 1e-38][(seed >> 56) as usize & 3];
|
||||
let c: [f32; 3] = std::array::from_fn(|i| (((seed >> (16 * i)) & 0xffff) as f32 / 65535.0 * 1.6 - 0.2) * scale);
|
||||
out.push(match seed >> 60 {
|
||||
0 => [c[0]; 3],
|
||||
1 => [c[0].abs() + 0.1, c[1], c[1].next_up()],
|
||||
2 => [c[0].abs() + 0.1, c[1], c[1].next_down()],
|
||||
_ => c,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `apply_lanes` against `apply`, bit for bit, `N` colours at a time (the last batch padded).
|
||||
fn assert_lanes_match<const N: usize>(t: &HsvTable, colours: &[[f32; 3]]) {
|
||||
for chunk in colours.chunks(N) {
|
||||
let rgb: [[f32; N]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.25, |p| p[c])));
|
||||
let out = t.apply_lanes(rgb);
|
||||
for (l, p) in chunk.iter().enumerate() {
|
||||
let (got, want) = ([out[0][l], out[1][l], out[2][l]], t.apply(*p));
|
||||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lanes_match_one_by_one_bit_for_bit() {
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut table = |h: usize, s: usize, v: usize, srgb_value: bool, shift: f32, value_scale: bool| {
|
||||
let data = (0..h * s * v)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let x = |k: u64| ((seed >> k) & 0xffff) as f32 / 65535.0;
|
||||
[(x(0) * 2.0 - 1.0) * shift, 0.5 + x(16), if value_scale { 0.5 + x(32) } else { 1.0 }]
|
||||
})
|
||||
.collect();
|
||||
HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value }
|
||||
};
|
||||
let colours = awkward_colours();
|
||||
// a fitted camera look's shape; with value scales; a 2D DNG map turning hues by up to 5 turns; one hue and
|
||||
// one saturation
|
||||
for t in [
|
||||
table(72, 5, 5, true, 30.0, false),
|
||||
table(72, 5, 5, true, 30.0, true),
|
||||
table(90, 30, 1, false, 1800.0, true),
|
||||
table(1, 1, 3, true, 30.0, true),
|
||||
] {
|
||||
assert_lanes_match::<16>(&t, &colours);
|
||||
assert_lanes_match::<5>(&t, &colours);
|
||||
}
|
||||
// entries missing (the identity fallback); too big for the lanes' index arithmetic (one by one)
|
||||
let short = HsvTable { data: vec![[10.0, 1.5, 0.8]; 7], ..identity(4, 3, 2) };
|
||||
let huge = HsvTable { data: Vec::new(), ..identity(4096, 4096, 1) };
|
||||
for t in [short, huge] {
|
||||
assert_lanes_match::<16>(&t, &colours);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
@@ -135,8 +135,10 @@ mod tests {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Metadata::default(),
|
||||
lens_metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+47
-22
@@ -45,8 +45,9 @@
|
||||
//! through the generic TIFF path.
|
||||
|
||||
use super::arw6;
|
||||
use crate::lens_metadata::SonyLensGeometry;
|
||||
use crate::tiffraw::{Packing, check_image, read_image_in};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
@@ -553,6 +554,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option<Rect> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`.
|
||||
fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option<Rect> {
|
||||
let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None };
|
||||
let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None };
|
||||
let n = |v: u64| usize::try_from(v).ok();
|
||||
Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?))
|
||||
}
|
||||
|
||||
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
|
||||
/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]).
|
||||
///
|
||||
@@ -749,6 +758,39 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
// The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has
|
||||
// one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and
|
||||
// the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area
|
||||
// coordinates, so it stays on the optical centre whichever crop frames the image.
|
||||
let reference = dng_default_crop(raw, w, h).unwrap_or(crop);
|
||||
let geometry = SonyLensGeometry {
|
||||
storage_width: w,
|
||||
storage_height: h,
|
||||
sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE),
|
||||
default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE),
|
||||
image_crop: crop,
|
||||
reference,
|
||||
compression: info.compression,
|
||||
linear_ycbcr: linear_rgb,
|
||||
native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference),
|
||||
};
|
||||
// decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by
|
||||
// reading only three `SR2Private` entries (see `sr2::SubIfd::read`)
|
||||
let (mut sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry);
|
||||
// The ILCE-7RM6's tables follow other conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]);
|
||||
// its allowlist and `arw_lens`'s are disjoint, so at most one of them produces an opcode. Both the image framing
|
||||
// and the table geometry must stay within the validated 3:2 scope.
|
||||
let rm6: Vec<crate::Opcode> = if linear_rgb || !super::arw_lens::is_3_2(crop) {
|
||||
Vec::new()
|
||||
} else {
|
||||
super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference)
|
||||
};
|
||||
if !rm6.is_empty()
|
||||
&& let Some(record) = sony_lens.as_mut()
|
||||
{
|
||||
// distortion only: its lateral CA planes are off (see `arw_lens_rm6::lens_opcodes`)
|
||||
record.distortion.status = crate::lens_metadata::SonyLensStatus::Applied;
|
||||
}
|
||||
let img = RawImage {
|
||||
format: RawFormat::Arw,
|
||||
width: w,
|
||||
@@ -768,28 +810,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
// 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 },
|
||||
linearized: false,
|
||||
// The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A
|
||||
// it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was
|
||||
// validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so
|
||||
// it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other
|
||||
// conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are
|
||||
// disjoint, so at most one of them produces an opcode.
|
||||
opcodes: OpcodeLists {
|
||||
// DefaultCrop can retain native geometry while Sony's tags select an aspect crop.
|
||||
// Both the image framing and the table geometry must stay within the validated 3:2 scope.
|
||||
list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 {
|
||||
Vec::new()
|
||||
} else {
|
||||
let geometry = dng_default_crop(raw, w, h).unwrap_or(crop);
|
||||
let active = Rect::new(0, 0, w, h);
|
||||
super::arw_lens::distortion(&model, raw, active, geometry)
|
||||
.into_iter()
|
||||
.chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry))
|
||||
.collect()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
pixel_aspect: 1.0,
|
||||
// only validated components become opcodes (see `arw_lens`, and `arw_lens_rm6` for the ILCE-7RM6)
|
||||
opcodes: OpcodeLists { list3: warp.into_iter().chain(rm6).collect(), ..Default::default() },
|
||||
metadata,
|
||||
lens_metadata: LensMetadata { sony: sony_lens },
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
|
||||
Vendored
+26
-25
@@ -329,9 +329,10 @@ type HalfLines = (Half, Vec<Vec<i32>>);
|
||||
fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result<Vec<HalfLines>> {
|
||||
let halves = stream_halves(tile, st, ntu)?;
|
||||
let width = line_width(st.group, w2);
|
||||
// halves are independent bit streams: decode them in parallel
|
||||
halves
|
||||
.into_iter()
|
||||
.zip(layouts)
|
||||
.into_par_iter()
|
||||
.zip(layouts.par_iter())
|
||||
.map(|(half, rows)| {
|
||||
let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?;
|
||||
let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?;
|
||||
@@ -468,36 +469,36 @@ pub(crate) fn colour(p: &TilePlanes) -> Result<Vec<u16>> {
|
||||
}
|
||||
let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?;
|
||||
let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?;
|
||||
let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 };
|
||||
// (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match)
|
||||
let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0));
|
||||
let mut g1 = vec![0i64; len];
|
||||
for j in 0..h2 {
|
||||
let jp = j.saturating_sub(1);
|
||||
for x in 0..w2 {
|
||||
g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| {
|
||||
let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return };
|
||||
for (x, g) in row.iter_mut().enumerate() {
|
||||
let xn = (x + 1).min(w2 - 1);
|
||||
let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn);
|
||||
if let Some(g) = g1.get_mut(j * w2 + x) {
|
||||
*g = at(&p.m, j, x) - ((r + 4) >> 3);
|
||||
}
|
||||
let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn);
|
||||
*g = at(m, x) - ((r + 4) >> 3);
|
||||
}
|
||||
}
|
||||
let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0);
|
||||
});
|
||||
let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]);
|
||||
let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0);
|
||||
let mut out = vec![0u16; total];
|
||||
for j in 0..h2 {
|
||||
let jn = (j + 1).min(h2 - 1);
|
||||
for x in 0..w2 {
|
||||
// each plane row gives two mosaic rows
|
||||
out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| {
|
||||
let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1)));
|
||||
let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else {
|
||||
return;
|
||||
};
|
||||
for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() {
|
||||
let xp = x.saturating_sub(1);
|
||||
let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x);
|
||||
let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1;
|
||||
let r = 2 * at(&p.c1, j, x) + mean;
|
||||
let b = 2 * at(&p.c2, j, x) + mean;
|
||||
let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)];
|
||||
for (off, v) in cells {
|
||||
if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) {
|
||||
*o = curve(v.clamp(0, 4095) as i32);
|
||||
}
|
||||
let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x);
|
||||
let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1;
|
||||
let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean];
|
||||
for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) {
|
||||
*o = curve(v.clamp(0, 4095) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1055
-46
File diff suppressed because it is too large
Load Diff
Vendored
+2
@@ -249,8 +249,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user