diff --git a/Cargo.lock b/Cargo.lock index fd03d210..3606369f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2876,6 +2876,7 @@ name = "lightcraft-engine" version = "0.4.0" dependencies = [ "ab_glyph", + "harfrust", "libc", "lightcraft-catalog", "lightcraft-codecs", @@ -3028,6 +3029,7 @@ dependencies = [ "proptest", "rayon", "serde", + "sha2", "thiserror 2.0.21", "zune-core", "zune-jpeg", diff --git a/README.md b/README.md index 7444912e..43e41dee 100644 --- a/README.md +++ b/README.md @@ -288,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the - **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony / older-Canon raws on one machine. - **The biggest gaps:** - - **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted; - - **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only; + - **camera colour calibration:** Sony, Nikon, Panasonic and Fujifilm raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **CR3 and compressed Olympus raws:** these open as embedded previews only. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **HDR, video and the Classic Print / Book / Map modules.** - **What's next:** see [where we're going](ROADMAP.md#where-were-going). @@ -302,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) | +| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | -| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ | +| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ | @@ -312,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | | XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ | -| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | +| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | | AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) | @@ -353,9 +353,22 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). +**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder +(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS +`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A +launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the +previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the +next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with +`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else +at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and +errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces +the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a +directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there +too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`. + The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI (`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). diff --git a/ROADMAP.md b/ROADMAP.md index aeb07567..e6650ab5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,11 +6,11 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi ## Where we stand -*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in +*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in [`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can **switch** from Lightroom. Update it when a gap below closes.* -**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working +**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly **quality of results and camera coverage**, not missing buttons. @@ -22,14 +22,14 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| -| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | +| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2 and RAF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | | Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction | -| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | +| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | +| Canon CR3 / Olympus compressed-raw shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise | @@ -46,9 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit + matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity. -2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF +2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. 3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and @@ -67,6 +68,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29 Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32). +Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views. +The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not +yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM). + ## Milestones **Status legend:** ✅ done · 🚧 in progress · ⬜ not started @@ -84,24 +89,24 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) | -## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) +## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02) **By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask parity` prints this line on every run, so it stays current): | Scope | Weighted completion | Rows | 2026-10-02 | |---|---:|---:|---:| -| P0 (core) | **98.5%** | 200 | 98.7% | -| P1 (important parity) | **95.9%** | 147 | 94.4% | -| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% | -| **All in-scope rows** | **79.2%** | 505 | 75.6% | +| P0 (core) | **98.2%** | 200 | 98.7% | +| P1 (important parity) | **95.6%** | 149 | 94.4% | +| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% | +| **All in-scope rows** | **79.7%** | 509 | 75.6% | The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified camera coverage, render fidelity), which is why P0 dipped slightly. @@ -118,7 +123,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel | Work package | Tracker rows | Agent-hours | Risk | |---|---|---:|---| | Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low | -| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | +| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | | Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection | | Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium | | AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training | @@ -167,8 +172,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1 Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered -maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and -X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, +maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and +X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from 118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants @@ -179,9 +184,9 @@ Not decoded yet — preview only (no permissively licensed description; black-bo files): rows above the split decode with the regular lossy table; from the split row on a different code is used that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). -- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. +- **Olympus compressed ORF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. -**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2 and RAF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. @@ -190,6 +195,8 @@ own chart-based calibration (M11.4). Adobe matrices are never used. - 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete. - 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed. - 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed. +- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open. +- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap. - 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples. - 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b9e2641c..d978641f 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -71,6 +71,8 @@ USAGE: per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}. Replies go to stdout. `ui.screenshot` without a path writes -o (then OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders. + A failed request (\"ok\": false) does not stop the script, but the exit + status is non-zero when any request failed. -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) @@ -87,7 +89,7 @@ USAGE: lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… Fit a colour profile per camera model from raw files and their embedded camera JPEGs - (Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per + (Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per model, written as .json to DIR (default: the profiles folder LightCraft reads, /camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then take their colour from the profile and only their tone from their own JPEG. @@ -122,6 +124,9 @@ fn main() -> ExitCode { dhat::Profiler::builder().file_name(file).build() }; alloc_release::install(); + // Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG + // or RUST_LOG picks another level (#168). + lightcraft_engine::logging::install("lightcraft-cli"); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("run") => run(&args[1..]), @@ -154,7 +159,8 @@ fn main() -> ExitCode { /// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders. fn raw_files(path: &Path, out: &mut Vec, depth: usize) { - let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x))); + let is_raw = + |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x))); if path.is_file() { if is_raw(path) { out.push(path.to_path_buf()); @@ -659,7 +665,7 @@ fn render(args: &[String]) -> Result<(), String> { for (k, v) in opts { p[k] = v; } - let mut o = ExportOptions::from_json(&p); + let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; if p.get("format").is_none() { let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); o.format = ExportFormat::parse(&ext) @@ -755,6 +761,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string()) }; let mut wrote_output = false; + // Issue #166: a scripted request that fails still gets its reply printed and the run carries + // on (like `run --keep-going`), but the exit status reports it — CI judges by exit status. + let mut failed = 0usize; if let Some(script) = &script { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; let mut out = std::io::stdout().lock(); @@ -787,6 +796,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { } _ => h.request(&method, params, timeout), }; + if reply["ok"] != true { + failed += 1; + } if let Some(o) = reply.as_object_mut() { o.insert("id".into(), id); // wall time of the request (incl. the frames it ran), and since the start @@ -823,7 +835,7 @@ fn snapshot(args: &[String]) -> Result<(), String> { } } eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames()); - Ok(()) + if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) } } fn commands(args: &[String]) -> Result<(), String> { diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 7cd0710b..21fa6310 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) { std::fs::write(path, png).unwrap(); } +#[test] +fn calibrate_reads_raf_inputs_and_reports_bad_files() { + let input = tmp("invalid.RAF"); + std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap(); + let out = tmp("raf-profiles"); + let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap(); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}"); + assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}"); + assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile"); +} + fn mean(path: &std::path::Path) -> f64 { let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap(); let img = d.to_srgb8(); @@ -183,6 +196,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!((d.width, d.height), (640, 480)); } +/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is +/// still printed, the later lines still run, and the final `-o` screenshot is still written. +#[test] +fn snapshot_script_failure_exits_non_zero() { + let script = tmp("snap-fail.jsonl"); + let out = tmp("snap-fail.png"); + std::fs::write( + &script, + format!( + "{}\n{}\n", + json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, … + json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + ), + ) + .unwrap(); + let _ = std::fs::remove_file(&out); + let o = Command::new(BIN) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}"); + assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}"); + let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 2, "{replies:?}"); + assert_eq!(replies[0]["ok"], false, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}"); + assert_eq!(replies[1]["ok"], true, "{replies:?}"); + // continue-and-report: the final screenshot is still written + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (320, 200)); +} + /// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, /// without creating a GPU device (no driver is loaded), and says why. #[test] @@ -336,3 +382,29 @@ fn connect_failure_hint_names_the_attempted_port() { assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}"); assert!(!err.contains("--control 7980"), "{err}"); } + +/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is +/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared. +#[test] +fn warnings_are_logged_on_stderr() { + let input = tmp("log-in.png"); + gradient_png(&input); + let out = tmp("log-out.jpg"); + let run = |level: &str| { + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .env("LIGHTCRAFT_GPU_BACKEND", "bogus") + .env("RUST_LOG", level) + .env_remove("LIGHTCRAFT_LOG") + .env_remove("LIGHTCRAFT_GPU") + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(o.status.success(), "{stderr}"); + stderr + }; + let warn = run("warn"); + assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}"); + let off = run("off"); + assert!(!off.contains("names no known backend"), "{off}"); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 2efabebb..41e64c02 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -429,8 +429,17 @@ impl WebApp { cores.saturating_sub(1).clamp(1, 4) }); let cache = app.session.media.rendered.clone(); - let workers = - (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone())); + let workers = (n > 0).then(|| { + Workers::start( + n, + backend.as_ref().map_or("memory", |b| b.kind()), + backend.clone(), + index, + &cache, + app.session.cache_bytes(), + cc.egui_ctx.clone(), + ) + }); if let Some(w) = &workers { app.renderer.set_offload(Box::new(w.clone())); } @@ -601,11 +610,16 @@ impl eframe::App for WebApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { let now = ctx.input(|i| i.time); - self.app.ui.toast = Some((text, now + NOTICE_SECS)); + self.app.ui.toast = Some((text, now + NOTICE_SECS, None)); } self.run_inbox(); self.import_dropped(ctx); self.load_originals(ctx); + if let Some(w) = &self.workers { + // `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on + // its own thread, so it gets the budget every frame and applies it on the next store. + w.set_budget(self.app.session.cache_bytes()); + } self.app.logic(ctx); self.save(); if let Some(b) = self.bench.as_mut() diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index a90ed61e..52a32328 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array}; use lightcraft_engine::catalog::PhotoId; -use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel}; +use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel}; use lightcraft_engine::pipeline::Rendered; use lightcraft_preview::{Hash128, PreviewCache}; use lightcraft_raster::{Histogram, Rgba8}; @@ -183,6 +183,9 @@ struct Inner { /// (photo, level) → worker that last decoded it. affinity: HashMap<(PhotoId, SourceLevel), usize>, index: ThumbIndex, + /// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a + /// worker message, where the session is out of reach; the app refreshes it every frame. + budget: u64, backend: Option, ctx: egui::Context, /// Jobs finished by workers / inline (for diagnostics). @@ -214,7 +217,15 @@ impl Workers { /// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers. /// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache /// now: watching it from the start, a clear before the first render request still counts. - pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, cache: &Arc, ctx: egui::Context) -> Workers { + pub fn start( + n: usize, + store: &str, + backend: Option, + index: ThumbIndex, + cache: &Arc, + budget: u64, + ctx: egui::Context, + ) -> Workers { let w = Workers(Rc::new(RefCell::new(Inner { preview_generation: CacheWatch::new(cache), workers: Vec::new(), @@ -222,6 +233,7 @@ impl Workers { next_id: 0, affinity: HashMap::new(), index, + budget, backend, ctx, remote_done: 0, @@ -328,7 +340,10 @@ impl Workers { } if stored > 0 { g.index.insert(&hex, stored); - let gone = g.index.prune(DISK_CACHE_BYTES); + // The session's own budget (`library.preferences ▸ cacheMb`), not the native + // default: the browser accepted the setting, so it has to be the one enforced + // (#245). + let gone = g.index.prune(g.budget); if !gone.is_empty() && let Some(be) = g.backend.clone() { @@ -386,6 +401,12 @@ impl Workers { let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count(); (alive, ready, g.remote_done, g.inline_done) } + + /// The budget the thumbnail index is pruned to. The app refreshes it from the session every + /// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail. + pub fn set_budget(&self, bytes: u64) { + self.0.borrow_mut().budget = bytes; + } } impl RenderOffload for Workers { diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index ff16be2f..16bd5df0 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; - eprintln!("lightcraft: control server listening on 127.0.0.1:{port}"); + log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs new file mode 100644 index 00000000..718392f3 --- /dev/null +++ b/apps/lightcraft/src/logging.rs @@ -0,0 +1,689 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a +//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach: +//! `/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux +//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the +//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`), +//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS` +//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs. +//! +//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu +//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or +//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors +//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`, +//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every +//! target that starts with it (`lightcraft*=debug`). +//! +//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. Panics that escape everything are added to the +//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error). +//! +//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError, TryLockError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log folder. +pub const LOG_FILE: &str = "lightcraft.log"; +/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise. +pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info"; + +/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset). +/// +/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or +/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value +/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`]. +pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String { + let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned); + if let Some(level) = set(lightcraft_log) { + return match level.as_str() { + "debug" => "warn,lightcraft*=debug".to_owned(), + "info" => "warn,lightcraft*=info".to_owned(), + _ => "warn".to_owned(), + }; + } + set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned()) +} + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock +/// value can overflow it. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + // No file is coming ([`Sink::no_file`]): standard error had the line. + } else if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + self.written = 0; + self.capped = false; + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error + /// standard error stays the only sink. + pub fn attach_dir(&self, dir: &Path) -> Result { + // A thread that panicked while logging leaves the sink usable: take it back. + let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner); + match rotate(dir) { + Ok((path, file)) => { + sink.attach(file); + Ok(path) + } + Err(e) => { + sink.no_file(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only. + pub fn no_file(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file(); + } + + /// Add a panic report to the log file only (the default panic hook has printed it on standard + /// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on + /// this very thread while it held the lock, and waiting would hang the app. + pub fn record_panic(&self, thread: &str, report: &str) { + let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report); + let mut sink = match self.sink.try_lock() { + Ok(sink) => sink, + Err(TryLockError::Poisoned(p)) => p.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + sink.write(&line); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed +/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let var = |name| std::env::var(name).ok(); + let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref())); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +/// Chain a panic hook after the current one (the engine guard's: standard error plus +/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file. +pub fn record_panics(logger: &'static AppLogger) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + let thread = std::thread::current(); + let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); + let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload())); + logger.record_panic(thread.name().unwrap_or("?"), &report); + })); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + /// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and + /// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. + fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { + let max = match lightcraft_log { + "debug" => LevelFilter::Debug, + "info" => LevelFilter::Info, + _ => LevelFilter::Warn, + }; + level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) + } + + #[test] + fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + assert_eq!(filter_spec(None, None), DEFAULT_FILTER); + // An empty variable is the same as an unset one. + assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); + } + + /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. + #[test] + fn lightcraft_log_keeps_its_meaning() { + let targets = + ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; + let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; + for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { + let f = Filter::parse(&filter_spec(Some(value), None)); + for target in targets { + for level in levels { + assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); + } + } + } + assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); + assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); + } + + #[test] + fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { + assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); + let f = Filter::parse(&filter_spec(None, Some("debug"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Debug); + // The app's own variable is the more specific choice. + let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Warn); + assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); + assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); + assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "lightcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,lightcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "lightcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + let _ = filter_spec(Some(spec), Some(spec)); + } + assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), + "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); + assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); + record(&logger, log::Level::Debug, "lightcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("lightcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "lightcraft", "early"); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "lightcraft", "after the poison"); + assert!(read(&path).contains("lightcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The panic hook's report lands in the file, and never deadlocks when the panic happened + /// while this thread held the sink. + #[test] + fn a_panic_report_goes_to_the_file_and_never_deadlocks() { + let dir = temp_dir("panic"); + let logger = AppLogger::new(Filter::parse("off"), false); + let path = logger.attach_dir(&dir).expect("attach"); + logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); + { + let _held = logger.sink.lock().expect("lock"); + logger.record_panic("main", "while the sink is held"); + } + let text = read(&path); + assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); + assert!(!text.contains("while the sink is held"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 4bb1c6ff..97aa338b 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -24,6 +24,7 @@ mod alloc_release; mod control_server; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -63,10 +64,10 @@ impl eframe::App for App { } fn on_exit(&mut self) { if let Err(e) = self.1.save(&self.0) { - eprintln!("lightcraft: {e}"); + log::error!("{e}"); } if let Err(e) = self.0.session.close_library() { - eprintln!("lightcraft: saving the library failed: {e}"); + log::error!("saving the library failed: {e}"); } } } @@ -93,10 +94,23 @@ fn gpu_marker_path() -> Option { /// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU /// driver while creating the device), the notice to show — GPU rendering then starts off. -fn gpu_crash_check() -> Option { - let marker = gpu_marker_path(); - let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker); - lightcraft_engine::gpu::backend::set_init_marker(marker); +/// +/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would +/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU +/// starts off there too, and the next ordinary launch reports and clears it. +fn gpu_crash_check(in_memory: bool) -> Option { + gpu_crash_check_at(gpu_marker_path(), in_memory) +} + +fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { + use lightcraft_engine::gpu::backend; + let left = if in_memory { + marker.as_deref().and_then(backend::read_init_marker) + } else { + let left = marker.as_deref().and_then(backend::take_init_marker); + backend::set_init_marker(marker); + left + }; left.map(|what| gpu_crash_notice(&what)) } @@ -112,19 +126,28 @@ fn config_dir() -> Option { lightcraft_engine::camera_profiles::config_dir() } +/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or +/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. +fn prefs_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("ui.json")) +} + /// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user /// when the file exists but can't be used (issue #103): a damaged file is kept as /// `ui.json.corrupt-` first, so the next save can't lose the library location in it; /// one that can't be read at all is not written this session (`keep_file`). -fn load_prefs() -> (Option, Option, bool) { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return (None, None, false); - } - let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) }; - load_prefs_at(&path) +/// +/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing +/// (issue #164), so a damaged file is left as it is there, not set aside. +fn load_prefs(in_memory: bool) -> (Option, Option, bool) { + let Some(path) = prefs_path() else { return (None, None, false) }; + load_prefs_at(&path, !in_memory) } -fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bool) { +fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), @@ -135,6 +158,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bo }; match serde_json::from_slice::(&bytes) { Ok(ui) => (Some(ui.sanitized()), None, false), + Err(e) if !set_aside_damaged => { + let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); + (None, Some(msg), true) + } Err(e) => { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); @@ -177,8 +204,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { /// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library /// changes, every few seconds when anything else changed, and at exit. +/// +/// Nothing is written while the session is one that promises to save nothing: `--memory` +/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the +/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the +/// library it opened is saved then). #[derive(Default)] struct PrefsWriter { + /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). + path: Option, written: Vec, library: String, checked: f64, @@ -190,19 +224,26 @@ struct PrefsWriter { impl PrefsWriter { fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + self.save_ui(&app.ui, app.library_problem.is_some()) + } + + /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be + /// opened) saves nothing. + fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { + if self.keep_file || temporary { return Ok(()); } - let Some(d) = config_dir() else { return Ok(()) }; - let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?; + let Some(path) = self.path.clone() else { return Ok(()) }; + let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { return Ok(()); } - std::fs::create_dir_all(&d) - .and_then(|()| write_atomic(&d.join("ui.json"), &bytes)) + path.parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| write_atomic(&path, &bytes)) .map_err(|e| format!("saving the app settings failed: {e}"))?; self.written = bytes; - self.library = app.ui.settings.library_path.clone(); + self.library = ui.settings.library_path.clone(); Ok(()) } @@ -216,8 +257,8 @@ impl PrefsWriter { match self.save(app) { Ok(()) => self.failing = false, Err(e) => { - eprintln!("lightcraft: {e}"); if !self.failing { + log::warn!("{e}"); app.notices.push(format!("{e}. LightCraft keeps trying.")); } self.failing = true; @@ -228,6 +269,16 @@ impl PrefsWriter { } } +/// The Windows command that opens `url` in the default browser. `explorer ` opened File +/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link +/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so +/// `?`, `&` and `#` need no escaping. +fn windows_open_url_command(url: &str) -> std::process::Command { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c +} + fn services() -> Services { Services { pick_folder: Some(Box::new(|| { @@ -262,7 +313,7 @@ fn services() -> Services { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").arg(url).status() } else if cfg!(target_os = "windows") { - std::process::Command::new("explorer").arg(url).status() + windows_open_url_command(url).status() } else { std::process::Command::new("xdg-open").arg(url).status() }; @@ -272,7 +323,8 @@ fn services() -> Services { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").args(["-R", path]).status() } else if cfg!(target_os = "windows") { - std::process::Command::new("explorer").arg(format!("/select,{path}")).status() + let win_path = path.replace('/', "\\"); + std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status() } else { let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); std::process::Command::new("xdg-open").arg(dir).status() @@ -366,7 +418,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo } let unopened = || Session::new().with_fs().with_system_clock(); let Some(dir) = dir else { - eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)"); + log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); }; let t0 = std::time::Instant::now(); @@ -374,8 +426,8 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo match s.open_library(&dir, seed_demo) { Ok(r) => { let (replayed, torn) = (r.replayed, r.torn_bytes); - eprintln!( - "lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", + log::info!( + "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", dir.display(), s.catalog.len(), if torn > 0 { ", torn tail repaired" } else { "" }, @@ -384,7 +436,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo (s, None) } Err(e) => { - eprintln!("lightcraft: can't open library {}: {e}", dir.display()); + log::error!("can't open library {}: {e}", dir.display()); (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) } } @@ -409,41 +461,40 @@ ENVIRONMENT: LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; optional: LightCraft offers to download it when first needed) LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) + LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings + from other crates); else RUST_LOG=. Records go to stderr and to + /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) "; -/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info` -/// (or `debug`) shows more. -struct StderrLog(log::LevelFilter); - -impl log::Log for StderrLog { - fn enabled(&self, m: &log::Metadata) -> bool { - m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft")) +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. +fn log_dir() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; } - fn log(&self, r: &log::Record) { - if self.enabled(r.metadata()) { - eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args()); - } - } - fn flush(&self) {} + config_dir().map(|d| d.join("logs")) } -fn install_log() { - let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() { - "debug" => log::LevelFilter::Debug, - "info" => log::LevelFilter::Info, - _ => log::LevelFilter::Warn, - }; - static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new(); - if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() { - log::set_max_level(level); +/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is +/// the option or variable it came from). Logged before the log file opens, so it is kept for it. +fn control_port_from(what: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{what} {value:?} is not a port number; no control server"); } + port } fn main() -> eframe::Result { + // First, so every start-up record is kept for the log file (`logging`). + let logger = logging::install(); lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log")); - install_log(); + if let Some(logger) = logger { + logging::record_panics(logger); + } alloc_release::install(); - let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty())); let mut files = Vec::new(); let mut seed_demo = true; let mut in_memory = false; @@ -451,7 +502,7 @@ fn main() -> eframe::Result { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())), "--library" => library_dir = args.next().map(std::path::PathBuf::from), "--no-demo" => seed_demo = false, "--memory" | "--demo" => in_memory = true, @@ -470,7 +521,20 @@ fn main() -> eframe::Result { _ => files.push(a), } } - let (prefs, prefs_warning, keep_prefs_file) = load_prefs(); + // The log file: opened after the arguments, so `--version`, `--help` and a usage error leave + // no file behind. Records logged until now are written to it first. + if let Some(logger) = logger { + // a --memory session writes nothing (issue #164), a log file included + match log_dir().filter(|_| !in_memory) { + Some(dir) => match logger.attach_dir(&dir) { + Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // The file sink has given up by now, so this goes to standard error only. + Err(e) => log::warn!("no log file: {e}"), + }, + None => logger.no_file(), + } + } + let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory); // --library, else the library last opened from Settings, else the default location let library_dir = library_dir.or_else(|| { prefs @@ -482,7 +546,7 @@ fn main() -> eframe::Result { let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it - let gpu_crash = gpu_crash_check(); + let gpu_crash = gpu_crash_check(in_memory); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); let options = eframe::NativeOptions { @@ -520,6 +584,7 @@ fn main() -> eframe::Result { app.notices.extend(prefs_warning); // what's on disk now: only changes are written let writer = PrefsWriter { + path: if in_memory { None } else { prefs_path() }, written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), library: app.ui.settings.library_path.clone(), keep_file: keep_prefs_file, @@ -575,7 +640,7 @@ mod tests { let d = dir("damaged"); let path = d.join("ui.json"); std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path); + let (ui, warning, keep) = load_prefs_at(&path, true); assert!(ui.is_none() && !keep); let warning = warning.unwrap(); assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); @@ -583,7 +648,80 @@ mod tests { let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); // a missing file is just "no settings yet" - assert_eq!(load_prefs_at(&path).1, None); + assert_eq!(load_prefs_at(&path, true).1, None); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is + /// reported and left where it is, not set aside. + #[test] + fn memory_session_leaves_a_damaged_ui_json_alone() { + let d = dir("damaged-memory"); + let path = d.join("ui.json"); + let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; + std::fs::write(&path, bytes).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, false); + assert!(ui.is_none() && keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #164: the writer saves changed settings to its file — and nothing at all without one + /// (`--memory`) or while the session is the temporary one offered when the library can't open. + #[test] + fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { + let d = dir("writer"); + let path = d.join("sub").join("ui.json"); + let mut ui = UiState::default(); + // an ordinary session: a change is written, the folder created on the way + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + let saved = std::fs::read(&path).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); + // the temporary session (library problem): a further change is not written… + ui.thumb_size += 50.0; + w.save_ui(&ui, true).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), saved); + // …until a library opens + w.save_ui(&ui, false).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), saved); + // --memory: no path, so no file and no folder, whatever changes + let d2 = dir("writer-memory"); + let _ = std::fs::remove_dir_all(&d2); + let mut w = PrefsWriter { path: None, ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + assert!(w.written.is_empty() && !d2.exists()); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create + /// the settings folder) nor removes a marker a crashed launch left; it still reports it, and + /// the next ordinary launch finds it, reports it and clears it. + #[test] + fn memory_session_leaves_the_gpu_marker_and_folder_alone() { + let d = dir("gpu-marker-memory"); + let _ = std::fs::remove_dir_all(&d); + let m = d.join("gpu-init.marker"); + // nothing left behind: no notice, and the folder is not created + assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); + assert!(!d.exists(), "a --memory session must not create the settings folder"); + // a marker left by a crashed launch: reported, kept + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); + let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); + assert!(notice.contains("GPU rendering is now off"), "{notice}"); + assert!(m.exists(), "the marker stays for the next ordinary launch"); + // the next ordinary launch reports it once and removes it + assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); + lightcraft_engine::gpu::backend::set_init_marker(None); + assert!(!m.exists()); + assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); + lightcraft_engine::gpu::backend::set_init_marker(None); let _ = std::fs::remove_dir_all(&d); } @@ -616,6 +754,27 @@ mod tests { assert!(!b.is_empty()); } + /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not + /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. + #[test] + fn windows_links_open_in_the_browser() { + let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; + let c = windows_open_url_command(url); + assert_eq!(c.get_program(), "rundll32"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, ["url.dll,FileProtocolHandler", url]); + } + + #[test] + fn a_control_port_that_is_not_a_number_is_ignored() { + assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); + assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); + assert_eq!(control_port_from("--control", Some("nope".into())), None); + assert_eq!(control_port_from("--control", Some(String::new())), None); + assert_eq!(control_port_from("--control", Some("70000".into())), None); + assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); + } + #[test] fn ui_json_is_written_atomically() { let d = dir("atomic"); @@ -630,7 +789,7 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); let ui = UiState::default(); write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); - let (loaded, warning, _) = load_prefs_at(&path); + let (loaded, warning, _) = load_prefs_at(&path, true); assert!(loaded.is_some() && warning.is_none()); let _ = std::fs::remove_dir_all(&d); } diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 557c9abf..02d33db3 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -2,9 +2,12 @@ //! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, //! enabled and checked state, and live labels ("Undo Exposure"). //! -//! Key handling: a native key equivalent consumes the key press before egui sees it, so -//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by -//! the egui shortcut handler (nothing fires twice); +//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a +//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose +//! winit view always takes it, so a menu item never fires from it. Hence +//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in +//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires +//! twice); the others stay on their menu items for display and egui runs them; //! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the //! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come //! back when the field loses focus. @@ -146,6 +149,18 @@ fn yields_to_text(sc: &str) -> bool { !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) } +/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function +/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run +/// those even though their menu item shows the key. +fn menu_delivers(sc: &str) -> bool { + sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) +} + +/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`. +fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet { + installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect() +} + /// `&` marks a mnemonic in muda labels. fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") @@ -329,13 +344,8 @@ impl NativeMenu { /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. fn publish_shortcuts(&self, app: &mut LightcraftApp) { - app.native_shortcuts = self - .items - .values() - .filter(|i| i.accel.is_some()) - .filter_map(|i| i.shortcut.clone()) - .filter(|sc| !(self.text_focus && yields_to_text(sc))) - .collect::>(); + let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref()); + app.native_shortcuts = owned_by_menu(installed, self.text_focus); app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } @@ -467,4 +477,39 @@ mod tests { assert!(accelerator(sc).is_some(), "{sc}"); } } + + /// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must + /// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own. + #[test] + fn single_key_shortcuts_stay_with_egui() { + assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11")); + assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F")); + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut installed = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut installed); + } + for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar"); + } + let owned = owned_by_menu(installed.iter().map(String::as_str), false); + for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu"); + assert!(!owned.contains(sc), "{sc} must be left to egui"); + } + for sc in ["Cmd+Shift+H", "F2"] { + assert!(owned.contains(sc), "{sc} is run by the menu bar"); + } + // while typing, the menu bar gives up F2 too (the text field has it) + assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2")); + } } diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 208cc39c..202eb27e 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -11,6 +11,8 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | +| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | +| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | diff --git a/assets/camera-profiles/X-H2S.json b/assets/camera-profiles/X-H2S.json new file mode 100644 index 00000000..59cd498d --- /dev/null +++ b/assets/camera-profiles/X-H2S.json @@ -0,0 +1,2111 @@ +{ + "version": 1, + "model": "X-H2S", + "files": 201, + "samples": 722133, + "matrix": [ + [1.2458876370972227, -0.3237571865289368, 0.062138487539840526], + [-0.08719440905566955, 1.1839386569330799, -0.0901410829920053], + [-0.09235873400444561, -0.6687925210227519, 1.7553435681239122] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [8.728679, 1.0206304, 1.0], + [4.3200827, 0.9860159, 1.0], + [1.2806753, 1.0042948, 1.0], + [0.15212817, 1.0054297, 1.0], + [0.0, 1.0, 1.0], + [6.898361, 1.0119873, 1.0], + [3.6430912, 0.98571527, 1.0], + [0.54247886, 1.0148934, 1.0], + [-0.28426996, 1.0097318, 1.0], + [0.0, 1.0, 1.0], + [4.7296433, 1.0000898, 1.0], + [1.1935737, 1.0203358, 1.0], + [-0.5411634, 1.0170898, 1.0], + [-0.88588536, 0.9812703, 1.0], + [0.0, 1.0, 1.0], + [2.8998873, 0.98929864, 1.0], + [0.34247065, 1.0188023, 1.0], + [-0.70872885, 1.0079176, 1.0], + [-1.3457271, 0.97846293, 1.0], + [0.0, 1.0, 1.0], + [2.7941706, 1.0029006, 1.0], + [0.090435915, 1.0282906, 1.0], + [-0.9954686, 1.0179157, 1.0], + [-1.8290771, 0.98781705, 1.0], + [0.0, 1.0, 1.0], + [2.6052246, 1.0248086, 1.0], + [-0.16740479, 1.0330777, 1.0], + [-1.1437633, 1.0250585, 1.0], + [-1.6554425, 1.005749, 1.0], + [0.0, 1.0, 1.0], + [2.6925323, 1.0300746, 1.0], + [-0.18578847, 1.0318998, 1.0], + [-1.167182, 1.0260776, 1.0], + [-1.8700649, 1.013574, 1.0], + [0.0, 1.0, 1.0], + [3.3137257, 1.026785, 1.0], + [0.27151796, 1.026643, 1.0], + [-1.0162086, 1.0205064, 1.0], + [-1.555533, 1.0105867, 1.0], + [0.0, 1.0, 1.0], + [3.3468738, 1.0230751, 1.0], + [0.98764545, 1.02391, 1.0], + [-0.4541654, 1.0172719, 1.0], + [-1.3170965, 1.0072583, 1.0], + [0.0, 1.0, 1.0], + [3.2349977, 1.0158701, 1.0], + [0.95254457, 1.0192627, 1.0], + [-0.34923795, 1.0145884, 1.0], + [-1.1261563, 1.0043564, 1.0], + [0.0, 1.0, 1.0], + [3.7663198, 1.0100601, 1.0], + [1.4589342, 1.0104818, 1.0], + [-0.23215643, 1.0029329, 1.0], + [-1.0081172, 0.9913272, 1.0], + [0.0, 1.0, 1.0], + [3.838287, 1.020117, 1.0], + [2.1576033, 1.0143449, 1.0], + [0.8445625, 0.99050206, 1.0], + [0.42045176, 0.9640189, 1.0], + [0.0, 1.0, 1.0], + [3.444411, 1.0406119, 1.0], + [2.0267544, 1.0230625, 1.0], + [1.115889, 0.97797954, 1.0], + [0.7384578, 0.92473024, 1.0], + [0.0, 1.0, 1.0], + [3.2759016, 1.0458477, 1.0], + [1.8555862, 1.0206501, 1.0], + [1.1933137, 0.97871643, 1.0], + [0.86039966, 0.922511, 1.0], + [0.0, 1.0, 1.0], + [2.155127, 1.0392207, 1.0], + [1.3352914, 1.0169463, 1.0], + [1.1311455, 0.9890133, 1.0], + [1.274365, 0.9731691, 1.0], + [0.0, 1.0, 1.0], + [0.030139316, 1.0277171, 1.0], + [-0.1536572, 1.0162318, 1.0], + [0.75929344, 0.9932093, 1.0], + [1.2689896, 0.98043805, 1.0], + [0.0, 1.0, 1.0], + [-1.4671046, 1.0425197, 1.0], + [-0.62572336, 1.0360416, 1.0], + [1.11761, 1.0142438, 1.0], + [1.6834764, 0.99684334, 1.0], + [0.0, 1.0, 1.0], + [-2.7358959, 1.0736592, 1.0], + [-0.26553202, 1.0607176, 1.0], + [1.8074034, 1.0324123, 1.0], + [2.1980815, 1.0121138, 1.0], + [0.0, 1.0, 1.0], + [-4.240199, 1.087688, 1.0], + [-0.6343778, 1.0749793, 1.0], + [1.2906064, 1.047931, 1.0], + [1.5339005, 1.0187962, 1.0], + [0.0, 1.0, 1.0], + [-5.0441365, 1.0897394, 1.0], + [-1.5529838, 1.085552, 1.0], + [-0.05806478, 1.0628853, 1.0], + [0.27003333, 1.0148032, 1.0], + [0.0, 1.0, 1.0], + [-6.358739, 1.1044925, 1.0], + [-3.0616302, 1.0820334, 1.0], + [-1.1493404, 1.0458001, 1.0], + [-0.044788126, 1.0036042, 1.0], + [0.0, 1.0, 1.0], + [-7.314695, 1.1287619, 1.0], + [-3.2918382, 1.0697256, 1.0], + [-0.6346949, 1.0162474, 1.0], + [-0.015576859, 1.0004573, 1.0], + [0.0, 1.0, 1.0], + [-7.820848, 1.1305687, 1.0], + [-2.4455993, 1.0498186, 1.0], + [-0.19949687, 1.0041214, 1.0], + [-0.0027498177, 1.0000476, 1.0], + [0.0, 1.0, 1.0], + [-7.5484633, 1.1130339, 1.0], + [-2.1600857, 1.0477536, 1.0], + [-0.11881551, 1.0030932, 1.0], + [-0.0005820335, 1.0000135, 1.0], + [0.0, 1.0, 1.0], + [-8.035158, 1.1102359, 1.0], + [-2.1405637, 1.0350051, 1.0], + [-0.084177606, 1.0013684, 1.0], + [-0.00032260726, 1.0000027, 1.0], + [0.0, 1.0, 1.0], + [-7.6952314, 1.0635511, 1.0], + [-1.064063, 1.0112427, 1.0], + [-0.008954739, 0.99997956, 1.0], + [ + -1.8368839e-05, + 0.99999607, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.061495, 1.0261137, 1.0], + [-1.4261262, 1.0039278, 1.0], + [-0.013760631, 1.0001167, 1.0], + [ + -1.3433922e-05, + 1.0000004, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.943509, 0.99616253, 1.0], + [-2.180308, 0.9981994, 1.0], + [-0.032691147, 0.99998057, 1.0], + [ + -4.4223594e-05, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-10.810746, 0.98468745, 1.0], + [-2.652356, 0.9928594, 1.0], + [-0.043256707, 0.9998229, 1.0], + [ + -5.016976e-05, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [-10.338566, 0.96656805, 1.0], + [-3.6949215, 0.9859071, 1.0], + [-0.102222234, 0.9995505, 1.0], + [-0.00015470105, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-12.014268, 0.9764745, 1.0], + [-6.8061976, 0.9887849, 1.0], + [-0.31399488, 0.9994232, 1.0], + [-0.0005296378, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-13.442714, 0.9890778, 1.0], + [-9.357521, 0.99581265, 1.0], + [-0.5783634, 0.9992511, 1.0], + [-0.0011424923, 0.99999434, 1.0], + [0.0, 1.0, 1.0], + [-14.343322, 0.98522204, 1.0], + [-9.916013, 0.9911534, 1.0], + [-0.5881451, 0.9987247, 1.0], + [-0.0013036833, 0.9999904, 1.0], + [0.0, 1.0, 1.0], + [-13.69773, 0.9788999, 1.0], + [-8.763287, 0.9857234, 1.0], + [-0.47650328, 0.9978328, 1.0], + [-0.001562948, 0.9999825, 1.0], + [0.0, 1.0, 1.0], + [-12.636312, 0.9741085, 1.0], + [-6.735057, 0.9883704, 1.0], + [-0.2865094, 0.99915266, 1.0], + [-0.00070323877, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [-11.292832, 0.97211325, 1.0], + [-5.0721035, 0.9860957, 1.0], + [-0.18372871, 0.99921757, 1.0], + [-0.00035769286, 0.9999973, 1.0], + [0.0, 1.0, 1.0], + [-9.231987, 0.9687564, 1.0], + [-3.7118556, 0.98878354, 1.0], + [-0.12847522, 0.99951947, 1.0], + [-0.00029055856, 0.99999857, 1.0], + [0.0, 1.0, 1.0], + [-9.55005, 0.95274824, 1.0], + [-3.3982742, 0.98425007, 1.0], + [-0.10297954, 0.99951214, 1.0], + [-0.00026502798, 0.9999987, 1.0], + [0.0, 1.0, 1.0], + [-8.93991, 0.92654765, 1.0], + [-3.4434712, 0.972461, 1.0], + [-0.15123777, 0.99885994, 1.0], + [-0.00096332224, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-8.364199, 0.89885736, 1.0], + [-2.9382148, 0.96036947, 1.0], + [-0.15202415, 0.99773234, 1.0], + [-0.0011220135, 0.9999822, 1.0], + [0.0, 1.0, 1.0], + [-6.8977294, 0.8749669, 1.0], + [-3.0306811, 0.9445574, 1.0], + [-0.31894, 0.9935104, 1.0], + [-0.0031635074, 0.99992883, 1.0], + [0.0, 1.0, 1.0], + [-6.643872, 0.86401826, 1.0], + [-2.5939164, 0.94932747, 1.0], + [-0.23428063, 0.9957458, 1.0], + [-0.0025634407, 0.99994916, 1.0], + [0.0, 1.0, 1.0], + [-7.108995, 0.8512457, 1.0], + [-2.3661106, 0.9332001, 1.0], + [-0.08739928, 0.99674493, 1.0], + [-0.00063831237, 0.9999771, 1.0], + [0.0, 1.0, 1.0], + [-5.545464, 0.89238304, 1.0], + [-2.0838609, 0.93224514, 1.0], + [-0.011354147, 0.9921285, 1.0], + [0.0021260942, 0.99993163, 1.0], + [0.0, 1.0, 1.0], + [-2.3110693, 0.930202, 1.0], + [1.2572228, 0.9713286, 1.0], + [1.7645134, 0.9932074, 1.0], + [0.10183909, 0.9997941, 1.0], + [0.0, 1.0, 1.0], + [0.30794027, 0.9202272, 1.0], + [2.0130813, 0.9786798, 1.0], + [1.9920294, 0.9952443, 1.0], + [0.14857435, 0.9998033, 1.0], + [0.0, 1.0, 1.0], + [0.46019936, 0.93292373, 1.0], + [1.2379118, 0.97167575, 1.0], + [1.1637435, 1.0017741, 1.0], + [0.11729451, 1.0008876, 1.0], + [0.0, 1.0, 1.0], + [-0.30377665, 0.95070875, 1.0], + [0.92464393, 0.99626124, 1.0], + [3.7344003, 1.0415727, 1.0], + [1.4934452, 1.0185289, 1.0], + [0.0, 1.0, 1.0], + [-0.6724653, 0.905078, 1.0], + [0.8874332, 0.97638196, 1.0], + [4.093791, 1.045553, 1.0], + [1.6834058, 1.0226531, 1.0], + [0.0, 1.0, 1.0], + [-1.17155, 0.822445, 1.0], + [-0.51097, 0.88816243, 1.0], + [0.56960315, 0.99968165, 1.0], + [0.080809385, 1.0010427, 1.0], + [0.0, 1.0, 1.0], + [-1.2161565, 0.81127447, 1.0], + [-0.40078506, 0.86335325, 1.0], + [0.068287045, 0.9943868, 1.0], + [0.0020908245, 1.0000015, 1.0], + [0.0, 1.0, 1.0], + [-2.4894366, 0.7998197, 1.0], + [-0.84341663, 0.8705174, 1.0], + [0.040301893, 0.99392956, 1.0], + [0.0006503587, 0.99992245, 1.0], + [0.0, 1.0, 1.0], + [-2.780393, 0.78455186, 1.0], + [-0.33670017, 0.8948624, 1.0], + [0.007884285, 0.99553126, 1.0], + [-0.0005095186, 0.99989825, 1.0], + [0.0, 1.0, 1.0], + [-2.9800856, 0.77555025, 1.0], + [-0.36861885, 0.92584366, 1.0], + [-0.015655512, 0.99895173, 1.0], + [-0.000318789, 0.9999976, 1.0], + [0.0, 1.0, 1.0], + [-2.9218316, 0.80435187, 1.0], + [-0.36963376, 0.9587903, 1.0], + [-0.020300027, 0.9996703, 1.0], + [-0.00028825158, 1.0000017, 1.0], + [0.0, 1.0, 1.0], + [-0.56449103, 0.8612327, 1.0], + [0.17715399, 0.9788929, 1.0], + [0.0057121958, 1.0000331, 1.0], + [ + -3.5405683e-05, + 1.0000029, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.8323374, 0.89369875, 1.0], + [1.0687087, 0.9809675, 1.0], + [0.034107335, 0.9997653, 1.0], + [0.000120967496, 1.0000002, 1.0], + [0.0, 1.0, 1.0], + [3.5763772, 0.93426466, 1.0], + [0.66850424, 0.99200565, 1.0], + [0.022399126, 0.9997675, 1.0], + [0.0001369294, 0.9999979, 1.0], + [0.0, 1.0, 1.0], + [3.6716483, 0.9446645, 1.0], + [0.60636437, 0.99354786, 1.0], + [0.0044138604, 1.0000226, 1.0], + [ + -1.8536522e-05, + 1.0000005, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.2337399, 0.9710475, 1.0], + [0.42016056, 0.9981193, 1.0], + [0.012040432, 1.0000653, 1.0], + [ + 3.5203393e-05, + 1.0000005, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.295084, 0.97946674, 1.0], + [0.29066184, 0.9992383, 1.0], + [0.0036088976, 1.0000501, 1.0], + [ + 4.384876e-06, + 1.0000002, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.125425, 0.9786852, 1.0], + [0.20953342, 0.99828696, 1.0], + [0.004922661, 0.99999815, 1.0], + [ + 1.0277095e-05, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.2043383, 0.9779079, 1.0], + [0.090610325, 0.99730194, 1.0], + [-0.00017681491, 0.99997425, 1.0], + [ + -4.1671146e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [3.1792917, 0.9613604, 1.0], + [0.35856792, 0.99540615, 1.0], + [0.0028171474, 0.9999634, 1.0], + [ + -2.471271e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [4.075094, 0.9539685, 1.0], + [0.5917587, 0.9927337, 1.0], + [0.005103707, 0.99990827, 1.0], + [ + -3.1705735e-05, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [3.5010443, 0.9536551, 1.0], + [0.20673071, 0.98983246, 1.0], + [-0.032583736, 0.99970436, 1.0], + [-0.00028767975, 0.99999875, 1.0], + [0.0, 1.0, 1.0], + [5.2023478, 0.96730983, 1.0], + [0.6703333, 0.9937887, 1.0], + [-0.020854607, 1.0000542, 1.0], + [-0.00025753156, 1.0000008, 1.0], + [0.0, 1.0, 1.0], + [5.3636384, 0.9527599, 1.0], + [0.81285733, 0.9896896, 1.0], + [0.017544644, 0.9998711, 1.0], + [0.000114887676, 1.0000008, 1.0], + [0.0, 1.0, 1.0], + [6.417876, 0.9383751, 1.0], + [1.1611464, 0.98046106, 1.0], + [0.018655559, 0.9995545, 1.0], + [-0.0002014685, 0.9999993, 1.0], + [0.0, 1.0, 1.0], + [6.7440786, 0.9787412, 1.0], + [1.8940457, 0.9993158, 1.0], + [0.10403728, 1.0014251, 1.0], + [-0.0007007577, 1.0000236, 1.0], + [0.0, 1.0, 1.0], + [9.234887, 0.9963478, 1.0], + [2.5921743, 1.0027596, 1.0], + [-0.3284632, 1.0038372, 1.0], + [-0.043428678, 1.0003042, 1.0], + [0.0, 1.0, 1.0], + [11.043382, 1.0092762, 1.0], + [3.6184309, 0.99392426, 1.0], + [-0.3205221, 1.0031682, 1.0], + [-0.059132684, 1.0006497, 1.0], + [0.0, 1.0, 1.0], + [5.97402, 1.0034658, 1.0], + [-0.6740785, 0.9949814, 1.0], + [-1.0243659, 1.0299757, 1.0], + [0.10299488, 1.034073, 1.0], + [0.0, 1.0, 1.0], + [4.5237284, 0.9901157, 1.0], + [1.2369099, 1.005908, 1.0], + [-0.32235593, 1.0317756, 1.0], + [-0.8128157, 1.0050477, 1.0], + [0.0, 1.0, 1.0], + [2.642822, 0.9752414, 1.0], + [0.046472717, 1.0169463, 1.0], + [-1.0525423, 1.0133541, 1.0], + [-1.3776581, 0.9691162, 1.0], + [0.0, 1.0, 1.0], + [1.2084394, 0.972613, 1.0], + [-0.25541478, 1.0089694, 1.0], + [-1.0787785, 1.0010968, 1.0], + [-2.0386431, 0.965853, 1.0], + [0.0, 1.0, 1.0], + [1.3990965, 0.9883769, 1.0], + [-0.23538761, 1.011664, 1.0], + [-1.0311636, 1.0079699, 1.0], + [-2.0373921, 0.98409766, 1.0], + [0.0, 1.0, 1.0], + [1.3430843, 1.0004642, 1.0], + [-0.35947296, 1.0128645, 1.0], + [-1.0458986, 1.0115215, 1.0], + [-1.5997602, 1.0003799, 1.0], + [0.0, 1.0, 1.0], + [1.5115094, 1.0079246, 1.0], + [-0.35197452, 1.0135599, 1.0], + [-1.0123414, 1.0121523, 1.0], + [-1.5962777, 1.0062771, 1.0], + [0.0, 1.0, 1.0], + [2.2201242, 1.0069045, 1.0], + [-0.05537218, 1.0074033, 1.0], + [-0.9185825, 1.0038182, 1.0], + [-1.3931786, 0.99976593, 1.0], + [0.0, 1.0, 1.0], + [2.6776147, 1.0080688, 1.0], + [0.7692571, 1.0131639, 1.0], + [-0.5027828, 1.0076567, 1.0], + [-1.2549909, 0.9997216, 1.0], + [0.0, 1.0, 1.0], + [2.5154178, 0.996633, 1.0], + [0.9251866, 1.0093694, 1.0], + [-0.16704215, 1.0091307, 1.0], + [-0.9250501, 1.0001701, 1.0], + [0.0, 1.0, 1.0], + [2.786738, 0.98252994, 1.0], + [1.17775, 0.9951249, 1.0], + [-0.040805407, 0.995746, 1.0], + [-0.8885909, 0.9822554, 1.0], + [0.0, 1.0, 1.0], + [2.4449787, 0.9717951, 1.0], + [1.5592619, 0.9895185, 1.0], + [0.8975454, 0.97139394, 1.0], + [0.8434216, 0.91258097, 1.0], + [0.0, 1.0, 1.0], + [1.3327093, 0.96855474, 1.0], + [1.2922856, 0.9951774, 1.0], + [1.1538825, 0.95140374, 1.0], + [1.0445648, 0.882907, 1.0], + [0.0, 1.0, 1.0], + [1.3042054, 0.97626907, 1.0], + [1.2261003, 1.0032405, 1.0], + [1.1920351, 0.95930433, 1.0], + [1.0248796, 0.87122154, 1.0], + [0.0, 1.0, 1.0], + [1.1218151, 0.99428594, 1.0], + [0.9402701, 1.0072844, 1.0], + [1.2057712, 0.9790373, 1.0], + [1.715661, 0.9378413, 1.0], + [0.0, 1.0, 1.0], + [-0.66689795, 0.9936543, 1.0], + [-0.3043008, 1.0076888, 1.0], + [0.8821287, 0.98223394, 1.0], + [1.5125725, 0.9544597, 1.0], + [0.0, 1.0, 1.0], + [-2.1180842, 1.007544, 1.0], + [-0.6135006, 1.0289868, 1.0], + [1.3090985, 1.0115852, 1.0], + [2.0874233, 0.9913464, 1.0], + [0.0, 1.0, 1.0], + [-3.3278608, 1.03237, 1.0], + [-0.34648758, 1.0507596, 1.0], + [1.7782654, 1.0327485, 1.0], + [2.468833, 1.0156778, 1.0], + [0.0, 1.0, 1.0], + [-4.748517, 1.0421953, 1.0], + [-0.9545988, 1.0644007, 1.0], + [1.0075394, 1.0452629, 1.0], + [1.8186473, 1.0213822, 1.0], + [0.0, 1.0, 1.0], + [-5.6511426, 1.04099, 1.0], + [-2.212496, 1.0738522, 1.0], + [-0.5295884, 1.058841, 1.0], + [0.39001313, 1.0159221, 1.0], + [0.0, 1.0, 1.0], + [-7.2649274, 1.0555432, 1.0], + [-4.1198425, 1.08102, 1.0], + [-2.3006728, 1.0733076, 1.0], + [-0.51555675, 1.0098453, 1.0], + [0.0, 1.0, 1.0], + [-8.993211, 1.0847929, 1.0], + [-5.831633, 1.0979817, 1.0], + [-3.0940223, 1.066904, 1.0], + [-0.9517784, 0.9851261, 1.0], + [0.0, 1.0, 1.0], + [-10.3772, 1.0938902, 1.0], + [-6.41151, 1.0909004, 1.0], + [-1.8480648, 1.0164669, 1.0], + [-0.16703343, 0.9933557, 1.0], + [0.0, 1.0, 1.0], + [-11.117343, 1.0931859, 1.0], + [-5.9912677, 1.0973685, 1.0], + [-0.5732509, 1.0049413, 1.0], + [0.08346253, 0.995802, 1.0], + [0.0, 1.0, 1.0], + [-12.642463, 1.1002907, 1.0], + [-6.1474624, 1.0736011, 1.0], + [-0.3243047, 0.9991013, 1.0], + [0.016711053, 0.99943656, 1.0], + [0.0, 1.0, 1.0], + [-13.478187, 1.0329441, 1.0], + [-4.5098143, 0.9993377, 1.0], + [-0.088909596, 0.99232316, 1.0], + [0.0015094848, 0.9998537, 1.0], + [0.0, 1.0, 1.0], + [-14.786204, 0.9694178, 1.0], + [-6.1401577, 0.9893014, 1.0], + [-0.16727397, 1.0001292, 1.0], + [-0.0004984068, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-15.986527, 0.92521626, 1.0], + [-7.2159486, 0.9676191, 1.0], + [-0.17465894, 0.99944806, 1.0], + [-0.00039394363, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-16.760332, 0.91941845, 1.0], + [-7.364063, 0.9667283, 1.0], + [-0.14445107, 0.9989603, 1.0], + [0.0004303302, 0.9999847, 1.0], + [0.0, 1.0, 1.0], + [-15.557542, 0.91452426, 1.0], + [-7.8338785, 0.9618465, 1.0], + [-0.3343677, 0.9981042, 1.0], + [ + -4.918289e-05, + 0.99998575, + 1.0 + ], + [0.0, 1.0, 1.0], + [-15.201462, 0.9339844, 1.0], + [-10.613217, 0.96940356, 1.0], + [-1.136429, 0.99680275, 1.0], + [-0.0021724077, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [-15.266676, 0.95900613, 1.0], + [-12.692006, 0.9837428, 1.0], + [-2.1463373, 0.9947698, 1.0], + [-0.005627853, 0.9999566, 1.0], + [0.0, 1.0, 1.0], + [-16.444113, 0.95752037, 1.0], + [-13.965291, 0.97963464, 1.0], + [-2.2071412, 0.9926996, 1.0], + [-0.0063773347, 0.99993426, 1.0], + [0.0, 1.0, 1.0], + [-16.428102, 0.944903, 1.0], + [-13.466559, 0.9741484, 1.0], + [-1.5537314, 0.99316627, 1.0], + [-0.0054058074, 0.99994093, 1.0], + [0.0, 1.0, 1.0], + [-15.743757, 0.9297916, 1.0], + [-11.245777, 0.96999633, 1.0], + [-1.0017846, 0.9963959, 1.0], + [-0.0027330904, 0.99998176, 1.0], + [0.0, 1.0, 1.0], + [-14.582258, 0.92690057, 1.0], + [-9.555166, 0.9580048, 1.0], + [-0.91683203, 0.9949841, 1.0], + [-0.0023285556, 0.9999827, 1.0], + [0.0, 1.0, 1.0], + [-13.66012, 0.9144587, 1.0], + [-8.907967, 0.96176255, 1.0], + [-0.75339365, 0.9969989, 1.0], + [-0.0020785667, 0.99999136, 1.0], + [0.0, 1.0, 1.0], + [-14.766155, 0.8885144, 1.0], + [-8.151366, 0.95314914, 1.0], + [-0.43513343, 0.9979274, 1.0], + [-0.0013458556, 0.99999374, 1.0], + [0.0, 1.0, 1.0], + [-12.756684, 0.84383404, 1.0], + [-8.069312, 0.9194366, 1.0], + [-0.73601234, 0.9940366, 1.0], + [-0.006278685, 0.99995303, 1.0], + [0.0, 1.0, 1.0], + [-12.3996105, 0.78645676, 1.0], + [-7.4046135, 0.88479775, 1.0], + [-0.60771656, 0.99156505, 1.0], + [-0.005061967, 0.9999345, 1.0], + [0.0, 1.0, 1.0], + [-9.395499, 0.7727399, 1.0], + [-6.246277, 0.86766297, 1.0], + [-0.77940494, 0.98375356, 1.0], + [-0.007498043, 0.9998306, 1.0], + [0.0, 1.0, 1.0], + [-8.069854, 0.7712122, 1.0], + [-5.7335796, 0.8622934, 1.0], + [-0.7198741, 0.98598284, 1.0], + [-0.008054674, 0.9998355, 1.0], + [0.0, 1.0, 1.0], + [-7.1986036, 0.78911334, 1.0], + [-4.2859526, 0.8467513, 1.0], + [-0.30990627, 0.98300517, 1.0], + [-0.001539077, 0.99987894, 1.0], + [0.0, 1.0, 1.0], + [-4.362009, 0.862973, 1.0], + [-1.2797484, 0.89488745, 1.0], + [0.57525235, 0.9574352, 1.0], + [0.020433621, 0.9993585, 1.0], + [0.0, 1.0, 1.0], + [-0.44913286, 0.92161065, 1.0], + [2.1760025, 0.9585874, 1.0], + [3.1253667, 0.97889954, 1.0], + [0.5430722, 0.998097, 1.0], + [0.0, 1.0, 1.0], + [1.5043082, 0.92482764, 1.0], + [2.4694016, 0.9740288, 1.0], + [2.8363268, 0.99162847, 1.0], + [0.7368365, 0.9988949, 1.0], + [0.0, 1.0, 1.0], + [1.2908894, 0.9381854, 1.0], + [1.9291751, 0.9809296, 1.0], + [3.2904375, 1.0165812, 1.0], + [1.4833065, 1.0123935, 1.0], + [0.0, 1.0, 1.0], + [0.42789957, 0.9608102, 1.0], + [2.0859613, 1.0199693, 1.0], + [5.4975877, 1.0738639, 1.0], + [5.1542764, 1.0715116, 1.0], + [0.0, 1.0, 1.0], + [0.041113988, 0.91577923, 1.0], + [2.5786953, 1.0219108, 1.0], + [5.4007845, 1.0863035, 1.0], + [5.081802, 1.0821114, 1.0], + [0.0, 1.0, 1.0], + [-0.7529762, 0.8233519, 1.0], + [0.38908187, 0.9149488, 1.0], + [2.9440103, 1.0415642, 1.0], + [1.2238824, 1.0184511, 1.0], + [0.0, 1.0, 1.0], + [-1.0758475, 0.8046038, 1.0], + [-0.122891106, 0.8494543, 1.0], + [0.6972854, 0.9855657, 1.0], + [0.0899615, 0.9960086, 1.0], + [0.0, 1.0, 1.0], + [-2.1830697, 0.79670805, 1.0], + [-0.57822895, 0.83725864, 1.0], + [0.27999595, 0.9634356, 1.0], + [0.011383898, 0.99835503, 1.0], + [0.0, 1.0, 1.0], + [-2.842725, 0.77273744, 1.0], + [-0.43526837, 0.85823804, 1.0], + [-0.0893142, 0.9760571, 1.0], + [-0.014267705, 0.99880594, 1.0], + [0.0, 1.0, 1.0], + [-2.5974367, 0.7690728, 1.0], + [-0.6484193, 0.92034054, 1.0], + [-0.26290956, 1.0013702, 1.0], + [-0.007495878, 1.0000707, 1.0], + [0.0, 1.0, 1.0], + [-2.6498926, 0.7932041, 1.0], + [-0.8644455, 0.94679594, 1.0], + [-0.3005925, 1.001227, 1.0], + [-0.0067892326, 1.0000719, 1.0], + [0.0, 1.0, 1.0], + [-1.1335323, 0.85572964, 1.0], + [0.07130498, 0.97290087, 1.0], + [-0.052292693, 1.0024012, 1.0], + [-0.0014092727, 1.0000482, 1.0], + [0.0, 1.0, 1.0], + [3.0666177, 0.8645106, 1.0], + [2.0084338, 0.9645754, 1.0], + [0.19086616, 0.99913764, 1.0], + [0.0013613411, 1.000007, 1.0], + [0.0, 1.0, 1.0], + [5.092513, 0.8992678, 1.0], + [2.0034046, 0.97106105, 1.0], + [0.21043883, 0.99723476, 1.0], + [0.0017143496, 0.9999766, 1.0], + [0.0, 1.0, 1.0], + [3.0797167, 0.9267911, 1.0], + [0.22682886, 0.9934631, 1.0], + [-0.04511432, 1.0006658, 1.0], + [-0.0003056895, 1.000005, 1.0], + [0.0, 1.0, 1.0], + [3.0111675, 0.94898635, 1.0], + [1.3779145, 1.0007775, 1.0], + [0.07663839, 1.0008199, 1.0], + [0.00024265566, 1.0000049, 1.0], + [0.0, 1.0, 1.0], + [3.3374429, 0.95532244, 1.0], + [0.7815224, 0.9982896, 1.0], + [0.02490354, 1.0002367, 1.0], + [ + 7.3306524e-05, + 1.0000012, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.6006174, 0.96363807, 1.0], + [0.8803053, 0.99620026, 1.0], + [0.03250935, 1.0000377, 1.0], + [ + 7.561181e-05, + 1.0000002, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.2088186, 0.9428956, 1.0], + [-0.21422692, 0.9855742, 1.0], + [-0.030162197, 0.9994372, 1.0], + [-0.00015745845, 0.99999803, 1.0], + [0.0, 1.0, 1.0], + [5.1379385, 0.9243572, 1.0], + [0.5781135, 0.98177475, 1.0], + [-0.04092876, 0.9992675, 1.0], + [-0.00032025157, 0.9999967, 1.0], + [0.0, 1.0, 1.0], + [6.595079, 0.90528804, 1.0], + [2.2092948, 0.97474945, 1.0], + [0.051008992, 0.9995308, 1.0], + [-0.000108188826, 0.9999993, 1.0], + [0.0, 1.0, 1.0], + [4.917749, 0.9147641, 1.0], + [-0.3735081, 0.9730886, 1.0], + [-0.24302472, 0.9982874, 1.0], + [-0.002041071, 0.99998975, 1.0], + [0.0, 1.0, 1.0], + [5.282022, 0.9485167, 1.0], + [-0.5685607, 0.985533, 1.0], + [-0.31762916, 0.9988836, 1.0], + [-0.002783568, 0.99999034, 1.0], + [0.0, 1.0, 1.0], + [4.8673153, 0.9184925, 1.0], + [0.37535796, 0.9620167, 1.0], + [0.03746142, 0.9971347, 1.0], + [0.0030534149, 1.0000186, 1.0], + [0.0, 1.0, 1.0], + [7.4104285, 0.91220593, 1.0], + [0.87249917, 0.95596325, 1.0], + [-0.48440197, 0.99392086, 1.0], + [-0.019409077, 0.9999061, 1.0], + [0.0, 1.0, 1.0], + [6.944506, 0.9487564, 1.0], + [0.5558285, 0.9802875, 1.0], + [-1.4248892, 1.0021002, 1.0], + [-0.15107189, 1.0004932, 1.0], + [0.0, 1.0, 1.0], + [7.4704447, 0.9741639, 1.0], + [-2.879535, 1.0111853, 1.0], + [-4.005509, 1.0224288, 1.0], + [-2.128151, 1.013158, 1.0], + [0.0, 1.0, 1.0], + [7.3452177, 1.0056307, 1.0], + [-3.1270623, 1.0118408, 1.0], + [-4.1959133, 1.0224493, 1.0], + [-2.526543, 1.0163975, 1.0], + [0.0, 1.0, 1.0], + [-3.885383, 0.89095396, 1.0], + [-6.0336304, 0.9250444, 1.0], + [-4.8112493, 0.9800796, 1.0], + [-1.296847, 1.0041145, 1.0], + [0.0, 1.0, 1.0], + [-1.1661447, 0.89456373, 1.0], + [-2.9906945, 0.92001104, 1.0], + [-1.5090454, 0.99253356, 1.0], + [-1.2905122, 0.96820384, 1.0], + [0.0, 1.0, 1.0], + [-0.34511527, 0.87384003, 1.0], + [-1.0981119, 0.935078, 1.0], + [-1.5757431, 0.97081584, 1.0], + [-1.8983483, 0.9408328, 1.0], + [0.0, 1.0, 1.0], + [-0.63075507, 0.87999564, 1.0], + [-0.70884484, 0.933653, 1.0], + [-1.3498812, 0.95319027, 1.0], + [-2.5344388, 0.93276036, 1.0], + [0.0, 1.0, 1.0], + [0.7205443, 0.90210545, 1.0], + [0.062813476, 0.93098074, 1.0], + [-0.51643556, 0.9495486, 1.0], + [-1.4865465, 0.9489686, 1.0], + [0.0, 1.0, 1.0], + [0.76368576, 0.89917886, 1.0], + [0.10259387, 0.9347847, 1.0], + [-0.40181014, 0.95541286, 1.0], + [-0.8396916, 0.9608325, 1.0], + [0.0, 1.0, 1.0], + [1.0973828, 0.91251415, 1.0], + [0.22598426, 0.94344103, 1.0], + [-0.28614137, 0.9616241, 1.0], + [-0.73642546, 0.9707769, 1.0], + [0.0, 1.0, 1.0], + [1.713114, 0.91217285, 1.0], + [0.56806993, 0.93098885, 1.0], + [-0.07742618, 0.949184, 1.0], + [-0.55610317, 0.9599895, 1.0], + [0.0, 1.0, 1.0], + [2.2137406, 0.9127354, 1.0], + [1.1848869, 0.9180361, 1.0], + [0.20251377, 0.93949586, 1.0], + [-0.40885153, 0.94527155, 1.0], + [0.0, 1.0, 1.0], + [2.0143402, 0.91241056, 1.0], + [1.3650892, 0.9393329, 1.0], + [0.6422094, 0.9600322, 1.0], + [0.011677059, 0.95110327, 1.0], + [0.0, 1.0, 1.0], + [2.0051758, 0.8948882, 1.0], + [1.3498341, 0.9142505, 1.0], + [0.893142, 0.93700206, 1.0], + [0.27319187, 0.9396592, 1.0], + [0.0, 1.0, 1.0], + [1.1864032, 0.887447, 1.0], + [1.0613244, 0.89364576, 1.0], + [1.2507672, 0.89813834, 1.0], + [1.0013293, 0.86100835, 1.0], + [0.0, 1.0, 1.0], + [-0.5468271, 0.87609977, 1.0], + [0.2019898, 0.90091765, 1.0], + [1.2255975, 0.87712115, 1.0], + [1.2425985, 0.83051956, 1.0], + [0.0, 1.0, 1.0], + [-1.6023275, 0.8365268, 1.0], + [-0.24021581, 0.90386933, 1.0], + [0.9349438, 0.87908584, 1.0], + [0.80752313, 0.8075074, 1.0], + [0.0, 1.0, 1.0], + [-2.221242, 0.8157836, 1.0], + [-0.39425692, 0.92637694, 1.0], + [0.8056268, 0.93297124, 1.0], + [1.3850795, 0.88745916, 1.0], + [0.0, 1.0, 1.0], + [-3.3293705, 0.8298025, 1.0], + [-1.2209973, 0.939168, 1.0], + [0.27317744, 0.9352172, 1.0], + [0.54171324, 0.923213, 1.0], + [0.0, 1.0, 1.0], + [-4.3448424, 0.8548377, 1.0], + [-1.4867651, 0.96641356, 1.0], + [0.6099653, 0.9759955, 1.0], + [0.59837306, 0.95445424, 1.0], + [0.0, 1.0, 1.0], + [-5.492541, 0.88700557, 1.0], + [-1.374813, 0.9962723, 1.0], + [1.030289, 1.0119152, 1.0], + [1.5211387, 0.99829376, 1.0], + [0.0, 1.0, 1.0], + [-5.779272, 0.918289, 1.0], + [-1.9571413, 0.99485767, 1.0], + [-0.3243052, 0.98670727, 1.0], + [0.08840849, 0.96744764, 1.0], + [0.0, 1.0, 1.0], + [-5.946428, 0.92973804, 1.0], + [-3.30334, 0.94437987, 1.0], + [-2.2054818, 0.92658573, 1.0], + [-1.9096534, 0.9025644, 1.0], + [0.0, 1.0, 1.0], + [-7.0771604, 0.938371, 1.0], + [-4.9650874, 0.9262841, 1.0], + [-5.804127, 0.87206984, 1.0], + [-10.723357, 0.7699574, 1.0], + [0.0, 1.0, 1.0], + [-8.103424, 0.9506851, 1.0], + [-7.6311193, 0.9232969, 1.0], + [-13.356695, 0.7412918, 1.0], + [-18.471521, 0.6411481, 1.0], + [0.0, 1.0, 1.0], + [-8.404529, 0.9482244, 1.0], + [-5.100853, 0.9020949, 1.0], + [-3.5057256, 0.7494264, 1.0], + [-5.474798, 0.7397798, 1.0], + [0.0, 1.0, 1.0], + [-8.278467, 0.9477641, 1.0], + [-1.6004895, 0.8717935, 1.0], + [3.8334162, 0.7214794, 1.0], + [3.0950382, 0.8104765, 1.0], + [0.0, 1.0, 1.0], + [-8.883697, 0.95728064, 1.0], + [0.22832659, 0.8919602, 1.0], + [3.9137595, 0.8611687, 1.0], + [1.0353252, 0.95515287, 1.0], + [0.0, 1.0, 1.0], + [-8.987924, 0.9267598, 1.0], + [-0.7117046, 0.9237348, 1.0], + [1.0760044, 0.9687251, 1.0], + [0.19669743, 0.9952294, 1.0], + [0.0, 1.0, 1.0], + [-11.256816, 0.895007, 1.0], + [-3.5309718, 0.9615622, 1.0], + [-0.049140226, 0.9978403, 1.0], + [0.003940524, 0.9998941, 1.0], + [0.0, 1.0, 1.0], + [-12.83839, 0.8533601, 1.0], + [-3.8871167, 0.9450403, 1.0], + [-0.04521785, 0.99777806, 1.0], + [0.0017813657, 0.99995136, 1.0], + [0.0, 1.0, 1.0], + [-10.2904825, 0.87262124, 1.0], + [-0.73294914, 0.93938565, 1.0], + [0.94196904, 0.9771655, 1.0], + [0.050941736, 0.9988109, 1.0], + [0.0, 1.0, 1.0], + [-10.646717, 0.87451744, 1.0], + [-1.9415165, 0.94662905, 1.0], + [0.6730928, 0.9824702, 1.0], + [0.04025293, 0.99905807, 1.0], + [0.0, 1.0, 1.0], + [-11.218059, 0.87239087, 1.0], + [-4.397181, 0.9521776, 1.0], + [-0.14049566, 0.9981121, 1.0], + [0.00026845204, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-10.286129, 0.89371806, 1.0], + [-5.3461366, 0.96262884, 1.0], + [-0.24486518, 0.9982041, 1.0], + [-0.00065676763, 0.9999931, 1.0], + [0.0, 1.0, 1.0], + [-9.945648, 0.8883441, 1.0], + [-5.1836414, 0.97065026, 1.0], + [-0.20957097, 0.99866295, 1.0], + [-0.0005872021, 0.9999922, 1.0], + [0.0, 1.0, 1.0], + [-8.575455, 0.85818356, 1.0], + [-3.7089288, 0.96788406, 1.0], + [-0.118012555, 0.99915797, 1.0], + [-0.0004706569, 0.99999267, 1.0], + [0.0, 1.0, 1.0], + [-6.362028, 0.81099313, 1.0], + [-1.9053118, 0.9541133, 1.0], + [-0.02172425, 0.9980922, 1.0], + [0.00013715065, 0.9999891, 1.0], + [0.0, 1.0, 1.0], + [-6.124928, 0.8288149, 1.0], + [-2.1002314, 0.9534652, 1.0], + [-0.041706882, 0.99746007, 1.0], + [0.00017853655, 0.9999867, 1.0], + [0.0, 1.0, 1.0], + [-5.7694325, 0.8521812, 1.0], + [-2.324253, 0.9688911, 1.0], + [-0.09758008, 0.9992199, 1.0], + [-0.0002588657, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [-6.6154294, 0.8325412, 1.0], + [-1.5618141, 0.9723158, 1.0], + [-0.043864716, 0.999613, 1.0], + [-0.00013398798, 0.9999991, 1.0], + [0.0, 1.0, 1.0], + [-6.2191544, 0.77018803, 1.0], + [-1.651971, 0.9547857, 1.0], + [-0.07113765, 0.9991722, 1.0], + [-0.00058576465, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [-6.46673, 0.72007173, 1.0], + [-2.0999687, 0.9299423, 1.0], + [-0.060667057, 0.99887276, 1.0], + [-0.00039300308, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [-2.3606741, 0.70400625, 1.0], + [-1.1170449, 0.90795213, 1.0], + [-0.03983348, 0.9983942, 1.0], + [-0.0002703255, 0.99999326, 1.0], + [0.0, 1.0, 1.0], + [-1.6689389, 0.665112, 1.0], + [-1.5962267, 0.8485116, 1.0], + [-0.071202874, 0.9962268, 1.0], + [-0.00039650878, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-2.192851, 0.6639008, 1.0], + [-1.9858385, 0.77799946, 1.0], + [-0.08988286, 0.9801069, 1.0], + [0.0011868065, 0.99986833, 1.0], + [0.0, 1.0, 1.0], + [-1.2615559, 0.7249733, 1.0], + [-0.09431905, 0.7996679, 1.0], + [0.35236925, 0.902335, 1.0], + [0.014255428, 0.9975748, 1.0], + [0.0, 1.0, 1.0], + [1.5921153, 0.8544056, 1.0], + [2.169442, 0.8899757, 1.0], + [1.4621226, 0.8872571, 1.0], + [0.09632246, 0.9899705, 1.0], + [0.0, 1.0, 1.0], + [1.8741682, 0.87441283, 1.0], + [2.3036768, 0.93104565, 1.0], + [1.8774738, 0.9538973, 1.0], + [0.09678561, 0.99769336, 1.0], + [0.0, 1.0, 1.0], + [1.6764153, 0.8590117, 1.0], + [2.3436446, 0.93695617, 1.0], + [2.9313862, 0.9975119, 1.0], + [0.7593898, 1.0038697, 1.0], + [0.0, 1.0, 1.0], + [1.5126485, 0.8899826, 1.0], + [3.1817915, 0.9964198, 1.0], + [4.897578, 1.0497705, 1.0], + [3.8805265, 1.0446904, 1.0], + [0.0, 1.0, 1.0], + [1.2394414, 0.8644259, 1.0], + [3.212901, 1.0230495, 1.0], + [4.632083, 1.0699781, 1.0], + [3.9237301, 1.0605509, 1.0], + [0.0, 1.0, 1.0], + [0.5773077, 0.82117635, 1.0], + [1.2358187, 0.97362995, 1.0], + [2.1459916, 1.0216151, 1.0], + [0.7130695, 1.0066472, 1.0], + [0.0, 1.0, 1.0], + [0.42648524, 0.82499254, 1.0], + [0.69195044, 0.9376137, 1.0], + [0.50218654, 0.9798264, 1.0], + [0.12657197, 0.9915114, 1.0], + [0.0, 1.0, 1.0], + [-0.7465619, 0.86613846, 1.0], + [0.25071248, 0.94280666, 1.0], + [0.06618124, 0.98196954, 1.0], + [0.008201971, 0.9988316, 1.0], + [0.0, 1.0, 1.0], + [-0.30600023, 0.8721118, 1.0], + [0.4314889, 0.94861966, 1.0], + [0.009828173, 0.9878695, 1.0], + [-0.006427021, 0.9995776, 1.0], + [0.0, 1.0, 1.0], + [0.5700442, 0.89965767, 1.0], + [-0.05676093, 0.9809169, 1.0], + [-0.15799609, 1.001165, 1.0], + [-0.005288328, 1.0000738, 1.0], + [0.0, 1.0, 1.0], + [0.82145816, 0.9085153, 1.0], + [-0.31160617, 0.9824265, 1.0], + [-0.2175388, 1.0011835, 1.0], + [-0.004642329, 1.0000556, 1.0], + [0.0, 1.0, 1.0], + [0.453476, 0.92736363, 1.0], + [0.20573618, 0.9796587, 1.0], + [-0.02427894, 0.9999128, 1.0], + [-0.00065988675, 1.0000101, 1.0], + [0.0, 1.0, 1.0], + [0.7588439, 0.9440933, 1.0], + [0.684478, 0.9797199, 1.0], + [0.072028, 0.9987622, 1.0], + [0.0004616782, 0.9999967, 1.0], + [0.0, 1.0, 1.0], + [1.706155, 0.96692014, 1.0], + [0.67578906, 0.99002194, 1.0], + [0.055119734, 0.99926174, 1.0], + [0.0003961114, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [0.7588563, 0.9780419, 1.0], + [-0.09298713, 0.99886334, 1.0], + [-0.01754928, 1.0001751, 1.0], + [-0.00010249701, 1.0000014, 1.0], + [0.0, 1.0, 1.0], + [0.8756794, 0.9665713, 1.0], + [0.21679287, 0.9911038, 1.0], + [0.010155896, 0.9995159, 1.0], + [ + 2.8347398e-05, + 0.9999979, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.7446922, 0.9719108, 1.0], + [0.13179037, 0.9942989, 1.0], + [0.005878921, 0.99972135, 1.0], + [ + 2.3439836e-05, + 0.99999875, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.03203987, 0.9929243, 1.0], + [0.038900487, 0.99922925, 1.0], + [0.0021857598, 0.9999744, 1.0], + [ + 5.484122e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.4139118, 0.96163833, 1.0], + [-0.21519373, 0.9870976, 1.0], + [-0.020248298, 0.9992978, 1.0], + [ + -9.694598e-05, + 0.99999726, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1697552, 0.9555298, 1.0], + [-0.3862619, 0.98755735, 1.0], + [-0.044676315, 0.9992296, 1.0], + [-0.0002547122, 0.9999961, 1.0], + [0.0, 1.0, 1.0], + [0.12476056, 0.9306088, 1.0], + [0.16077553, 0.9884132, 1.0], + [0.004473155, 0.9998182, 1.0], + [ + -1.2910279e-05, + 0.9999996, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.34852365, 0.9528473, 1.0], + [-0.21723595, 0.9923173, 1.0], + [-0.032549698, 0.99970585, 1.0], + [-0.00026074066, 0.99999833, 1.0], + [0.0, 1.0, 1.0], + [-0.93782365, 0.9629782, 1.0], + [-0.97855735, 0.9888486, 1.0], + [-0.09944643, 0.9992393, 1.0], + [-0.0006539717, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [-1.6884682, 0.9228505, 1.0], + [-1.57592, 0.95246685, 1.0], + [-0.1084121, 0.9967328, 1.0], + [0.00042106112, 0.9999968, 1.0], + [0.0, 1.0, 1.0], + [0.49395004, 0.9275926, 1.0], + [-1.7809842, 0.9529799, 1.0], + [-0.6778947, 0.9934386, 1.0], + [-0.02107622, 0.99988353, 1.0], + [0.0, 1.0, 1.0], + [0.09461049, 0.91591424, 1.0], + [-2.842822, 0.9585913, 1.0], + [-2.0642266, 1.0008389, 1.0], + [-0.19923098, 1.0007129, 1.0], + [0.0, 1.0, 1.0], + [-1.4209429, 0.9632619, 1.0], + [-4.301751, 1.0129267, 1.0], + [-4.282477, 1.0219837, 1.0], + [-2.4989915, 1.014272, 1.0], + [0.0, 1.0, 1.0], + [-2.8516593, 0.94976884, 1.0], + [-4.918388, 0.9980298, 1.0], + [-4.6032376, 1.0179507, 1.0], + [-2.9246483, 1.0153408, 1.0], + [0.0, 1.0, 1.0], + [-5.434877, 0.8649714, 1.0], + [-6.194555, 0.86495477, 1.0], + [-2.6064827, 0.9455794, 1.0], + [0.18676785, 0.99121183, 1.0], + [0.0, 1.0, 1.0], + [-3.0596585, 0.88212174, 1.0], + [-0.39083886, 0.8480833, 1.0], + [7.699114, 0.8111516, 1.0], + [4.9935384, 0.8855417, 1.0], + [0.0, 1.0, 1.0], + [1.1838806, 0.8388054, 1.0], + [7.2978683, 0.7670886, 1.0], + [8.46766, 0.78871816, 1.0], + [2.179444, 0.9128828, 1.0], + [0.0, 1.0, 1.0], + [1.2858877, 0.8069495, 1.0], + [1.8508788, 0.7823992, 1.0], + [0.85906374, 0.84280854, 1.0], + [-0.8319525, 0.9454967, 1.0], + [0.0, 1.0, 1.0], + [1.1529174, 0.7913341, 1.0], + [1.0985173, 0.77566886, 1.0], + [0.7970306, 0.8064273, 1.0], + [-0.13033624, 0.95262057, 1.0], + [0.0, 1.0, 1.0], + [1.2466996, 0.7730009, 1.0], + [1.4481469, 0.79517776, 1.0], + [1.4872804, 0.8149948, 1.0], + [0.50854456, 0.9299336, 1.0], + [0.0, 1.0, 1.0], + [2.0708513, 0.77292246, 1.0], + [1.9574028, 0.80550534, 1.0], + [1.8944045, 0.8277315, 1.0], + [0.885336, 0.9109165, 1.0], + [0.0, 1.0, 1.0], + [2.8570101, 0.7737381, 1.0], + [2.1413271, 0.81192267, 1.0], + [1.4845073, 0.8555065, 1.0], + [0.67854315, 0.9129586, 1.0], + [0.0, 1.0, 1.0], + [3.5446448, 0.7237353, 1.0], + [3.1861176, 0.7315414, 1.0], + [2.4211752, 0.7813792, 1.0], + [1.090997, 0.862102, 1.0], + [0.0, 1.0, 1.0], + [3.3204086, 0.69664717, 1.0], + [3.060574, 0.7266127, 1.0], + [2.3519804, 0.81517905, 1.0], + [0.86642855, 0.92962444, 1.0], + [0.0, 1.0, 1.0], + [2.7798967, 0.6820568, 1.0], + [2.8921554, 0.7140203, 1.0], + [3.4070027, 0.7793045, 1.0], + [1.9032202, 0.9059005, 1.0], + [0.0, 1.0, 1.0], + [1.8094573, 0.6692679, 1.0], + [3.514479, 0.6703009, 1.0], + [3.2144916, 0.7359185, 1.0], + [0.7362445, 0.8973399, 1.0], + [0.0, 1.0, 1.0], + [0.8017239, 0.7016305, 1.0], + [4.4307218, 0.6483909, 1.0], + [1.597159, 0.63064265, 1.0], + [-0.7608446, 0.6738216, 1.0], + [0.0, 1.0, 1.0], + [-0.70194286, 0.71021265, 1.0], + [4.076452, 0.64545816, 1.0], + [2.4849653, 0.58867955, 1.0], + [-0.3030934, 0.5878087, 1.0], + [0.0, 1.0, 1.0], + [-2.423018, 0.7011179, 1.0], + [0.867048, 0.7857347, 1.0], + [1.8778415, 0.7980667, 1.0], + [0.994016, 0.74506015, 1.0], + [0.0, 1.0, 1.0], + [-3.9815702, 0.71286356, 1.0], + [-1.3673462, 0.86698985, 1.0], + [-0.31344816, 0.9293362, 1.0], + [-0.5929167, 0.9181718, 1.0], + [0.0, 1.0, 1.0], + [-4.8956604, 0.7347033, 1.0], + [-1.5237117, 0.8810682, 1.0], + [-1.8345463, 0.87915176, 1.0], + [-4.8288293, 0.8226873, 1.0], + [0.0, 1.0, 1.0], + [-5.5750155, 0.75573695, 1.0], + [-2.32776, 0.83855045, 1.0], + [-7.9848466, 0.7422793, 1.0], + [-11.596745, 0.70366794, 1.0], + [0.0, 1.0, 1.0], + [-4.497558, 0.7963771, 1.0], + [-2.6339295, 0.79546416, 1.0], + [-6.573479, 0.6898624, 1.0], + [-10.560926, 0.66375583, 1.0], + [0.0, 1.0, 1.0], + [-4.5946136, 0.8173761, 1.0], + [-3.3373203, 0.80128264, 1.0], + [-1.2990564, 0.73161983, 1.0], + [-0.411279, 0.7264181, 1.0], + [0.0, 1.0, 1.0], + [-4.839973, 0.827455, 1.0], + [-8.91897, 0.7633143, 1.0], + [-13.195236, 0.68692213, 1.0], + [-15.08099, 0.667959, 1.0], + [0.0, 1.0, 1.0], + [-7.8307505, 0.81448054, 1.0], + [-18.429901, 0.6384616, 1.0], + [-21.627077, 0.5857334, 1.0], + [-21.719206, 0.57701963, 1.0], + [0.0, 1.0, 1.0], + [-5.3877606, 0.87045765, 1.0], + [-11.953717, 0.6845442, 1.0], + [-14.84228, 0.6076244, 1.0], + [-14.956815, 0.59963316, 1.0], + [0.0, 1.0, 1.0], + [-2.2047, 0.89726675, 1.0], + [-0.18282485, 0.7400877, 1.0], + [-0.45509633, 0.6334427, 1.0], + [-4.008627, 0.66572005, 1.0], + [0.0, 1.0, 1.0], + [-0.6257489, 0.8809391, 1.0], + [4.4138246, 0.7427594, 1.0], + [6.3582187, 0.7134476, 1.0], + [2.7748961, 0.7984047, 1.0], + [0.0, 1.0, 1.0], + [-0.90505534, 0.92275494, 1.0], + [4.2922206, 0.83137786, 1.0], + [4.8596277, 0.8543205, 1.0], + [1.9056705, 0.939401, 1.0], + [0.0, 1.0, 1.0], + [-1.7767366, 0.95586765, 1.0], + [0.3932802, 0.9633871, 1.0], + [0.60861015, 0.97952, 1.0], + [0.036355883, 0.9975114, 1.0], + [0.0, 1.0, 1.0], + [-1.3862658, 0.93128735, 1.0], + [1.6583297, 0.9430399, 1.0], + [1.3840419, 0.9670389, 1.0], + [0.106311135, 0.99748856, 1.0], + [0.0, 1.0, 1.0], + [1.2226677, 0.91598487, 1.0], + [4.22652, 0.9187779, 1.0], + [1.5662466, 0.96636087, 1.0], + [0.076074705, 0.99824005, 1.0], + [0.0, 1.0, 1.0], + [-0.6022197, 0.9403912, 1.0], + [1.2155503, 0.962949, 1.0], + [0.8285263, 0.9807966, 1.0], + [0.044100095, 0.99897105, 1.0], + [0.0, 1.0, 1.0], + [-1.7198387, 0.9403748, 1.0], + [-0.6352572, 0.9795508, 1.0], + [-0.052131, 0.998167, 1.0], + [0.0001652719, 0.9999738, 1.0], + [0.0, 1.0, 1.0], + [-0.5542123, 0.9565107, 1.0], + [-0.109918736, 0.9894566, 1.0], + [-0.009283989, 0.999551, 1.0], + [ + -6.8360925e-05, + 0.9999978, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.7696542, 0.95847034, 1.0], + [-0.15155797, 0.9900539, 1.0], + [-0.06100524, 0.9982844, 1.0], + [-0.0020035189, 0.9999522, 1.0], + [0.0, 1.0, 1.0], + [-1.1908454, 0.9578101, 1.0], + [-1.193139, 0.9622795, 1.0], + [-0.66812485, 0.9839621, 1.0], + [-0.022401229, 0.99947536, 1.0], + [0.0, 1.0, 1.0], + [-0.7816994, 0.93443125, 1.0], + [0.6398307, 0.96510935, 1.0], + [-0.044041365, 0.9943015, 1.0], + [-0.003998923, 0.9998783, 1.0], + [0.0, 1.0, 1.0], + [-0.28142613, 0.93776274, 1.0], + [0.8375012, 0.9702105, 1.0], + [0.101512685, 0.997176, 1.0], + [0.00067303015, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-0.8797605, 0.95135915, 1.0], + [-0.126102, 0.993933, 1.0], + [-0.003883867, 0.99978, 1.0], + [ + -8.009816e-06, + 0.99999905, + 1.0 + ], + [0.0, 1.0, 1.0], + [-2.6810584, 0.86311865, 1.0], + [-0.17959136, 0.9858264, 1.0], + [-0.0015349581, 0.9998344, 1.0], + [ + -8.907976e-06, + 0.99999964, + 1.0 + ], + [0.0, 1.0, 1.0], + [-5.445669, 0.7283394, 1.0], + [-0.6088305, 0.9556886, 1.0], + [-0.01624582, 0.9992147, 1.0], + [ + -9.05368e-05, + 0.9999974, + 1.0 + ], + [0.0, 1.0, 1.0], + [-6.5090322, 0.71607465, 1.0], + [-0.7856189, 0.9618718, 1.0], + [-0.0063526426, 0.99975485, 1.0], + [ + -1.7714505e-05, + 0.9999995, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.7068195, 0.77349, 1.0], + [-0.24131422, 0.9724856, 1.0], + [-0.0030667144, 0.99979204, 1.0], + [ + -4.3706127e-06, + 0.9999998, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.1626506, 0.7329441, 1.0], + [-0.49740094, 0.9462425, 1.0], + [-0.013326823, 0.999034, 1.0], + [ + -2.82295e-05, + 0.99999803, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.86920273, 0.6843783, 1.0], + [-0.8369342, 0.8680424, 1.0], + [-0.055886846, 0.99398565, 1.0], + [-0.00019886444, 0.99997324, 1.0], + [0.0, 1.0, 1.0], + [-2.0519063, 0.67568576, 1.0], + [-1.380502, 0.7593411, 1.0], + [-0.23168321, 0.93081456, 1.0], + [-0.0014318114, 0.99881375, 1.0], + [0.0, 1.0, 1.0], + [-0.6460104, 0.7239269, 1.0], + [-0.27629834, 0.769188, 1.0], + [-0.002961831, 0.8593207, 1.0], + [0.0041828407, 0.993566, 1.0], + [0.0, 1.0, 1.0], + [0.37849802, 0.775351, 1.0], + [0.36891446, 0.8593253, 1.0], + [0.03710465, 0.9495408, 1.0], + [0.0007436436, 0.99869233, 1.0], + [0.0, 1.0, 1.0], + [0.4106183, 0.7918468, 1.0], + [0.9571653, 0.9164912, 1.0], + [0.2806054, 0.98760474, 1.0], + [0.026692087, 0.99853516, 1.0], + [0.0, 1.0, 1.0], + [0.68634075, 0.8532397, 1.0], + [1.1827903, 0.9357391, 1.0], + [0.869629, 0.95853925, 1.0], + [0.24507365, 0.9850702, 1.0], + [0.0, 1.0, 1.0], + [0.6320396, 0.9290106, 1.0], + [0.90500677, 0.964906, 1.0], + [0.9593051, 0.9864873, 1.0], + [0.21237753, 0.9966006, 1.0], + [0.0, 1.0, 1.0], + [0.048774928, 0.95484895, 1.0], + [-0.77230346, 0.96596956, 1.0], + [-0.36607814, 0.9833947, 1.0], + [-0.027992101, 0.9972649, 1.0], + [0.0, 1.0, 1.0], + [0.07467206, 0.9574848, 1.0], + [-0.46401745, 0.9480488, 1.0], + [-0.7730967, 0.85603, 1.0], + [-0.13825144, 0.9314603, 1.0], + [0.0, 1.0, 1.0], + [-0.20196562, 0.9510938, 1.0], + [0.4332324, 0.8811578, 1.0], + [0.7512844, 0.86460304, 1.0], + [0.38913283, 0.9498416, 1.0], + [0.0, 1.0, 1.0], + [0.07075552, 0.9259933, 1.0], + [1.2225338, 0.89603615, 1.0], + [0.39722675, 0.97127604, 1.0], + [0.020897377, 0.99866724, 1.0], + [0.0, 1.0, 1.0], + [1.8001552, 0.9517485, 1.0], + [1.2267344, 0.9665981, 1.0], + [0.074866876, 0.99737835, 1.0], + [0.00035822866, 0.9999817, 1.0], + [0.0, 1.0, 1.0], + [1.1735111, 0.9407677, 1.0], + [0.55925053, 0.9782085, 1.0], + [0.014851813, 0.99942917, 1.0], + [ + -2.6381907e-05, + 0.9999996, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.491996, 0.96088326, 1.0], + [-0.10901659, 0.9911518, 1.0], + [-0.0014201105, 0.9998647, 1.0], + [ + -7.0408996e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.9082432, 0.97429425, 1.0], + [0.14978711, 0.9955272, 1.0], + [0.0022982752, 0.99993473, 1.0], + [ + 5.9549325e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.3517478, 0.99564123, 1.0], + [0.06255331, 0.99933046, 1.0], + [0.0009568419, 0.9999891, 1.0], + [ + 2.612675e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.74196255, 1.0120044, 1.0], + [0.081937216, 1.0010135, 1.0], + [0.00051152834, 0.99999976, 1.0], + [ + -1.3411326e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.2244991, 0.99848604, 1.0], + [0.034186296, 0.9979303, 1.0], + [0.001426202, 0.9998331, 1.0], + [ + 6.448161e-06, + 0.99999917, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.5212085, 0.99461675, 1.0], + [0.039274894, 0.9988406, 1.0], + [0.00067081454, 0.9999321, 1.0], + [ + 2.6707798e-06, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.42774403, 1.0063812, 1.0], + [0.02219702, 1.0003272, 1.0], + [ + 5.577047e-05, + 1.0000001, + 1.0 + ], + [ + -3.6048577e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1645134, 0.9992673, 1.0], + [0.006846053, 0.999736, 1.0], + [-0.00019761767, 0.99998826, 1.0], + [ + -1.0566964e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.0024718763, 0.99704987, 1.0], + [-0.005746108, 0.99963886, 1.0], + [-0.0005149844, 0.99998856, 1.0], + [ + -2.8267918e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.01222888, 0.99399185, 1.0], + [-0.0011779555, 0.99944973, 1.0], + [ + -4.2567906e-05, + 0.99999565, + 1.0 + ], + [ + -2.0611503e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.4803884, 0.9846379, 1.0], + [0.026714282, 0.9990032, 1.0], + [ + -4.8978454e-06, + 0.99999505, + 1.0 + ], + [ + -7.071696e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.06271327, 0.9943767, 1.0], + [-0.012053411, 0.99946314, 1.0], + [-0.0010432169, 0.99998796, 1.0], + [ + -4.96212e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.030970754, 0.9985594, 1.0], + [-0.044691127, 0.99863976, 1.0], + [-0.002984863, 0.999931, 1.0], + [ + -1.1928371e-05, + 0.99999976, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.13066156, 0.99966115, 1.0], + [-0.03325378, 0.99870265, 1.0], + [-0.012181685, 0.9998752, 1.0], + [-0.00035528053, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [-0.006449914, 0.99292916, 1.0], + [-0.10845718, 0.99747235, 1.0], + [-0.06701636, 1.0000327, 1.0], + [-0.0040320284, 1.0000145, 1.0], + [0.0, 1.0, 1.0], + [-0.2692093, 0.9923292, 1.0], + [-1.1617728, 1.0010196, 1.0], + [-1.1378351, 1.0052049, 1.0], + [-0.107098795, 1.0005559, 1.0], + [0.0, 1.0, 1.0], + [-2.1599848, 0.94598794, 1.0], + [-3.3414283, 0.9492445, 1.0], + [-1.9137881, 0.9936844, 1.0], + [-0.16074239, 1.0003223, 1.0], + [0.0, 1.0, 1.0], + [-0.12844217, 0.99728847, 1.0], + [-0.06282799, 0.9969152, 1.0], + [0.1619825, 0.9955758, 1.0], + [0.11950376, 0.9972032, 1.0], + [0.0, 1.0, 1.0], + [0.35873035, 0.9887963, 1.0], + [1.8258559, 0.95673186, 1.0], + [4.998067, 0.88979626, 1.0], + [2.386227, 0.9458393, 1.0], + [0.0, 1.0, 1.0], + [0.864645, 0.9789599, 1.0], + [3.940015, 0.91087675, 1.0], + [5.492297, 0.8787958, 1.0], + [1.6959858, 0.961089, 1.0], + [0.0, 1.0, 1.0], + [1.0089777, 0.9739419, 1.0], + [1.5740615, 0.9393496, 1.0], + [0.7091527, 0.9656322, 1.0], + [0.058232725, 0.9972437, 1.0], + [0.0, 1.0, 1.0], + [0.45170605, 0.9703701, 1.0], + [0.8162551, 0.9256496, 1.0], + [0.38978133, 0.9669807, 1.0], + [0.015846139, 0.99831927, 1.0], + [0.0, 1.0, 1.0], + [0.8393841, 0.93710715, 1.0], + [2.4146032, 0.8306746, 1.0], + [1.9834396, 0.877878, 1.0], + [0.15351856, 0.990385, 1.0], + [0.0, 1.0, 1.0], + [1.5492482, 0.916484, 1.0], + [3.833322, 0.7736226, 1.0], + [3.7509248, 0.7980663, 1.0], + [0.42413434, 0.97344464, 1.0], + [0.0, 1.0, 1.0], + [2.5753381, 0.9001595, 1.0], + [1.4414841, 0.89536625, 1.0], + [0.870943, 0.9281202, 1.0], + [0.78198856, 0.9027962, 1.0], + [0.0, 1.0, 1.0], + [3.626003, 0.8573594, 1.0], + [1.744929, 0.8717852, 1.0], + [0.45063186, 0.95120394, 1.0], + [0.49293122, 0.93314904, 1.0], + [0.0, 1.0, 1.0], + [3.8157206, 0.79073906, 1.0], + [1.8466086, 0.8735757, 1.0], + [0.3013591, 0.97690207, 1.0], + [0.38631627, 0.9668622, 1.0], + [0.0, 1.0, 1.0], + [4.498729, 0.75619996, 1.0], + [4.230379, 0.81058335, 1.0], + [1.221213, 0.9268467, 1.0], + [1.0963763, 0.9156937, 1.0], + [0.0, 1.0, 1.0], + [3.932148, 0.7442637, 1.0], + [6.609813, 0.7236575, 1.0], + [2.5758474, 0.83543426, 1.0], + [0.2271159, 0.97122496, 1.0], + [0.0, 1.0, 1.0], + [3.9551125, 0.7783704, 1.0], + [6.2856493, 0.6595728, 1.0], + [2.7745192, 0.6280471, 1.0], + [2.0338192, 0.6878951, 1.0], + [0.0, 1.0, 1.0], + [2.0266984, 0.87015027, 1.0], + [4.2741194, 0.7685621, 1.0], + [3.0171409, 0.6355655, 1.0], + [2.7988803, 0.59572875, 1.0], + [0.0, 1.0, 1.0], + [0.79312897, 0.9363041, 1.0], + [0.91887796, 0.95661134, 1.0], + [0.67517465, 0.9250825, 1.0], + [0.37668255, 0.869786, 1.0], + [0.0, 1.0, 1.0], + [0.437517, 0.9439974, 1.0], + [0.038780373, 0.9556593, 1.0], + [0.267114, 0.9353768, 1.0], + [1.6325206, 0.92748356, 1.0], + [0.0, 1.0, 1.0], + [-0.43791065, 0.9496331, 1.0], + [-1.3674537, 0.87884694, 1.0], + [-2.1990373, 0.8128939, 1.0], + [-1.9359235, 0.82100236, 1.0], + [0.0, 1.0, 1.0], + [-0.76871, 0.9194859, 1.0], + [-1.2029064, 0.84553814, 1.0], + [-5.327206, 0.77611315, 1.0], + [-8.786878, 0.7737814, 1.0], + [0.0, 1.0, 1.0], + [-1.2917868, 0.9232655, 1.0], + [-1.4194634, 0.878315, 1.0], + [-5.7113304, 0.78513825, 1.0], + [-11.315231, 0.7345054, 1.0], + [0.0, 1.0, 1.0], + [-1.282819, 0.963271, 1.0], + [-0.7401497, 0.92713356, 1.0], + [0.003420251, 0.86226225, 1.0], + [-1.9279886, 0.87573004, 1.0], + [0.0, 1.0, 1.0], + [-1.4500295, 0.96384203, 1.0], + [-3.9545982, 0.9027838, 1.0], + [-4.0920296, 0.87749696, 1.0], + [-5.584249, 0.82730573, 1.0], + [0.0, 1.0, 1.0], + [-2.7861502, 0.91505295, 1.0], + [-9.366375, 0.7534717, 1.0], + [-12.683253, 0.7126221, 1.0], + [-13.721524, 0.69018114, 1.0], + [0.0, 1.0, 1.0], + [-0.63505775, 0.9360138, 1.0], + [-5.646698, 0.7840974, 1.0], + [-11.826459, 0.6914667, 1.0], + [-12.043715, 0.64471924, 1.0], + [0.0, 1.0, 1.0], + [-0.20236929, 0.98128676, 1.0], + [-0.9277988, 0.9150858, 1.0], + [0.17753038, 0.775079, 1.0], + [3.4420626, 0.68469423, 1.0], + [0.0, 1.0, 1.0], + [0.59386843, 0.9422246, 1.0], + [1.948908, 0.89293414, 1.0], + [1.7242484, 0.8869361, 1.0], + [1.5372764, 0.90924776, 1.0], + [0.0, 1.0, 1.0], + [-0.17006855, 0.9595094, 1.0], + [0.9244115, 0.9223015, 1.0], + [-2.5031877, 0.88388723, 1.0], + [-1.2975974, 0.9453137, 1.0], + [0.0, 1.0, 1.0], + [0.43563363, 0.9744029, 1.0], + [1.7651839, 0.9294651, 1.0], + [0.3709067, 0.95219886, 1.0], + [-0.23438755, 0.991713, 1.0], + [0.0, 1.0, 1.0], + [0.8836351, 0.92381245, 1.0], + [3.3614104, 0.8902626, 1.0], + [2.6299663, 0.9327052, 1.0], + [0.20268272, 0.9949902, 1.0], + [0.0, 1.0, 1.0], + [0.03636021, 0.96812797, 1.0], + [0.9191721, 0.98153824, 1.0], + [0.49294955, 0.9890738, 1.0], + [0.035599157, 0.9991702, 1.0], + [0.0, 1.0, 1.0], + [-0.36295575, 0.98267245, 1.0], + [-0.65720224, 0.9795889, 1.0], + [-0.06744636, 0.9974913, 1.0], + [0.00014511583, 0.9999672, 1.0], + [0.0, 1.0, 1.0], + [-1.7780958, 0.92661333, 1.0], + [-3.7994225, 0.90834874, 1.0], + [-0.5296, 0.98757935, 1.0], + [-0.004139361, 0.99990284, 1.0], + [0.0, 1.0, 1.0], + [0.4182173, 0.94612914, 1.0], + [-0.2263425, 0.96315044, 1.0], + [-0.060515344, 0.9972809, 1.0], + [-0.00054953585, 0.9999832, 1.0], + [0.0, 1.0, 1.0], + [0.2474353, 0.96370727, 1.0], + [0.050504524, 0.9636764, 1.0], + [-0.08704806, 0.9956043, 1.0], + [-0.0032428538, 0.9999153, 1.0], + [0.0, 1.0, 1.0], + [0.20877357, 0.9770072, 1.0], + [-1.6285077, 0.9417298, 1.0], + [-1.0749032, 0.9740643, 1.0], + [-0.036731955, 0.9991393, 1.0], + [0.0, 1.0, 1.0], + [0.5124754, 0.97665536, 1.0], + [0.05648161, 0.97246367, 1.0], + [-0.20485052, 0.99366885, 1.0], + [-0.007456854, 0.99982077, 1.0], + [0.0, 1.0, 1.0], + [-1.0751122, 0.9738437, 1.0], + [-1.0979657, 0.9731047, 1.0], + [-0.07396637, 0.99813634, 1.0], + [-0.00031904137, 0.9999918, 1.0], + [0.0, 1.0, 1.0], + [-0.81148493, 0.9809923, 1.0], + [-0.79679507, 0.98153543, 1.0], + [-0.052809343, 0.9987658, 1.0], + [-0.00021324423, 0.999995, 1.0], + [0.0, 1.0, 1.0], + [-0.70272887, 0.97712904, 1.0], + [-0.23628409, 0.99248105, 1.0], + [-0.017436834, 0.99954605, 1.0], + [ + -9.100488e-05, + 0.9999978, + 1.0 + ], + [0.0, 1.0, 1.0], + [-2.112568, 0.92536825, 1.0], + [-1.793664, 0.952624, 1.0], + [-0.15880403, 0.99615294, 1.0], + [-0.0008353359, 0.9999803, 1.0], + [0.0, 1.0, 1.0], + [-1.0425283, 0.9575445, 1.0], + [-0.37356365, 0.9898527, 1.0], + [-0.027834127, 0.99933815, 1.0], + [-0.00014425212, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.5929818, 0.9799135, 1.0], + [-0.06785171, 0.9980503, 1.0], + [-0.0006129217, 0.99998415, 1.0], + [ + -7.431623e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.0108821, 0.97179604, 1.0], + [-0.12948345, 0.99659497, 1.0], + [-0.0011306758, 0.9999685, 1.0], + [ + -7.843986e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.1479146, 0.98383874, 1.0], + [-0.026573375, 0.9968805, 1.0], + [-0.0007648126, 0.9999066, 1.0], + [ + -2.9774496e-06, + 0.99999964, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.42330608, 0.9586368, 1.0], + [-0.117418036, 0.97570026, 1.0], + [-0.006413941, 0.9977138, 1.0], + [ + -4.5600293e-05, + 0.9999782, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.482407, 0.9569524, 1.0], + [-0.5354113, 0.95456654, 1.0], + [-0.05666969, 0.99288625, 1.0], + [-0.00037526168, 0.9998962, 1.0], + [0.0, 1.0, 1.0], + [-1.2456969, 0.96252, 1.0], + [-1.9524531, 0.9508849, 1.0], + [-0.2124356, 0.9941987, 1.0], + [-0.0013579516, 0.9999511, 1.0], + [0.0, 1.0, 1.0], + [-0.12160804, 0.98543537, 1.0], + [-0.27839974, 0.983876, 1.0], + [-0.14200354, 0.99454284, 1.0], + [-0.012517998, 0.9993907, 1.0], + [0.0, 1.0, 1.0], + [-0.08368426, 0.9217887, 1.0], + [-1.2399791, 0.8625204, 1.0], + [-1.6894629, 0.9284568, 1.0], + [-0.18025236, 0.99156326, 1.0], + [0.0, 1.0, 1.0], + [0.5968979, 0.94195247, 1.0], + [0.19960582, 0.85841644, 1.0], + [-0.6310517, 0.9365722, 1.0], + [-0.0635393, 0.99637383, 1.0], + [0.0, 1.0, 1.0], + [-1.7087493, 0.96301836, 1.0], + [-7.374809, 0.8499901, 1.0], + [-4.2648234, 0.88394064, 1.0], + [-0.40903544, 0.97660697, 1.0], + [0.0, 1.0, 1.0], + [-0.3982448, 0.97629553, 1.0], + [-3.0891116, 0.7622017, 1.0], + [-2.504011, 0.6110344, 1.0], + [-0.68706274, 0.7084606, 1.0], + [0.0, 1.0, 1.0], + [0.5045769, 0.84708446, 1.0], + [1.9575346, 0.64301115, 1.0], + [2.4821124, 0.6190746, 1.0], + [2.1545558, 0.7520362, 1.0], + [0.0, 1.0, 1.0], + [3.1298854, 0.77756673, 1.0], + [5.5874057, 0.67800367, 1.0], + [2.4975712, 0.8394186, 1.0], + [0.17680934, 0.98891705, 1.0], + [0.0, 1.0, 1.0], + [6.144584, 0.8652469, 1.0], + [4.7808557, 0.8791068, 1.0], + [0.3867177, 0.98648083, 1.0], + [0.0025304207, 0.999883, 1.0], + [0.0, 1.0, 1.0], + [4.867331, 0.8436379, 1.0], + [2.2105703, 0.93576556, 1.0], + [0.06521808, 0.99830455, 1.0], + [0.00012717527, 0.999997, 1.0], + [0.0, 1.0, 1.0], + [1.0654385, 0.91793096, 1.0], + [0.26658812, 0.9786744, 1.0], + [0.0042792116, 0.9996769, 1.0], + [ + 4.5931947e-06, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.074042, 0.9739217, 1.0], + [0.176678, 0.99581814, 1.0], + [0.0017133928, 0.9999589, 1.0], + [ + 1.0157613e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.01776, 0.9737646, 1.0], + [-0.09971152, 0.9972118, 1.0], + [-0.00057499425, 0.999982, 1.0], + [ + -1.9621856e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.4001637, 0.9907639, 1.0], + [-0.03467, 0.9991947, 1.0], + [-0.0001814811, 0.99999577, 1.0], + [ + -5.804475e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.042297646, 1.0002224, 1.0], + [0.0034461436, 1.0000255, 1.0], + [ + 2.1965232e-05, + 0.99999976, + 1.0 + ], + [ + 2.5862306e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1646268, 0.9987905, 1.0], + [0.010173208, 0.99992174, 1.0], + [ + 4.039547e-05, + 0.9999995, + 1.0 + ], + [ + 1.7013736e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.48721975, 0.9939957, 1.0], + [0.028629638, 0.9996232, 1.0], + [0.0001032999, 0.99999857, 1.0], + [ + 2.3236245e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.26688322, 0.99534684, 1.0], + [0.01570361, 0.9997175, 1.0], + [ + 5.7026642e-05, + 0.9999989, + 1.0 + ], + [ + 1.27192274e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.0029496485, 0.999937, 1.0], + [0.00017224046, 0.9999962, 1.0], + [ + 5.5217765e-07, + 1.0, + 1.0 + ], + [ + -2.9122849e-10, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.047425542, 0.99909365, 1.0], + [0.0026520186, 0.9999489, 1.0], + [ + 9.205303e-06, + 0.9999998, + 1.0 + ], + [ + 1.95425e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.56498164, 0.98940337, 1.0], + [0.03208864, 0.9993928, 1.0], + [0.00011148855, 0.99999785, 1.0], + [ + 2.4027702e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.26117882, 0.99926835, 1.0], + [0.014332054, 0.9999543, 1.0], + [ + 4.8232454e-05, + 0.9999998, + 1.0 + ], + [ + 8.686053e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.7915088, 1.0046446, 1.0], + [0.04312717, 1.0002536, 1.0], + [0.00014175105, 1.0000008, 1.0], + [ + 2.4375671e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.14177647, 0.99992174, 1.0], + [0.0075591076, 0.9999955, 1.0], + [ + 2.2167036e-05, + 0.99999994, + 1.0 + ], + [ + -8.405434e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.06938606, 0.9985813, 1.0], + [-0.0027954027, 0.9999414, 1.0], + [ + -2.462243e-05, + 0.9999999, + 1.0 + ], + [ + -1.0864502e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.23859352, 0.995206, 1.0], + [-0.010182198, 0.99980026, 1.0], + [-0.00044199303, 1.0000005, 1.0], + [ + -2.9140341e-05, + 1.0000001, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.04641313, 0.99916106, 1.0], + [-0.020731159, 0.9994961, 1.0], + [-0.0036757442, 0.99992925, 1.0], + [-0.0001021458, 0.9999989, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/X-T4.json b/assets/camera-profiles/X-T4.json new file mode 100644 index 00000000..92f8dd3e --- /dev/null +++ b/assets/camera-profiles/X-T4.json @@ -0,0 +1,1863 @@ +{ + "version": 1, + "model": "X-T4", + "files": 545, + "samples": 1951939, + "matrix": [ + [1.1956707020637634, -0.20567495430470473, -0.001118128888055736], + [-0.07689679823427831, 1.1536404662027415, -0.07257103371689055], + [0.012366539151109635, -0.8454877840052113, 1.8346862279899163] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [8.488724, 1.1072181, 1.0], + [6.027911, 1.1025184, 1.0], + [5.126879, 1.1056141, 1.0], + [2.7562134, 1.0608389, 1.0], + [0.0, 1.0, 1.0], + [6.9790807, 1.0982808, 1.0], + [4.307873, 1.0916511, 1.0], + [2.7828488, 1.0824455, 1.0], + [1.8936595, 1.0677147, 1.0], + [0.0, 1.0, 1.0], + [5.7833548, 1.0808423, 1.0], + [2.821706, 1.0722075, 1.0], + [1.2096823, 1.0580945, 1.0], + [0.3733568, 1.0406352, 1.0], + [0.0, 1.0, 1.0], + [4.533599, 1.0569755, 1.0], + [1.7481736, 1.0532024, 1.0], + [0.47299206, 1.0389953, 1.0], + [-0.8960845, 1.0057793, 1.0], + [0.0, 1.0, 1.0], + [3.2647514, 1.037265, 1.0], + [0.57080513, 1.0367802, 1.0], + [-0.491963, 1.0290552, 1.0], + [-1.6263541, 1.0026131, 1.0], + [0.0, 1.0, 1.0], + [2.1541393, 1.0179027, 1.0], + [-0.6506915, 1.019257, 1.0], + [-1.7829689, 1.0136136, 1.0], + [-3.3493202, 0.9885524, 1.0], + [0.0, 1.0, 1.0], + [1.7049778, 0.99872667, 1.0], + [-1.5122603, 0.9989183, 1.0], + [-2.9810934, 0.99024343, 1.0], + [-5.581267, 0.9519912, 1.0], + [0.0, 1.0, 1.0], + [1.690269, 0.9788303, 1.0], + [-1.8288996, 0.97644824, 1.0], + [-3.569127, 0.96698815, 1.0], + [-5.301545, 0.9436633, 1.0], + [0.0, 1.0, 1.0], + [1.675697, 0.9631066, 1.0], + [-1.8190717, 0.9620807, 1.0], + [-3.9894445, 0.94959897, 1.0], + [-5.4966874, 0.93077904, 1.0], + [0.0, 1.0, 1.0], + [2.015561, 0.9440323, 1.0], + [-1.5841109, 0.9433394, 1.0], + [-3.7623508, 0.9329007, 1.0], + [-4.8697934, 0.91531616, 1.0], + [0.0, 1.0, 1.0], + [1.7726275, 0.9228537, 1.0], + [-0.8683006, 0.90954083, 1.0], + [-3.5469055, 0.89416665, 1.0], + [-4.6822357, 0.8788996, 1.0], + [0.0, 1.0, 1.0], + [0.8594914, 0.9101041, 1.0], + [-0.8338466, 0.89342636, 1.0], + [-2.8553827, 0.8670687, 1.0], + [-3.9147246, 0.842152, 1.0], + [0.0, 1.0, 1.0], + [0.91838557, 0.9096098, 1.0], + [-0.22313553, 0.8916189, 1.0], + [-2.1062787, 0.84502786, 1.0], + [-4.6637325, 0.79085016, 1.0], + [0.0, 1.0, 1.0], + [1.7867746, 0.9109965, 1.0], + [0.5220267, 0.88554436, 1.0], + [-1.6875609, 0.8377822, 1.0], + [-2.5638952, 0.8012038, 1.0], + [0.0, 1.0, 1.0], + [1.608996, 0.9175911, 1.0], + [0.51752204, 0.87700754, 1.0], + [-0.87092155, 0.8299583, 1.0], + [-0.6205537, 0.8152296, 1.0], + [0.0, 1.0, 1.0], + [0.60483766, 0.92897344, 1.0], + [0.48028103, 0.86985594, 1.0], + [0.6325482, 0.83478713, 1.0], + [1.2977381, 0.8260493, 1.0], + [0.0, 1.0, 1.0], + [-1.3837581, 0.9366167, 1.0], + [-0.17185253, 0.8691589, 1.0], + [1.6557579, 0.8430431, 1.0], + [3.0675054, 0.8435281, 1.0], + [0.0, 1.0, 1.0], + [-2.8841403, 0.9447056, 1.0], + [-0.14756455, 0.88780934, 1.0], + [2.745795, 0.8501864, 1.0], + [4.020532, 0.83667177, 1.0], + [0.0, 1.0, 1.0], + [-3.4971426, 0.9572608, 1.0], + [0.27770972, 0.9148666, 1.0], + [3.7949884, 0.8749599, 1.0], + [5.156509, 0.8579785, 1.0], + [0.0, 1.0, 1.0], + [-4.1615067, 0.9798452, 1.0], + [0.7165952, 0.9488004, 1.0], + [3.0187547, 0.91653454, 1.0], + [2.6265273, 0.9383036, 1.0], + [0.0, 1.0, 1.0], + [-5.221659, 1.0131094, 1.0], + [0.13435012, 0.97644806, 1.0], + [1.240259, 0.96669924, 1.0], + [0.052574184, 0.99833775, 1.0], + [0.0, 1.0, 1.0], + [-6.439757, 1.0429982, 1.0], + [-0.75906193, 0.98914695, 1.0], + [0.6928731, 0.98318547, 1.0], + [0.014971263, 0.9996664, 1.0], + [0.0, 1.0, 1.0], + [-7.9659386, 1.0793759, 1.0], + [-1.7617505, 1.0095686, 1.0], + [0.22509712, 0.9961085, 1.0], + [0.004788822, 0.99993336, 1.0], + [0.0, 1.0, 1.0], + [-8.894038, 1.111571, 1.0], + [-2.515549, 1.037413, 1.0], + [-0.13141948, 1.0021963, 1.0], + [-0.00069948833, 1.000002, 1.0], + [0.0, 1.0, 1.0], + [-8.479235, 1.1109792, 1.0], + [-1.8521774, 1.0352653, 1.0], + [-0.016141478, 1.001208, 1.0], + [ + -4.3190834e-05, + 1.0000027, + 1.0 + ], + [0.0, 1.0, 1.0], + [-8.549912, 1.0822867, 1.0], + [-2.329735, 1.0172695, 1.0], + [-0.078001685, 0.9996079, 1.0], + [-0.0005751441, 0.9999892, 1.0], + [0.0, 1.0, 1.0], + [-10.067066, 1.0514193, 1.0], + [-2.9787543, 1.0056072, 1.0], + [-0.068143584, 0.999506, 1.0], + [-0.0004552759, 0.9999932, 1.0], + [0.0, 1.0, 1.0], + [-8.974208, 1.0131854, 1.0], + [-2.0702703, 0.9893971, 1.0], + [-0.0065514506, 0.9995635, 1.0], + [ + -1.1568422e-05, + 0.9999987, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.526247, 0.9756266, 1.0], + [-2.8014424, 0.9778582, 1.0], + [-0.027753195, 0.9994349, 1.0], + [ + -2.2518001e-05, + 0.99999917, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.626008, 0.9605885, 1.0], + [-3.5689998, 0.97556823, 1.0], + [-0.06463481, 0.9995251, 1.0], + [ + -8.669659e-05, + 0.99999934, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.666746, 0.9549443, 1.0], + [-3.2697423, 0.9675152, 1.0], + [-0.077041924, 0.99725926, 1.0], + [-0.0004004262, 0.99997205, 1.0], + [0.0, 1.0, 1.0], + [-10.91483, 0.93973595, 1.0], + [-4.0554028, 0.9567627, 1.0], + [-0.19969644, 0.994213, 1.0], + [-0.0015807059, 0.99994254, 1.0], + [0.0, 1.0, 1.0], + [-10.917845, 0.9374126, 1.0], + [-3.937062, 0.9639455, 1.0], + [-0.10804518, 0.99682367, 1.0], + [-0.0003749917, 0.9999778, 1.0], + [0.0, 1.0, 1.0], + [-10.878841, 0.9248424, 1.0], + [-4.854392, 0.9475052, 1.0], + [-0.3740493, 0.99154884, 1.0], + [-0.0027292988, 0.9999195, 1.0], + [0.0, 1.0, 1.0], + [-11.24207, 0.9229496, 1.0], + [-5.3360095, 0.955147, 1.0], + [-0.303692, 0.9946303, 1.0], + [-0.0016415697, 0.9999533, 1.0], + [0.0, 1.0, 1.0], + [-12.459016, 0.92287266, 1.0], + [-6.2727904, 0.9671372, 1.0], + [-0.26351833, 0.9981922, 1.0], + [-0.00071758503, 0.9999905, 1.0], + [0.0, 1.0, 1.0], + [-14.29214, 0.91273934, 1.0], + [-7.047706, 0.96466416, 1.0], + [-0.29217795, 0.9984951, 1.0], + [-0.0011865492, 0.9999872, 1.0], + [0.0, 1.0, 1.0], + [-15.4999695, 0.87166667, 1.0], + [-7.790251, 0.9259767, 1.0], + [-0.44737285, 0.9925323, 1.0], + [-0.0028230716, 0.9999344, 1.0], + [0.0, 1.0, 1.0], + [-16.484762, 0.81370276, 1.0], + [-7.1786904, 0.8921729, 1.0], + [-0.2108328, 0.9926036, 1.0], + [ + 8.717539e-05, + 0.9999606, + 1.0 + ], + [0.0, 1.0, 1.0], + [-16.285719, 0.7696234, 1.0], + [-8.000221, 0.86041737, 1.0], + [-0.25902525, 0.9928877, 1.0], + [ + 3.6861604e-06, + 0.99996454, + 1.0 + ], + [0.0, 1.0, 1.0], + [-14.937389, 0.7474696, 1.0], + [-8.215962, 0.8344298, 1.0], + [-0.29087847, 0.9932387, 1.0], + [-0.0004977129, 0.9999889, 1.0], + [0.0, 1.0, 1.0], + [-13.858105, 0.7273871, 1.0], + [-8.646881, 0.80850846, 1.0], + [-0.6027111, 0.98492134, 1.0], + [-0.0006273363, 0.9999705, 1.0], + [0.0, 1.0, 1.0], + [-12.701234, 0.724614, 1.0], + [-7.9289045, 0.8156208, 1.0], + [-0.8558639, 0.97617847, 1.0], + [0.0064291214, 0.9999933, 1.0], + [0.0, 1.0, 1.0], + [-11.146729, 0.7225693, 1.0], + [-6.5765376, 0.82539093, 1.0], + [-1.0245144, 0.96958196, 1.0], + [0.013567956, 1.0000076, 1.0], + [0.0, 1.0, 1.0], + [-8.884148, 0.7417882, 1.0], + [-4.3417435, 0.87207294, 1.0], + [-0.85739046, 0.98081857, 1.0], + [-0.0033849054, 0.999951, 1.0], + [0.0, 1.0, 1.0], + [-6.049297, 0.8116962, 1.0], + [-1.4851714, 0.9950118, 1.0], + [0.49591282, 1.0545783, 1.0], + [1.0066286, 1.0165282, 1.0], + [0.0, 1.0, 1.0], + [-4.6716075, 0.83988357, 1.0], + [-1.0457978, 0.99501514, 1.0], + [3.4836724, 1.0588785, 1.0], + [6.21208, 1.0532575, 1.0], + [0.0, 1.0, 1.0], + [-3.4979618, 0.86866015, 1.0], + [-1.4233817, 0.9817547, 1.0], + [1.8918269, 1.0534106, 1.0], + [4.6594706, 1.0482085, 1.0], + [0.0, 1.0, 1.0], + [-3.3181837, 0.8540786, 1.0], + [-2.0863492, 0.9573726, 1.0], + [-0.3537899, 1.0474013, 1.0], + [0.934481, 1.0493503, 1.0], + [0.0, 1.0, 1.0], + [-3.0748718, 0.7988069, 1.0], + [-1.966728, 0.8981509, 1.0], + [-0.28818163, 1.0324, 1.0], + [1.1813463, 1.0708457, 1.0], + [0.0, 1.0, 1.0], + [-2.8137631, 0.7544842, 1.0], + [-1.7292049, 0.86010194, 1.0], + [-0.7809513, 1.0063258, 1.0], + [-0.045840506, 1.0474083, 1.0], + [0.0, 1.0, 1.0], + [-2.5037665, 0.7084344, 1.0], + [-1.2390419, 0.79690814, 1.0], + [-0.40586868, 0.9500636, 1.0], + [-0.25943267, 1.0091549, 1.0], + [0.0, 1.0, 1.0], + [-2.5305736, 0.6992817, 1.0], + [-0.84799874, 0.7868287, 1.0], + [-0.26535538, 0.9460887, 1.0], + [-0.26857254, 1.0045226, 1.0], + [0.0, 1.0, 1.0], + [-1.7894933, 0.696515, 1.0], + [-0.087908834, 0.79940414, 1.0], + [-0.40865606, 0.9581447, 1.0], + [-0.26790136, 1.0027667, 1.0], + [0.0, 1.0, 1.0], + [1.0415573, 0.69235516, 1.0], + [2.7619398, 0.79386014, 1.0], + [0.7820542, 0.95513344, 1.0], + [-0.07779682, 1.000791, 1.0], + [0.0, 1.0, 1.0], + [4.733248, 0.70772463, 1.0], + [5.005565, 0.8130484, 1.0], + [1.6844413, 0.95230424, 1.0], + [0.0011346041, 0.999593, 1.0], + [0.0, 1.0, 1.0], + [7.1720886, 0.72739035, 1.0], + [6.0618978, 0.83798486, 1.0], + [1.9718703, 0.959339, 1.0], + [0.01772261, 0.99964523, 1.0], + [0.0, 1.0, 1.0], + [8.698657, 0.7538606, 1.0], + [6.9594636, 0.85568684, 1.0], + [2.449368, 0.96119237, 1.0], + [0.03495715, 0.99959546, 1.0], + [0.0, 1.0, 1.0], + [9.8315, 0.8065308, 1.0], + [7.1267076, 0.8956971, 1.0], + [2.3986976, 0.97244316, 1.0], + [0.03165836, 0.9996826, 1.0], + [0.0, 1.0, 1.0], + [10.795148, 0.85122234, 1.0], + [7.7294583, 0.93523824, 1.0], + [3.1217756, 0.9856404, 1.0], + [0.057580743, 0.9998344, 1.0], + [0.0, 1.0, 1.0], + [10.398136, 0.8843981, 1.0], + [7.9121175, 0.9568833, 1.0], + [3.1463122, 0.99417585, 1.0], + [0.05408725, 1.0000057, 1.0], + [0.0, 1.0, 1.0], + [10.596379, 0.89801466, 1.0], + [8.130247, 0.9633411, 1.0], + [3.3988564, 0.9945132, 1.0], + [0.05017191, 1.0000184, 1.0], + [0.0, 1.0, 1.0], + [10.625336, 0.9014762, 1.0], + [7.98765, 0.958355, 1.0], + [3.875602, 0.9900766, 1.0], + [0.07159707, 0.99994385, 1.0], + [0.0, 1.0, 1.0], + [10.804855, 0.9026359, 1.0], + [8.302903, 0.9591842, 1.0], + [4.20411, 0.9905614, 1.0], + [0.10111151, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [11.296136, 0.91712755, 1.0], + [8.392348, 0.9658389, 1.0], + [4.6424003, 0.9887021, 1.0], + [0.13342388, 0.9997721, 1.0], + [0.0, 1.0, 1.0], + [11.479186, 0.93038946, 1.0], + [8.325115, 0.96937376, 1.0], + [4.8091784, 0.9889414, 1.0], + [0.14431237, 0.99973685, 1.0], + [0.0, 1.0, 1.0], + [11.55654, 0.93699944, 1.0], + [8.343578, 0.97098297, 1.0], + [4.5094857, 0.9892612, 1.0], + [0.11552952, 0.9997806, 1.0], + [0.0, 1.0, 1.0], + [11.673957, 0.95636433, 1.0], + [9.015992, 0.9808939, 1.0], + [5.2865705, 0.99124783, 1.0], + [0.15847602, 0.99981487, 1.0], + [0.0, 1.0, 1.0], + [11.381441, 0.97744256, 1.0], + [9.125418, 0.99072415, 1.0], + [5.898516, 0.99096805, 1.0], + [0.22721227, 0.99941134, 1.0], + [0.0, 1.0, 1.0], + [11.39636, 0.99879706, 1.0], + [8.978779, 1.001649, 1.0], + [5.9936876, 0.99107313, 1.0], + [0.47186422, 0.99851125, 1.0], + [0.0, 1.0, 1.0], + [10.871172, 1.0297832, 1.0], + [8.347715, 1.0252823, 1.0], + [5.9642944, 1.0091741, 1.0], + [0.6254432, 1.0003536, 1.0], + [0.0, 1.0, 1.0], + [9.91249, 1.0697851, 1.0], + [7.514703, 1.0643711, 1.0], + [6.2715187, 1.0660433, 1.0], + [1.3713064, 1.0171726, 1.0], + [0.0, 1.0, 1.0], + [6.9206777, 1.0631331, 1.0], + [5.4058423, 1.0743114, 1.0], + [4.9153433, 1.0924674, 1.0], + [3.5594451, 1.0767422, 1.0], + [0.0, 1.0, 1.0], + [5.296089, 1.0543188, 1.0], + [3.7133682, 1.0690163, 1.0], + [2.6709373, 1.0747231, 1.0], + [1.8147451, 1.0627362, 1.0], + [0.0, 1.0, 1.0], + [4.072669, 1.0297099, 1.0], + [2.2282882, 1.0441604, 1.0], + [1.04859, 1.0460346, 1.0], + [0.09582316, 1.0271128, 1.0], + [0.0, 1.0, 1.0], + [3.131797, 0.99783087, 1.0], + [1.3904452, 1.0142667, 1.0], + [0.21789947, 1.0169302, 1.0], + [-1.3347801, 0.99124795, 1.0], + [0.0, 1.0, 1.0], + [2.1123412, 0.9821653, 1.0], + [0.47084513, 0.9980239, 1.0], + [-0.4589091, 1.0047126, 1.0], + [-2.6004968, 0.97831535, 1.0], + [0.0, 1.0, 1.0], + [1.2340026, 0.9715437, 1.0], + [-0.52651185, 0.9844928, 1.0], + [-1.4068123, 0.99132365, 1.0], + [-3.105275, 0.9731577, 1.0], + [0.0, 1.0, 1.0], + [0.8440855, 0.9556401, 1.0], + [-1.2418174, 0.96413547, 1.0], + [-2.314578, 0.97029686, 1.0], + [-4.555726, 0.9510563, 1.0], + [0.0, 1.0, 1.0], + [0.8005227, 0.9413437, 1.0], + [-1.6244266, 0.9424764, 1.0], + [-2.8907351, 0.94735694, 1.0], + [-4.531418, 0.938058, 1.0], + [0.0, 1.0, 1.0], + [0.84144706, 0.9381546, 1.0], + [-1.8843055, 0.9437712, 1.0], + [-3.7513957, 0.93906224, 1.0], + [-5.2546344, 0.9245488, 1.0], + [0.0, 1.0, 1.0], + [1.1232786, 0.91807526, 1.0], + [-1.6892345, 0.927733, 1.0], + [-3.5301387, 0.9231299, 1.0], + [-4.4934497, 0.90747577, 1.0], + [0.0, 1.0, 1.0], + [0.7078936, 0.88705426, 1.0], + [-1.1164439, 0.8847049, 1.0], + [-3.1222675, 0.881657, 1.0], + [-4.24306, 0.8661209, 1.0], + [0.0, 1.0, 1.0], + [-0.3021957, 0.866729, 1.0], + [-1.4405441, 0.8674825, 1.0], + [-2.6025193, 0.8471425, 1.0], + [-2.9510996, 0.78768075, 1.0], + [0.0, 1.0, 1.0], + [-0.6495463, 0.84840906, 1.0], + [-1.1817076, 0.8663429, 1.0], + [-2.0115397, 0.8132654, 1.0], + [-3.20892, 0.75081503, 1.0], + [0.0, 1.0, 1.0], + [-0.08545443, 0.8417304, 1.0], + [-0.41332915, 0.86041677, 1.0], + [-1.7788253, 0.8134252, 1.0], + [-2.212696, 0.76719844, 1.0], + [0.0, 1.0, 1.0], + [0.047263395, 0.8536788, 1.0], + [-0.28024718, 0.85070366, 1.0], + [-1.1217487, 0.80729675, 1.0], + [-0.9744078, 0.782097, 1.0], + [0.0, 1.0, 1.0], + [-0.5738716, 0.8675571, 1.0], + [-0.26259437, 0.8452582, 1.0], + [0.15979776, 0.8179859, 1.0], + [0.7517238, 0.8056368, 1.0], + [0.0, 1.0, 1.0], + [-2.153183, 0.8773336, 1.0], + [-0.59632957, 0.845526, 1.0], + [1.2569299, 0.83155745, 1.0], + [2.6460948, 0.83558846, 1.0], + [0.0, 1.0, 1.0], + [-3.344423, 0.89766663, 1.0], + [-0.4878973, 0.86092067, 1.0], + [2.0878763, 0.8382237, 1.0], + [3.5387704, 0.8289335, 1.0], + [0.0, 1.0, 1.0], + [-3.6364002, 0.91960084, 1.0], + [-0.20694885, 0.88383955, 1.0], + [2.819625, 0.86090726, 1.0], + [4.849336, 0.84782916, 1.0], + [0.0, 1.0, 1.0], + [-3.991286, 0.9524578, 1.0], + [0.28421873, 0.9292402, 1.0], + [2.098149, 0.8972288, 1.0], + [2.5357125, 0.92512876, 1.0], + [0.0, 1.0, 1.0], + [-4.56505, 0.9899462, 1.0], + [0.38352713, 0.9732783, 1.0], + [1.4336663, 0.9498769, 1.0], + [-2.1187909, 0.9426851, 1.0], + [0.0, 1.0, 1.0], + [-5.3135715, 1.0177003, 1.0], + [-0.23551735, 0.99192464, 1.0], + [1.1284361, 0.97364134, 1.0], + [-0.43181676, 0.97194505, 1.0], + [0.0, 1.0, 1.0], + [-6.6075764, 1.0397655, 1.0], + [-1.0467725, 1.0000273, 1.0], + [0.6370652, 0.9854795, 1.0], + [0.66872966, 0.9790045, 1.0], + [0.0, 1.0, 1.0], + [-7.4358993, 1.0649753, 1.0], + [-2.0699987, 1.0154411, 1.0], + [0.22289409, 0.9912232, 1.0], + [0.68752974, 0.9844199, 1.0], + [0.0, 1.0, 1.0], + [-7.9629416, 1.0769956, 1.0], + [-2.8259487, 1.0380164, 1.0], + [0.17933619, 0.9962173, 1.0], + [0.24746062, 0.9950691, 1.0], + [0.0, 1.0, 1.0], + [-8.186394, 1.0412176, 1.0], + [-3.2612817, 1.0117664, 1.0], + [0.16850522, 0.9932496, 1.0], + [0.2498481, 0.9976146, 1.0], + [0.0, 1.0, 1.0], + [-9.472168, 0.9964655, 1.0], + [-4.180181, 0.9870781, 1.0], + [-0.0750533, 0.9970658, 1.0], + [0.012545125, 0.9995864, 1.0], + [0.0, 1.0, 1.0], + [-8.411514, 0.9593027, 1.0], + [-2.9796257, 0.97248125, 1.0], + [-0.018407283, 0.9964052, 1.0], + [-0.04716424, 0.9984274, 1.0], + [0.0, 1.0, 1.0], + [-8.102845, 0.922432, 1.0], + [-3.318696, 0.95474064, 1.0], + [-0.020426346, 0.9982194, 1.0], + [-0.001956191, 0.9996863, 1.0], + [0.0, 1.0, 1.0], + [-8.526674, 0.89696586, 1.0], + [-4.260207, 0.9472845, 1.0], + [-0.11072428, 0.99930483, 1.0], + [-0.00018295995, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-8.737394, 0.89237434, 1.0], + [-3.8464966, 0.9399867, 1.0], + [-0.07978496, 0.99751663, 1.0], + [-0.00027686163, 0.9999805, 1.0], + [0.0, 1.0, 1.0], + [-9.886306, 0.8757939, 1.0], + [-4.6172514, 0.92852616, 1.0], + [-0.1890676, 0.9948679, 1.0], + [-0.001193005, 0.999959, 1.0], + [0.0, 1.0, 1.0], + [-10.259174, 0.8847142, 1.0], + [-5.02464, 0.9355583, 1.0], + [-0.14631894, 0.996452, 1.0], + [-0.00036141326, 0.9999813, 1.0], + [0.0, 1.0, 1.0], + [-9.869882, 0.8831311, 1.0], + [-5.362167, 0.93398, 1.0], + [-0.34822685, 0.9924211, 1.0], + [-0.0020351785, 0.9999336, 1.0], + [0.0, 1.0, 1.0], + [-10.093717, 0.8826345, 1.0], + [-5.5686555, 0.93492806, 1.0], + [-0.21373157, 0.9918344, 1.0], + [-0.0002989677, 0.99993354, 1.0], + [0.0, 1.0, 1.0], + [-11.619686, 0.8809789, 1.0], + [-7.5092616, 0.9418452, 1.0], + [-0.3233299, 0.9970841, 1.0], + [0.001180963, 0.9999634, 1.0], + [0.0, 1.0, 1.0], + [-13.424544, 0.8696788, 1.0], + [-8.506053, 0.93757474, 1.0], + [-0.2895198, 0.99745727, 1.0], + [0.004820106, 0.99991095, 1.0], + [0.0, 1.0, 1.0], + [-14.621148, 0.8231381, 1.0], + [-9.321265, 0.88769966, 1.0], + [-0.47915274, 0.99181235, 1.0], + [-0.0022949055, 0.99993384, 1.0], + [0.0, 1.0, 1.0], + [-15.68596, 0.7722066, 1.0], + [-8.95147, 0.8554953, 1.0], + [-0.21178812, 0.9913755, 1.0], + [0.0008263445, 0.9999525, 1.0], + [0.0, 1.0, 1.0], + [-15.074831, 0.728671, 1.0], + [-9.225928, 0.8146718, 1.0], + [-0.2110684, 0.9879508, 1.0], + [0.0014654138, 0.9999165, 1.0], + [0.0, 1.0, 1.0], + [-12.948474, 0.7042668, 1.0], + [-9.001779, 0.7873532, 1.0], + [-0.40309152, 0.9885863, 1.0], + [-0.00052242767, 0.9999782, 1.0], + [0.0, 1.0, 1.0], + [-10.635279, 0.68401897, 1.0], + [-7.905069, 0.7697101, 1.0], + [-0.7297403, 0.9731825, 1.0], + [-0.00023977055, 0.99993455, 1.0], + [0.0, 1.0, 1.0], + [-8.394399, 0.6700475, 1.0], + [-6.597605, 0.77691966, 1.0], + [-1.2440937, 0.95707667, 1.0], + [0.0048901793, 0.9999025, 1.0], + [0.0, 1.0, 1.0], + [-7.0069995, 0.65731025, 1.0], + [-5.66303, 0.7795728, 1.0], + [-1.7324567, 0.9417374, 1.0], + [-0.008971522, 0.9993332, 1.0], + [0.0, 1.0, 1.0], + [-5.887643, 0.6666744, 1.0], + [-3.5603712, 0.8430337, 1.0], + [-1.0138998, 0.9716629, 1.0], + [-0.04949627, 0.9985733, 1.0], + [0.0, 1.0, 1.0], + [-4.3985267, 0.7247905, 1.0], + [-1.2181568, 0.9698552, 1.0], + [0.889513, 1.0484955, 1.0], + [3.5816274, 1.0411543, 1.0], + [0.0, 1.0, 1.0], + [-3.7034328, 0.7695667, 1.0], + [-0.7939945, 0.97303194, 1.0], + [4.931476, 1.0582556, 1.0], + [7.859906, 1.0638645, 1.0], + [0.0, 1.0, 1.0], + [-3.0874662, 0.82422346, 1.0], + [-1.2831166, 0.97711766, 1.0], + [2.3523912, 1.0499644, 1.0], + [6.217497, 1.0628635, 1.0], + [0.0, 1.0, 1.0], + [-2.7996006, 0.83586675, 1.0], + [-1.5887228, 0.9804846, 1.0], + [-0.12895624, 1.0526341, 1.0], + [1.8886707, 1.0742506, 1.0], + [0.0, 1.0, 1.0], + [-2.4595652, 0.7937858, 1.0], + [-1.5304415, 0.9256194, 1.0], + [0.49791723, 1.0557373, 1.0], + [1.8103733, 1.0766511, 1.0], + [0.0, 1.0, 1.0], + [-1.8303367, 0.74952114, 1.0], + [-1.2938573, 0.8971074, 1.0], + [-0.48455238, 1.0300556, 1.0], + [0.37591517, 1.0647826, 1.0], + [0.0, 1.0, 1.0], + [-0.7799921, 0.69642293, 1.0], + [-0.50904167, 0.8235331, 1.0], + [-0.7142233, 0.98446524, 1.0], + [-0.95801145, 1.0281906, 1.0], + [0.0, 1.0, 1.0], + [-0.12392041, 0.689838, 1.0], + [0.14127903, 0.8031821, 1.0], + [-0.71944845, 0.98211265, 1.0], + [-1.0947827, 1.0201542, 1.0], + [0.0, 1.0, 1.0], + [0.6446894, 0.6899457, 1.0], + [0.84962654, 0.8166399, 1.0], + [-0.5199237, 0.9733036, 1.0], + [-0.75620574, 1.0087903, 1.0], + [0.0, 1.0, 1.0], + [3.0502963, 0.68614894, 1.0], + [3.0366545, 0.81591415, 1.0], + [0.6730178, 0.9554072, 1.0], + [-0.007607221, 0.9978117, 1.0], + [0.0, 1.0, 1.0], + [6.0329676, 0.6941915, 1.0], + [5.0321407, 0.81927717, 1.0], + [1.6395164, 0.94279826, 1.0], + [-0.03492225, 0.9970755, 1.0], + [0.0, 1.0, 1.0], + [7.5941086, 0.71911955, 1.0], + [5.390384, 0.8432774, 1.0], + [1.8999803, 0.94711256, 1.0], + [0.0023088262, 0.9986426, 1.0], + [0.0, 1.0, 1.0], + [8.8637085, 0.74631906, 1.0], + [6.2989063, 0.85505736, 1.0], + [2.735379, 0.94543207, 1.0], + [0.06463228, 0.9988073, 1.0], + [0.0, 1.0, 1.0], + [9.588615, 0.7898098, 1.0], + [6.376941, 0.8845687, 1.0], + [2.3718834, 0.9601361, 1.0], + [0.013906611, 0.9995857, 1.0], + [0.0, 1.0, 1.0], + [10.782804, 0.83546484, 1.0], + [7.3467307, 0.92574006, 1.0], + [3.498415, 0.9810678, 1.0], + [0.10678408, 0.99972445, 1.0], + [0.0, 1.0, 1.0], + [10.249238, 0.86850566, 1.0], + [7.6262197, 0.94297904, 1.0], + [4.036777, 0.9895052, 1.0], + [0.16037866, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [9.931091, 0.8846399, 1.0], + [7.519002, 0.94786286, 1.0], + [4.074718, 0.9868774, 1.0], + [0.13248277, 1.0000677, 1.0], + [0.0, 1.0, 1.0], + [9.689591, 0.8865812, 1.0], + [7.432243, 0.9456806, 1.0], + [4.7070894, 0.983843, 1.0], + [0.18639432, 0.9999364, 1.0], + [0.0, 1.0, 1.0], + [10.043292, 0.8922917, 1.0], + [7.8223534, 0.9484012, 1.0], + [5.358868, 0.9835407, 1.0], + [0.31019974, 1.0000116, 1.0], + [0.0, 1.0, 1.0], + [10.045664, 0.9007508, 1.0], + [7.642868, 0.95041484, 1.0], + [5.4579797, 0.9804828, 1.0], + [0.39148968, 0.99986535, 1.0], + [0.0, 1.0, 1.0], + [9.769167, 0.9075519, 1.0], + [7.2386246, 0.95056725, 1.0], + [5.2662854, 0.976752, 1.0], + [0.4462296, 0.9994238, 1.0], + [0.0, 1.0, 1.0], + [9.814191, 0.9142886, 1.0], + [7.2126784, 0.94775087, 1.0], + [5.001264, 0.9686987, 1.0], + [0.38073996, 0.9989886, 1.0], + [0.0, 1.0, 1.0], + [10.10585, 0.9291066, 1.0], + [7.8851285, 0.9544348, 1.0], + [6.079623, 0.96844214, 1.0], + [0.6109284, 0.9989012, 1.0], + [0.0, 1.0, 1.0], + [9.871696, 0.94608027, 1.0], + [7.800825, 0.96270525, 1.0], + [6.1262703, 0.9673819, 1.0], + [0.84064543, 0.997365, 1.0], + [0.0, 1.0, 1.0], + [9.842098, 0.9636502, 1.0], + [7.603165, 0.9709367, 1.0], + [5.927902, 0.9726488, 1.0], + [2.3444896, 0.9899, 1.0], + [0.0, 1.0, 1.0], + [9.367189, 0.9913651, 1.0], + [7.1136265, 0.9881401, 1.0], + [5.5312037, 0.9773815, 1.0], + [2.3738406, 0.9883841, 1.0], + [0.0, 1.0, 1.0], + [8.445051, 1.028042, 1.0], + [6.4724793, 1.0249939, 1.0], + [5.7201777, 1.02951, 1.0], + [2.6472864, 1.022244, 1.0], + [0.0, 1.0, 1.0], + [4.8764477, 0.92064744, 1.0], + [4.063755, 0.9517052, 1.0], + [3.4358697, 0.9500948, 1.0], + [1.6742893, 0.9403114, 1.0], + [0.0, 1.0, 1.0], + [3.8074338, 0.9376684, 1.0], + [3.1544921, 0.99583703, 1.0], + [2.8396516, 1.019307, 1.0], + [2.761816, 0.9831139, 1.0], + [0.0, 1.0, 1.0], + [2.9735732, 0.9055372, 1.0], + [1.964001, 0.96994215, 1.0], + [1.1794817, 1.0004675, 1.0], + [0.23656735, 0.9900725, 1.0], + [0.0, 1.0, 1.0], + [2.4316142, 0.85408896, 1.0], + [1.4779971, 0.9194293, 1.0], + [0.36998236, 0.96382165, 1.0], + [-0.78495336, 0.9628938, 1.0], + [0.0, 1.0, 1.0], + [1.7202682, 0.85409456, 1.0], + [0.85657984, 0.90512234, 1.0], + [-0.034063853, 0.94638556, 1.0], + [-2.4795413, 0.9543113, 1.0], + [0.0, 1.0, 1.0], + [1.1699636, 0.85612476, 1.0], + [0.2226863, 0.8977348, 1.0], + [-0.55164605, 0.9310963, 1.0], + [-2.9155931, 0.9300699, 1.0], + [0.0, 1.0, 1.0], + [0.9195899, 0.8302417, 1.0], + [-0.01082096, 0.86319506, 1.0], + [-0.8897206, 0.90112203, 1.0], + [-2.8909357, 0.91573393, 1.0], + [0.0, 1.0, 1.0], + [0.70681113, 0.81225044, 1.0], + [-0.2829643, 0.83714634, 1.0], + [-1.2980901, 0.8815819, 1.0], + [-2.9096456, 0.90339696, 1.0], + [0.0, 1.0, 1.0], + [0.45244727, 0.8261228, 1.0], + [-0.86100143, 0.8436378, 1.0], + [-2.2569923, 0.88340265, 1.0], + [-3.456668, 0.8849395, 1.0], + [0.0, 1.0, 1.0], + [0.58151424, 0.81086206, 1.0], + [-1.0969541, 0.8453545, 1.0], + [-2.3675127, 0.877735, 1.0], + [-3.063623, 0.8733827, 1.0], + [0.0, 1.0, 1.0], + [0.1315448, 0.76973885, 1.0], + [-0.7705917, 0.7837816, 1.0], + [-1.8659682, 0.8216326, 1.0], + [-2.8834462, 0.8402058, 1.0], + [0.0, 1.0, 1.0], + [-0.9416674, 0.7494247, 1.0], + [-1.3754656, 0.76372516, 1.0], + [-1.4642401, 0.7619152, 1.0], + [-1.5173858, 0.72746414, 1.0], + [0.0, 1.0, 1.0], + [-1.9809289, 0.73669344, 1.0], + [-1.8224959, 0.76059735, 1.0], + [-1.6169965, 0.69803905, 1.0], + [-3.6714156, 0.63129187, 1.0], + [0.0, 1.0, 1.0], + [-2.4930844, 0.7037204, 1.0], + [-1.7829679, 0.7479987, 1.0], + [-1.4971536, 0.70428216, 1.0], + [-3.4572604, 0.5994394, 1.0], + [0.0, 1.0, 1.0], + [-3.0422633, 0.6668609, 1.0], + [-2.1922023, 0.74247324, 1.0], + [-1.6100665, 0.78744763, 1.0], + [-1.2063087, 0.78941995, 1.0], + [0.0, 1.0, 1.0], + [-3.2440093, 0.6231938, 1.0], + [-2.0041919, 0.73567647, 1.0], + [-0.86709636, 0.8052706, 1.0], + [-0.4469063, 0.8399889, 1.0], + [0.0, 1.0, 1.0], + [-4.5079556, 0.6042883, 1.0], + [-2.3472598, 0.7395057, 1.0], + [-0.054738186, 0.82054764, 1.0], + [0.41392553, 0.85697615, 1.0], + [0.0, 1.0, 1.0], + [-6.4233527, 0.63508, 1.0], + [-1.9719031, 0.75168645, 1.0], + [0.4196573, 0.8112182, 1.0], + [1.0182172, 0.85615927, 1.0], + [0.0, 1.0, 1.0], + [-5.9163876, 0.7051664, 1.0], + [-1.4782107, 0.7433952, 1.0], + [-0.40683872, 0.8031918, 1.0], + [-0.4788465, 0.8863584, 1.0], + [0.0, 1.0, 1.0], + [-3.908668, 0.785487, 1.0], + [-0.9831386, 0.79933167, 1.0], + [-4.1048765, 0.81978315, 1.0], + [-11.719547, 0.7330626, 1.0], + [0.0, 1.0, 1.0], + [-3.066866, 0.8465281, 1.0], + [-1.0374848, 0.8885559, 1.0], + [-13.052467, 0.68214613, 1.0], + [-21.323591, 0.57109445, 1.0], + [0.0, 1.0, 1.0], + [-2.7582388, 0.8883443, 1.0], + [0.5189229, 0.91708815, 1.0], + [-0.7883722, 0.7302985, 1.0], + [-6.2000527, 0.6300695, 1.0], + [0.0, 1.0, 1.0], + [-2.8354056, 0.9174532, 1.0], + [2.34739, 0.910805, 1.0], + [9.42942, 0.74594164, 1.0], + [11.120875, 0.67275065, 1.0], + [0.0, 1.0, 1.0], + [-2.2180405, 0.92904836, 1.0], + [1.5843687, 0.9051751, 1.0], + [12.040191, 0.7458105, 1.0], + [14.777283, 0.7015992, 1.0], + [0.0, 1.0, 1.0], + [-2.2773137, 0.935868, 1.0], + [1.4081348, 0.9290241, 1.0], + [9.324613, 0.8047278, 1.0], + [8.094886, 0.833392, 1.0], + [0.0, 1.0, 1.0], + [-2.0836408, 0.9049924, 1.0], + [5.6842046, 0.8789433, 1.0], + [10.288447, 0.81913996, 1.0], + [6.26537, 0.92870164, 1.0], + [0.0, 1.0, 1.0], + [-3.6201372, 0.9030173, 1.0], + [2.3488736, 0.92669946, 1.0], + [3.109163, 0.9226741, 1.0], + [0.50225174, 0.9823943, 1.0], + [0.0, 1.0, 1.0], + [-3.0695627, 0.8845679, 1.0], + [1.9500862, 0.95027864, 1.0], + [1.1459787, 0.8927759, 1.0], + [-1.3981293, 0.94081604, 1.0], + [0.0, 1.0, 1.0], + [-2.2040372, 0.8723052, 1.0], + [1.018672, 0.96575177, 1.0], + [1.4323145, 0.95249796, 1.0], + [-0.18759486, 0.9809426, 1.0], + [0.0, 1.0, 1.0], + [-2.4124901, 0.850615, 1.0], + [-0.05903271, 0.97407734, 1.0], + [0.12506364, 0.9955236, 1.0], + [-0.016985215, 0.9992087, 1.0], + [0.0, 1.0, 1.0], + [-3.8181849, 0.844787, 1.0], + [-0.22574311, 0.9738625, 1.0], + [0.1223641, 0.9969789, 1.0], + [0.0039951545, 0.99990684, 1.0], + [0.0, 1.0, 1.0], + [-3.4693308, 0.8397684, 1.0], + [-0.39281702, 0.9756446, 1.0], + [0.02598722, 0.999133, 1.0], + [0.0010384705, 0.999975, 1.0], + [0.0, 1.0, 1.0], + [-2.6781962, 0.85489655, 1.0], + [-0.38995895, 0.9785057, 1.0], + [-0.0037281574, 0.999837, 1.0], + [ + 1.6256523e-06, + 0.9999995, + 1.0 + ], + [0.0, 1.0, 1.0], + [-3.335379, 0.87124074, 1.0], + [-0.48180598, 0.9825704, 1.0], + [-0.005992216, 0.99981517, 1.0], + [ + -1.742749e-05, + 0.9999991, + 1.0 + ], + [0.0, 1.0, 1.0], + [-4.184813, 0.85200375, 1.0], + [-0.6187777, 0.9787277, 1.0], + [0.0022274007, 0.9996338, 1.0], + [0.00046732937, 0.9999927, 1.0], + [0.0, 1.0, 1.0], + [-5.3020663, 0.83856326, 1.0], + [-0.4638571, 0.97142124, 1.0], + [0.7337303, 0.990346, 1.0], + [0.0751591, 0.9990356, 1.0], + [0.0, 1.0, 1.0], + [-5.3410516, 0.80858886, 1.0], + [0.40069363, 0.95397055, 1.0], + [2.218948, 0.9716582, 1.0], + [0.2358686, 0.99697757, 1.0], + [0.0, 1.0, 1.0], + [-6.416645, 0.7545048, 1.0], + [-1.2956624, 0.939975, 1.0], + [0.12502326, 0.9976219, 1.0], + [0.013717032, 0.9998224, 1.0], + [0.0, 1.0, 1.0], + [-8.273487, 0.72121274, 1.0], + [-1.9356983, 0.91340506, 1.0], + [0.12353294, 0.9965626, 1.0], + [0.004541849, 0.99992204, 1.0], + [0.0, 1.0, 1.0], + [-8.028335, 0.684168, 1.0], + [-1.6612952, 0.8862172, 1.0], + [0.61427313, 0.9880001, 1.0], + [0.020742713, 0.99964684, 1.0], + [0.0, 1.0, 1.0], + [-5.686304, 0.6284216, 1.0], + [-2.6316142, 0.840866, 1.0], + [0.030721515, 0.9969365, 1.0], + [0.0030909944, 0.9999094, 1.0], + [0.0, 1.0, 1.0], + [-3.3644114, 0.5920495, 1.0], + [-2.1064131, 0.7581428, 1.0], + [0.08250783, 0.9909103, 1.0], + [0.004092879, 0.99986243, 1.0], + [0.0, 1.0, 1.0], + [-1.6098745, 0.564251, 1.0], + [-3.0388198, 0.6718815, 1.0], + [-0.6501952, 0.9627276, 1.0], + [-0.006046299, 0.9997709, 1.0], + [0.0, 1.0, 1.0], + [-2.3306668, 0.5407966, 1.0], + [-4.1029615, 0.6064727, 1.0], + [-2.8782537, 0.8844327, 1.0], + [-0.073128805, 0.9976695, 1.0], + [0.0, 1.0, 1.0], + [-3.6157587, 0.5336421, 1.0], + [-4.0551357, 0.6346509, 1.0], + [-4.1803465, 0.81513524, 1.0], + [-0.43286413, 0.9813739, 1.0], + [0.0, 1.0, 1.0], + [-3.80694, 0.55238813, 1.0], + [-2.9400527, 0.75945127, 1.0], + [-1.957085, 0.91473925, 1.0], + [0.23186429, 0.98100156, 1.0], + [0.0, 1.0, 1.0], + [-3.2804413, 0.5958404, 1.0], + [-2.4060252, 0.811464, 1.0], + [2.1061044, 0.98812366, 1.0], + [5.310169, 1.0376091, 1.0], + [0.0, 1.0, 1.0], + [-2.648095, 0.6832738, 1.0], + [-2.096382, 0.89832383, 1.0], + [-0.57513076, 0.9792816, 1.0], + [1.4691317, 1.0191329, 1.0], + [0.0, 1.0, 1.0], + [-2.191342, 0.7055293, 1.0], + [-1.838788, 0.9088253, 1.0], + [-0.9401996, 0.9815046, 1.0], + [1.2051747, 1.0288774, 1.0], + [0.0, 1.0, 1.0], + [-1.2887207, 0.6609743, 1.0], + [-1.1732396, 0.80482507, 1.0], + [0.4986351, 0.99515104, 1.0], + [1.5360615, 1.0385249, 1.0], + [0.0, 1.0, 1.0], + [0.40449002, 0.6293669, 1.0], + [-0.38308343, 0.7883763, 1.0], + [-0.03740224, 0.9558314, 1.0], + [0.6230026, 1.0127206, 1.0], + [0.0, 1.0, 1.0], + [1.6413921, 0.6115583, 1.0], + [1.5963948, 0.72960037, 1.0], + [0.6866751, 0.8849578, 1.0], + [0.2589341, 0.9375699, 1.0], + [0.0, 1.0, 1.0], + [2.4193525, 0.6249198, 1.0], + [2.7416816, 0.7095477, 1.0], + [1.3268055, 0.84555423, 1.0], + [0.3328897, 0.9368017, 1.0], + [0.0, 1.0, 1.0], + [3.1844013, 0.6277898, 1.0], + [2.8372567, 0.71899444, 1.0], + [1.2267267, 0.85043055, 1.0], + [0.04637693, 0.97700346, 1.0], + [0.0, 1.0, 1.0], + [4.5027733, 0.6327071, 1.0], + [3.217072, 0.7416387, 1.0], + [1.577103, 0.8646953, 1.0], + [0.19375536, 0.9870552, 1.0], + [0.0, 1.0, 1.0], + [6.149598, 0.64911604, 1.0], + [4.1019955, 0.74544936, 1.0], + [1.431085, 0.8806098, 1.0], + [0.047591608, 0.993709, 1.0], + [0.0, 1.0, 1.0], + [7.1056666, 0.67726934, 1.0], + [4.2305317, 0.78717184, 1.0], + [1.067521, 0.92705023, 1.0], + [0.015493356, 0.997994, 1.0], + [0.0, 1.0, 1.0], + [7.474384, 0.7088058, 1.0], + [3.5543373, 0.80445033, 1.0], + [0.19845833, 0.95335525, 1.0], + [-0.025593635, 0.999446, 1.0], + [0.0, 1.0, 1.0], + [7.1915207, 0.7416353, 1.0], + [3.4266174, 0.83173305, 1.0], + [-0.5223778, 0.9669094, 1.0], + [-0.15612096, 0.99790776, 1.0], + [0.0, 1.0, 1.0], + [7.5696697, 0.77016944, 1.0], + [3.989671, 0.85700655, 1.0], + [-1.2651324, 0.954754, 1.0], + [-0.73263675, 0.9871268, 1.0], + [0.0, 1.0, 1.0], + [6.1636243, 0.7912714, 1.0], + [3.4775562, 0.8605731, 1.0], + [0.2865357, 0.98138404, 1.0], + [-0.07876425, 0.9983868, 1.0], + [0.0, 1.0, 1.0], + [6.3284674, 0.805469, 1.0], + [3.75536, 0.8683642, 1.0], + [0.60794705, 0.98088616, 1.0], + [0.008510712, 0.9998971, 1.0], + [0.0, 1.0, 1.0], + [7.4278216, 0.8018612, 1.0], + [4.286087, 0.863598, 1.0], + [0.393694, 0.9731425, 1.0], + [-0.026216365, 0.999259, 1.0], + [0.0, 1.0, 1.0], + [7.8491583, 0.8168845, 1.0], + [4.428734, 0.8584228, 1.0], + [-0.6927958, 0.9467803, 1.0], + [-0.17571971, 0.9967572, 1.0], + [0.0, 1.0, 1.0], + [6.8759727, 0.80086964, 1.0], + [4.1140614, 0.8347237, 1.0], + [1.008583, 0.9550637, 1.0], + [0.01853103, 0.99938357, 1.0], + [0.0, 1.0, 1.0], + [6.002123, 0.7990564, 1.0], + [3.352922, 0.81814086, 1.0], + [1.1990601, 0.94414854, 1.0], + [0.047310542, 0.9994817, 1.0], + [0.0, 1.0, 1.0], + [5.735746, 0.79181194, 1.0], + [2.7306662, 0.7838909, 1.0], + [0.5309012, 0.8979575, 1.0], + [-0.041853912, 0.99701124, 1.0], + [0.0, 1.0, 1.0], + [6.215762, 0.7899453, 1.0], + [3.104759, 0.7658282, 1.0], + [1.124937, 0.8786092, 1.0], + [0.021710817, 0.997453, 1.0], + [0.0, 1.0, 1.0], + [6.1507196, 0.79735833, 1.0], + [3.023819, 0.7685839, 1.0], + [1.5032152, 0.8632985, 1.0], + [0.11779032, 0.9967827, 1.0], + [0.0, 1.0, 1.0], + [6.1006694, 0.80814075, 1.0], + [3.1378238, 0.78737235, 1.0], + [1.8207941, 0.86908215, 1.0], + [-1.1852233, 0.9505832, 1.0], + [0.0, 1.0, 1.0], + [5.903391, 0.8252152, 1.0], + [2.9287477, 0.79821754, 1.0], + [0.5213138, 0.83226186, 1.0], + [-3.60744, 0.8885766, 1.0], + [0.0, 1.0, 1.0], + [5.579378, 0.86621565, 1.0], + [3.267353, 0.85239756, 1.0], + [1.4851551, 0.86720455, 1.0], + [-0.043647174, 0.96538955, 1.0], + [0.0, 1.0, 1.0], + [2.251857, 0.8048092, 1.0], + [3.868587, 0.73451906, 1.0], + [7.095376, 0.7049663, 1.0], + [4.2607813, 0.8634737, 1.0], + [0.0, 1.0, 1.0], + [3.7544513, 0.81845486, 1.0], + [6.617525, 0.80338174, 1.0], + [12.432594, 0.7366481, 1.0], + [8.489516, 0.81540203, 1.0], + [0.0, 1.0, 1.0], + [2.860258, 0.80569935, 1.0], + [4.0649204, 0.84190506, 1.0], + [8.932321, 0.78642964, 1.0], + [4.9175606, 0.85279024, 1.0], + [0.0, 1.0, 1.0], + [2.7026193, 0.7538319, 1.0], + [2.5008564, 0.812968, 1.0], + [3.5890124, 0.8474743, 1.0], + [2.7400997, 0.859288, 1.0], + [0.0, 1.0, 1.0], + [2.3664002, 0.7394628, 1.0], + [1.7941258, 0.78985816, 1.0], + [1.1166536, 0.8809234, 1.0], + [0.11488261, 0.9726579, 1.0], + [0.0, 1.0, 1.0], + [2.4183512, 0.7125459, 1.0], + [1.8356653, 0.76030463, 1.0], + [1.5251784, 0.8337058, 1.0], + [-0.23323414, 0.96463525, 1.0], + [0.0, 1.0, 1.0], + [2.1229203, 0.6823615, 1.0], + [1.7064534, 0.7226445, 1.0], + [1.2662644, 0.78341854, 1.0], + [-0.34037447, 0.921522, 1.0], + [0.0, 1.0, 1.0], + [1.922368, 0.670744, 1.0], + [1.3548895, 0.71063024, 1.0], + [0.73747265, 0.767286, 1.0], + [-0.32575443, 0.8069876, 1.0], + [0.0, 1.0, 1.0], + [2.468624, 0.6560331, 1.0], + [1.1733623, 0.7006847, 1.0], + [0.91913277, 0.77642107, 1.0], + [1.3170806, 0.78835416, 1.0], + [0.0, 1.0, 1.0], + [2.7764182, 0.62865984, 1.0], + [1.8569264, 0.67932403, 1.0], + [0.09585123, 0.8226712, 1.0], + [0.114856094, 0.89764315, 1.0], + [0.0, 1.0, 1.0], + [1.6984649, 0.5723032, 1.0], + [3.1778703, 0.5982749, 1.0], + [1.6580566, 0.7152358, 1.0], + [0.38139626, 0.8871746, 1.0], + [0.0, 1.0, 1.0], + [0.49222204, 0.556624, 1.0], + [2.705092, 0.5625561, 1.0], + [-0.2695512, 0.56588423, 1.0], + [-2.8712876, 0.6261747, 1.0], + [0.0, 1.0, 1.0], + [0.27390626, 0.6263597, 1.0], + [4.712818, 0.58389944, 1.0], + [-1.3854016, 0.5315325, 1.0], + [-6.950099, 0.5193624, 1.0], + [0.0, 1.0, 1.0], + [-0.50358576, 0.6688169, 1.0], + [4.557341, 0.7064863, 1.0], + [-0.214069, 0.5653314, 1.0], + [-4.7740607, 0.5246774, 1.0], + [0.0, 1.0, 1.0], + [-2.4929795, 0.6411706, 1.0], + [-1.6451489, 0.8176667, 1.0], + [0.34974566, 0.8819101, 1.0], + [-0.32462633, 0.8140249, 1.0], + [0.0, 1.0, 1.0], + [-2.7058835, 0.59305847, 1.0], + [-1.4331628, 0.818264, 1.0], + [-1.1053259, 0.91194034, 1.0], + [-3.1190734, 0.90261114, 1.0], + [0.0, 1.0, 1.0], + [-4.1035285, 0.5795477, 1.0], + [-2.1045144, 0.7871938, 1.0], + [-6.331526, 0.7170312, 1.0], + [-10.788408, 0.664596, 1.0], + [0.0, 1.0, 1.0], + [-5.7934265, 0.6102454, 1.0], + [-3.6331925, 0.7699751, 1.0], + [-3.415193, 0.68833, 1.0], + [-3.7475498, 0.64053464, 1.0], + [0.0, 1.0, 1.0], + [-5.1393547, 0.6529689, 1.0], + [-5.753634, 0.708333, 1.0], + [-5.8755636, 0.6708419, 1.0], + [-3.0788336, 0.67883736, 1.0], + [0.0, 1.0, 1.0], + [-4.6082006, 0.7062166, 1.0], + [-6.588885, 0.72459567, 1.0], + [-7.484304, 0.6874229, 1.0], + [-13.227061, 0.65968275, 1.0], + [0.0, 1.0, 1.0], + [-3.7472286, 0.78321165, 1.0], + [-7.7237554, 0.7579945, 1.0], + [-14.800508, 0.60856223, 1.0], + [-19.890417, 0.54714346, 1.0], + [0.0, 1.0, 1.0], + [-1.57658, 0.81595886, 1.0], + [4.2668858, 0.7361184, 1.0], + [1.7692925, 0.6172541, 1.0], + [-2.8298097, 0.5611174, 1.0], + [0.0, 1.0, 1.0], + [1.9543127, 0.84595275, 1.0], + [9.600064, 0.7089187, 1.0], + [13.987817, 0.61992806, 1.0], + [14.091181, 0.5941112, 1.0], + [0.0, 1.0, 1.0], + [0.4278858, 0.898544, 1.0], + [3.9224489, 0.8269029, 1.0], + [14.653015, 0.6571721, 1.0], + [15.524843, 0.62407255, 1.0], + [0.0, 1.0, 1.0], + [0.7066278, 0.91855973, 1.0], + [4.9186754, 0.8462665, 1.0], + [14.977205, 0.6851345, 1.0], + [11.15482, 0.7066212, 1.0], + [0.0, 1.0, 1.0], + [1.253572, 0.9393247, 1.0], + [5.97083, 0.88211775, 1.0], + [9.293864, 0.8153296, 1.0], + [3.8026578, 0.90733314, 1.0], + [0.0, 1.0, 1.0], + [0.71835244, 0.9160959, 1.0], + [2.0100238, 0.91115075, 1.0], + [3.6926022, 0.90838015, 1.0], + [0.7498695, 0.97529143, 1.0], + [0.0, 1.0, 1.0], + [0.24475421, 0.8969049, 1.0], + [1.8341327, 0.92656446, 1.0], + [3.3835375, 0.8649656, 1.0], + [0.05131399, 0.9339182, 1.0], + [0.0, 1.0, 1.0], + [-0.3472424, 0.9435528, 1.0], + [1.3106259, 0.955991, 1.0], + [-1.6902066, 0.89264774, 1.0], + [-2.4834325, 0.9318436, 1.0], + [0.0, 1.0, 1.0], + [0.1349813, 0.96349823, 1.0], + [3.2832685, 0.92533123, 1.0], + [1.5165656, 0.94987315, 1.0], + [-0.21866378, 0.9915875, 1.0], + [0.0, 1.0, 1.0], + [-0.1588628, 0.96242386, 1.0], + [4.3031096, 0.8986765, 1.0], + [2.426284, 0.9446792, 1.0], + [0.0851209, 0.998018, 1.0], + [0.0, 1.0, 1.0], + [-0.21821694, 0.97361916, 1.0], + [1.0029128, 0.96765107, 1.0], + [0.6752989, 0.98369634, 1.0], + [0.024960788, 0.9994141, 1.0], + [0.0, 1.0, 1.0], + [1.142324, 0.9429116, 1.0], + [-0.08796144, 0.9916399, 1.0], + [-0.021721277, 0.99964666, 1.0], + [ + 5.6598337e-06, + 0.9999943, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.42995253, 0.95589715, 1.0], + [-0.1302341, 0.99092823, 1.0], + [-0.014143473, 0.99967444, 1.0], + [ + -6.91973e-05, + 0.9999987, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.6089942, 0.95499057, 1.0], + [-0.2533017, 0.9897083, 1.0], + [-0.01806424, 0.9994577, 1.0], + [0.00011731918, 0.9999946, 1.0], + [0.0, 1.0, 1.0], + [-0.41291016, 0.9776058, 1.0], + [0.20236667, 0.99470246, 1.0], + [0.38761485, 0.9949851, 1.0], + [0.038843628, 0.99950147, 1.0], + [0.0, 1.0, 1.0], + [-0.44329658, 0.9657365, 1.0], + [0.7018292, 0.98709005, 1.0], + [1.1931813, 0.98478085, 1.0], + [0.12222669, 0.9984329, 1.0], + [0.0, 1.0, 1.0], + [-0.563516, 0.94921166, 1.0], + [-0.04889182, 0.9925632, 1.0], + [0.071504414, 0.9989917, 1.0], + [0.007119859, 0.99990845, 1.0], + [0.0, 1.0, 1.0], + [-1.4962281, 0.8972278, 1.0], + [0.15459283, 0.9727671, 1.0], + [0.23805064, 0.99562365, 1.0], + [0.0077146757, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-2.4667382, 0.8505024, 1.0], + [2.129667, 0.9446288, 1.0], + [1.1396877, 0.98031807, 1.0], + [0.037179574, 0.9993321, 1.0], + [0.0, 1.0, 1.0], + [-3.781338, 0.73189, 1.0], + [1.1288502, 0.93383217, 1.0], + [0.53486246, 0.9789267, 1.0], + [0.12766436, 0.9938904, 1.0], + [0.0, 1.0, 1.0], + [-6.5518723, 0.62532294, 1.0], + [0.68554205, 0.8983733, 1.0], + [0.57966214, 0.96441585, 1.0], + [0.20091358, 0.98821115, 1.0], + [0.0, 1.0, 1.0], + [-4.343184, 0.5767734, 1.0], + [-1.3167001, 0.8343934, 1.0], + [0.050232526, 0.99427885, 1.0], + [0.0041727684, 0.9994736, 1.0], + [0.0, 1.0, 1.0], + [-3.2084956, 0.5238724, 1.0], + [-3.5061476, 0.6398532, 1.0], + [-0.76263064, 0.96318024, 1.0], + [-0.01846399, 0.9992613, 1.0], + [0.0, 1.0, 1.0], + [-4.0521426, 0.50757915, 1.0], + [-4.9473753, 0.56081027, 1.0], + [-3.688444, 0.8328823, 1.0], + [-0.18685617, 0.99239963, 1.0], + [0.0, 1.0, 1.0], + [-4.2548413, 0.50943226, 1.0], + [-4.365784, 0.6119638, 1.0], + [-3.4715931, 0.8569653, 1.0], + [-0.3267715, 0.9877998, 1.0], + [0.0, 1.0, 1.0], + [-3.9077795, 0.5238228, 1.0], + [-3.707032, 0.67760634, 1.0], + [-2.9548562, 0.87688464, 1.0], + [-0.83595866, 0.9781673, 1.0], + [0.0, 1.0, 1.0], + [-2.8748026, 0.59341663, 1.0], + [-3.0543437, 0.8072005, 1.0], + [-3.223465, 0.8836812, 1.0], + [-2.364663, 0.9595582, 1.0], + [0.0, 1.0, 1.0], + [-2.5147824, 0.5750547, 1.0], + [-2.7698386, 0.72502124, 1.0], + [-2.2416823, 0.87920475, 1.0], + [-0.027188452, 0.96920735, 1.0], + [0.0, 1.0, 1.0], + [-1.3069018, 0.5413889, 1.0], + [-1.3765919, 0.6018836, 1.0], + [-0.23360418, 0.9007077, 1.0], + [0.44479728, 0.97555995, 1.0], + [0.0, 1.0, 1.0], + [0.36504295, 0.5780787, 1.0], + [0.102457404, 0.6328473, 1.0], + [0.9009599, 0.8177817, 1.0], + [0.7650351, 0.900801, 1.0], + [0.0, 1.0, 1.0], + [2.2459548, 0.6080276, 1.0], + [2.6340177, 0.6246803, 1.0], + [2.156162, 0.7627034, 1.0], + [1.6283973, 0.8479486, 1.0], + [0.0, 1.0, 1.0], + [2.3978112, 0.6188074, 1.0], + [2.9135118, 0.5810156, 1.0], + [2.4169562, 0.67806387, 1.0], + [0.88490367, 0.9132394, 1.0], + [0.0, 1.0, 1.0], + [2.6887186, 0.6619631, 1.0], + [2.912676, 0.61564636, 1.0], + [2.531864, 0.7293305, 1.0], + [0.44374037, 0.97021365, 1.0], + [0.0, 1.0, 1.0], + [3.5004687, 0.7542064, 1.0], + [2.6793096, 0.72422296, 1.0], + [1.9161915, 0.8289456, 1.0], + [0.20282242, 0.98497564, 1.0], + [0.0, 1.0, 1.0], + [3.6729927, 0.81156904, 1.0], + [1.9421659, 0.77600074, 1.0], + [0.16385932, 0.8949159, 1.0], + [0.00030535206, 0.99532956, 1.0], + [0.0, 1.0, 1.0], + [0.78105825, 0.8560677, 1.0], + [0.10841931, 0.88307595, 1.0], + [-0.057357736, 0.968077, 1.0], + [-0.0019446412, 0.9990688, 1.0], + [0.0, 1.0, 1.0], + [-0.46066833, 0.862668, 1.0], + [-0.5855417, 0.8481995, 1.0], + [-0.05946241, 0.96510565, 1.0], + [-0.00071739085, 0.9993665, 1.0], + [0.0, 1.0, 1.0], + [0.67381376, 0.87415904, 1.0], + [0.24980347, 0.875317, 1.0], + [-0.49778527, 0.9727575, 1.0], + [-0.17107673, 0.996763, 1.0], + [0.0, 1.0, 1.0], + [1.0746922, 0.91508603, 1.0], + [-1.2298028, 0.89923114, 1.0], + [-3.1673076, 0.9375756, 1.0], + [-1.0668249, 0.9814089, 1.0], + [0.0, 1.0, 1.0], + [0.26751196, 0.9166898, 1.0], + [-0.44020677, 0.9547969, 1.0], + [-0.52170914, 0.9885321, 1.0], + [-0.13126421, 0.99768424, 1.0], + [0.0, 1.0, 1.0], + [1.475818, 0.9037874, 1.0], + [0.21531126, 0.94464564, 1.0], + [-0.20985661, 0.99219394, 1.0], + [-0.009496927, 0.99976933, 1.0], + [0.0, 1.0, 1.0], + [1.0757535, 0.907198, 1.0], + [-1.7803211, 0.9137079, 1.0], + [-1.6718693, 0.9619583, 1.0], + [-0.09668204, 0.9980284, 1.0], + [0.0, 1.0, 1.0], + [1.4640148, 0.89358455, 1.0], + [-2.225918, 0.8979506, 1.0], + [-3.6723483, 0.93562967, 1.0], + [-0.34880057, 0.99417984, 1.0], + [0.0, 1.0, 1.0], + [1.519818, 0.8728914, 1.0], + [-0.15754203, 0.9012406, 1.0], + [-0.82398623, 0.97935987, 1.0], + [-0.07315472, 0.998723, 1.0], + [0.0, 1.0, 1.0], + [-0.08069864, 0.8122788, 1.0], + [-1.1186706, 0.80712515, 1.0], + [-0.32798508, 0.97505164, 1.0], + [-0.013122095, 0.99961483, 1.0], + [0.0, 1.0, 1.0], + [-0.58201814, 0.7335289, 1.0], + [-2.675131, 0.7052787, 1.0], + [-3.4144542, 0.8866787, 1.0], + [-0.34905022, 0.99161583, 1.0], + [0.0, 1.0, 1.0], + [-0.20724466, 0.6997621, 1.0], + [-1.3870329, 0.6642682, 1.0], + [-2.3293695, 0.8806666, 1.0], + [-0.22909772, 0.9941094, 1.0], + [0.0, 1.0, 1.0], + [-0.38550782, 0.6612821, 1.0], + [-2.025744, 0.6200057, 1.0], + [-1.6411146, 0.8548279, 1.0], + [-0.15217084, 0.9952627, 1.0], + [0.0, 1.0, 1.0], + [-1.2272899, 0.6092909, 1.0], + [-2.3132815, 0.57801414, 1.0], + [-4.862611, 0.7366766, 1.0], + [-4.110943, 0.9027854, 1.0], + [0.0, 1.0, 1.0], + [-0.5921729, 0.6219872, 1.0], + [-1.8060966, 0.59441036, 1.0], + [-5.2765656, 0.6901705, 1.0], + [-5.4457603, 0.8478828, 1.0], + [0.0, 1.0, 1.0], + [-0.6031663, 0.71592474, 1.0], + [-3.0251799, 0.6357208, 1.0], + [-2.314684, 0.7075568, 1.0], + [-0.55420774, 0.92152005, 1.0], + [0.0, 1.0, 1.0], + [0.9571505, 0.96764106, 1.0], + [2.2270002, 0.93239284, 1.0], + [4.9888797, 0.8701656, 1.0], + [5.671471, 0.871926, 1.0], + [0.0, 1.0, 1.0], + [1.6529764, 0.9643956, 1.0], + [3.7584913, 0.91487896, 1.0], + [8.399242, 0.8215937, 1.0], + [7.2129803, 0.84500617, 1.0], + [0.0, 1.0, 1.0], + [0.71301705, 0.95700616, 1.0], + [3.5324132, 0.91654754, 1.0], + [7.481569, 0.83848864, 1.0], + [3.3779297, 0.9219365, 1.0], + [0.0, 1.0, 1.0], + [2.1027052, 0.9415878, 1.0], + [3.9730468, 0.90671736, 1.0], + [2.484185, 0.9380085, 1.0], + [0.4668634, 0.98106366, 1.0], + [0.0, 1.0, 1.0], + [1.8107373, 0.95316005, 1.0], + [2.2031755, 0.9406371, 1.0], + [0.9073857, 0.97643065, 1.0], + [0.074082, 0.9965095, 1.0], + [0.0, 1.0, 1.0], + [1.9055933, 0.9425168, 1.0], + [2.002828, 0.9310888, 1.0], + [1.4750477, 0.9285675, 1.0], + [0.8887092, 0.93962455, 1.0], + [0.0, 1.0, 1.0], + [3.262398, 0.89178324, 1.0], + [4.3024693, 0.8579417, 1.0], + [3.0110278, 0.89283866, 1.0], + [0.7838177, 0.94552124, 1.0], + [0.0, 1.0, 1.0], + [3.9675722, 0.85747033, 1.0], + [2.8633819, 0.8719699, 1.0], + [1.8338549, 0.8609559, 1.0], + [-0.20613429, 0.8572672, 1.0], + [0.0, 1.0, 1.0], + [5.608068, 0.8017892, 1.0], + [2.561957, 0.8876735, 1.0], + [0.74627113, 0.9300394, 1.0], + [-0.3343548, 0.9052658, 1.0], + [0.0, 1.0, 1.0], + [6.1944766, 0.7262175, 1.0], + [4.09076, 0.79534775, 1.0], + [0.22131784, 0.9268037, 1.0], + [-0.41391882, 0.8326499, 1.0], + [0.0, 1.0, 1.0], + [4.064076, 0.70480764, 1.0], + [6.553729, 0.66739255, 1.0], + [1.9781996, 0.8487641, 1.0], + [-0.121733144, 0.9128515, 1.0], + [0.0, 1.0, 1.0], + [5.1924176, 0.7348373, 1.0], + [6.2945476, 0.59355253, 1.0], + [2.6655605, 0.603478, 1.0], + [-0.39269662, 0.750913, 1.0], + [0.0, 1.0, 1.0], + [4.218477, 0.84765875, 1.0], + [6.473049, 0.63901764, 1.0], + [2.3956764, 0.55744296, 1.0], + [-4.668969, 0.5518637, 1.0], + [0.0, 1.0, 1.0], + [1.4455693, 0.9488281, 1.0], + [2.86164, 0.8824624, 1.0], + [-0.83002204, 0.66669524, 1.0], + [-5.9115415, 0.5832427, 1.0], + [0.0, 1.0, 1.0], + [-0.36723047, 0.958581, 1.0], + [-0.9645545, 0.9544808, 1.0], + [-0.22951192, 0.9522579, 1.0], + [-0.5863564, 0.93541694, 1.0], + [0.0, 1.0, 1.0], + [-1.2512479, 0.9246585, 1.0], + [-0.7575997, 0.8948588, 1.0], + [-0.021703921, 0.8694443, 1.0], + [-1.218604, 0.917733, 1.0], + [0.0, 1.0, 1.0], + [-0.7749454, 0.96086174, 1.0], + [-1.9861422, 0.88336587, 1.0], + [-2.37847, 0.7608838, 1.0], + [-4.573041, 0.7435837, 1.0], + [0.0, 1.0, 1.0], + [-0.47515392, 0.96518725, 1.0], + [-1.1331214, 0.9392888, 1.0], + [-1.6403626, 0.82869226, 1.0], + [-0.24246822, 0.77455837, 1.0], + [0.0, 1.0, 1.0], + [-0.11114399, 0.92392385, 1.0], + [-0.93227565, 0.86591965, 1.0], + [0.2098795, 0.77408874, 1.0], + [4.1791086, 0.76218206, 1.0], + [0.0, 1.0, 1.0], + [-0.3893674, 0.9528764, 1.0], + [0.30993137, 0.88314533, 1.0], + [0.1185184, 0.7693184, 1.0], + [-5.573007, 0.7812261, 1.0], + [0.0, 1.0, 1.0], + [-0.3838659, 0.979484, 1.0], + [-1.1585885, 0.9468169, 1.0], + [-6.0387793, 0.80375, 1.0], + [-10.658616, 0.6974271, 1.0], + [0.0, 1.0, 1.0], + [0.61331856, 0.96465194, 1.0], + [3.4350507, 0.88050884, 1.0], + [4.5260396, 0.7807682, 1.0], + [4.9431553, 0.6703685, 1.0], + [0.0, 1.0, 1.0], + [0.6757144, 0.9519377, 1.0], + [3.6062489, 0.79651386, 1.0], + [8.332298, 0.73199075, 1.0], + [11.87797, 0.70557594, 1.0], + [0.0, 1.0, 1.0], + [-1.2273861, 0.95738363, 1.0], + [-3.0259614, 0.86301917, 1.0], + [4.467369, 0.7776754, 1.0], + [10.518545, 0.73643816, 1.0], + [0.0, 1.0, 1.0], + [-1.0061618, 0.9638054, 1.0], + [-0.028563451, 0.8975071, 1.0], + [4.998733, 0.8193289, 1.0], + [0.36073107, 0.7914785, 1.0], + [0.0, 1.0, 1.0], + [0.01982086, 0.9933504, 1.0], + [0.64965045, 0.97897077, 1.0], + [-0.023847675, 0.92317396, 1.0], + [-5.3040524, 0.8655476, 1.0], + [0.0, 1.0, 1.0], + [-0.7715526, 0.9489036, 1.0], + [-1.1035258, 0.9647486, 1.0], + [-0.073881075, 0.9852907, 1.0], + [-0.5774172, 0.98273665, 1.0], + [0.0, 1.0, 1.0], + [0.47609085, 0.9533993, 1.0], + [-0.4830082, 0.9705133, 1.0], + [0.31816888, 0.98172605, 1.0], + [0.09138787, 0.9937235, 1.0], + [0.0, 1.0, 1.0], + [0.7656475, 0.9784108, 1.0], + [1.1379092, 0.9705946, 1.0], + [-0.40117097, 0.9814611, 1.0], + [-0.43116137, 0.98941934, 1.0], + [0.0, 1.0, 1.0], + [0.96292067, 0.9768109, 1.0], + [1.9278172, 0.95556206, 1.0], + [0.4385697, 0.9872155, 1.0], + [-0.031007234, 0.9986333, 1.0], + [0.0, 1.0, 1.0], + [-0.5708252, 0.977658, 1.0], + [0.44023803, 0.94770473, 1.0], + [0.6502315, 0.98133624, 1.0], + [0.023992987, 0.99942213, 1.0], + [0.0, 1.0, 1.0], + [-1.115171, 0.97515327, 1.0], + [-1.192545, 0.96181446, 1.0], + [0.07483103, 0.9923174, 1.0], + [0.0070394175, 0.99980354, 1.0], + [0.0, 1.0, 1.0], + [-1.0444499, 0.98199964, 1.0], + [-2.1357777, 0.9839715, 1.0], + [-0.24349906, 0.99832034, 1.0], + [-0.0015810649, 0.9999879, 1.0], + [0.0, 1.0, 1.0], + [-1.7282641, 0.9509198, 1.0], + [-1.8230197, 0.96066856, 1.0], + [-0.12977597, 0.9974213, 1.0], + [-0.00060932315, 0.99998885, 1.0], + [0.0, 1.0, 1.0], + [-1.1258417, 0.9449696, 1.0], + [-1.9602667, 0.95090944, 1.0], + [-0.17851818, 0.9957969, 1.0], + [-0.00095068174, 0.9999777, 1.0], + [0.0, 1.0, 1.0], + [-0.020462122, 0.9958111, 1.0], + [-0.103778236, 0.99696887, 1.0], + [-0.007417411, 0.999726, 1.0], + [0.00020599048, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [0.00027978048, 0.9997287, 1.0], + [0.0051376326, 0.9999058, 1.0], + [0.008358893, 0.99989253, 1.0], + [0.0008256137, 0.9999894, 1.0], + [0.0, 1.0, 1.0], + [-0.002835088, 0.9997184, 1.0], + [-0.00021424032, 0.9999535, 1.0], + [0.0004976028, 0.99999297, 1.0], + [ + 4.8389087e-05, + 0.9999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.015410396, 0.99877673, 1.0], + [0.010854519, 0.9995375, 1.0], + [0.0058286395, 0.9998951, 1.0], + [0.00017566227, 0.999997, 1.0], + [0.0, 1.0, 1.0], + [0.15632677, 0.99270207, 1.0], + [0.5055092, 0.9882981, 1.0], + [0.09153723, 0.99786544, 1.0], + [0.0029066182, 0.9998918, 1.0], + [0.0, 1.0, 1.0], + [0.3650484, 0.975003, 1.0], + [1.0115135, 0.9615986, 1.0], + [0.085873, 0.96660244, 1.0], + [0.11893891, 0.99087006, 1.0], + [0.0, 1.0, 1.0], + [-0.7944044, 0.95407045, 1.0], + [-1.6605172, 0.94695765, 1.0], + [-2.2631047, 0.9047574, 1.0], + [-0.049214106, 0.97751004, 1.0], + [0.0, 1.0, 1.0], + [-1.1152909, 0.90077907, 1.0], + [-0.33290938, 0.98415893, 1.0], + [-0.4078292, 0.98850363, 1.0], + [-0.035879742, 0.9983943, 1.0], + [0.0, 1.0, 1.0], + [-2.5095966, 0.73562944, 1.0], + [-1.7563529, 0.92154866, 1.0], + [-0.6108345, 0.9851728, 1.0], + [-0.017620184, 0.9995598, 1.0], + [0.0, 1.0, 1.0], + [-3.7723317, 0.584941, 1.0], + [-3.2452734, 0.78533214, 1.0], + [-1.7837398, 0.94404143, 1.0], + [-0.27508318, 0.9898979, 1.0], + [0.0, 1.0, 1.0], + [-3.912875, 0.56384224, 1.0], + [-2.3403068, 0.76557755, 1.0], + [-2.328987, 0.90948534, 1.0], + [-0.72994906, 0.9717644, 1.0], + [0.0, 1.0, 1.0], + [-3.6593647, 0.61010647, 1.0], + [-2.3331697, 0.79162896, 1.0], + [-1.2066433, 0.8530142, 1.0], + [-0.5269507, 0.9523624, 1.0], + [0.0, 1.0, 1.0], + [-2.3541603, 0.7243888, 1.0], + [-2.905786, 0.7482802, 1.0], + [-2.578955, 0.7870731, 1.0], + [-0.48410028, 0.9308829, 1.0], + [0.0, 1.0, 1.0], + [-1.8553971, 0.7095888, 1.0], + [-3.4988222, 0.7074026, 1.0], + [-2.3901432, 0.84288883, 1.0], + [-2.6604743, 0.8977006, 1.0], + [0.0, 1.0, 1.0], + [-1.1934421, 0.68950033, 1.0], + [-1.7103893, 0.6494678, 1.0], + [-2.905974, 0.830583, 1.0], + [-3.459951, 0.88124573, 1.0], + [0.0, 1.0, 1.0], + [-0.9005052, 0.7782541, 1.0], + [0.25286996, 0.58092237, 1.0], + [3.6827495, 0.60284424, 1.0], + [1.3592215, 0.71437454, 1.0], + [0.0, 1.0, 1.0], + [2.7498083, 0.6792007, 1.0], + [5.0188756, 0.5581819, 1.0], + [6.5940504, 0.59141374, 1.0], + [5.501629, 0.72167873, 1.0], + [0.0, 1.0, 1.0], + [11.003128, 0.6373507, 1.0], + [10.752663, 0.5938981, 1.0], + [5.730028, 0.70469797, 1.0], + [1.2751229, 0.91109717, 1.0], + [0.0, 1.0, 1.0], + [8.977964, 0.6975431, 1.0], + [8.752965, 0.70448804, 1.0], + [5.0871115, 0.8300587, 1.0], + [1.5494138, 0.9552021, 1.0], + [0.0, 1.0, 1.0], + [5.414441, 0.82363, 1.0], + [3.8264759, 0.86136043, 1.0], + [1.5306748, 0.9412156, 1.0], + [0.11795472, 0.99586946, 1.0], + [0.0, 1.0, 1.0], + [4.8325124, 0.9293107, 1.0], + [1.3051184, 0.8975878, 1.0], + [-0.40878016, 0.95939577, 1.0], + [-0.022907255, 0.99856335, 1.0], + [0.0, 1.0, 1.0], + [1.3105308, 0.95905125, 1.0], + [-0.39071774, 0.9465973, 1.0], + [-0.46491575, 0.9753931, 1.0], + [-0.017971309, 0.9991197, 1.0], + [0.0, 1.0, 1.0], + [1.1686826, 0.94627494, 1.0], + [-0.10396043, 0.96546596, 1.0], + [-0.020627985, 0.9919678, 1.0], + [-0.00016111118, 0.99983495, 1.0], + [0.0, 1.0, 1.0], + [0.9874247, 0.97295517, 1.0], + [-0.12256643, 0.97974455, 1.0], + [-0.035051867, 0.996143, 1.0], + [-0.0033689938, 0.99987954, 1.0], + [0.0, 1.0, 1.0], + [2.1092582, 0.97678125, 1.0], + [-0.4759864, 0.9802004, 1.0], + [-0.21539624, 0.99497736, 1.0], + [-0.023042068, 0.9995811, 1.0], + [0.0, 1.0, 1.0], + [-0.024750752, 0.9861515, 1.0], + [-0.19931263, 0.99379665, 1.0], + [-0.05572195, 0.9987022, 1.0], + [-0.0031600085, 0.9999404, 1.0], + [0.0, 1.0, 1.0], + [0.7919422, 0.9732687, 1.0], + [-0.09644, 0.9860887, 1.0], + [-0.18857616, 0.9951589, 1.0], + [-0.008815362, 0.9997931, 1.0], + [0.0, 1.0, 1.0], + [0.6597246, 0.9796911, 1.0], + [-1.5101999, 0.96022296, 1.0], + [-1.2019504, 0.97245467, 1.0], + [-0.0584563, 0.99866176, 1.0], + [0.0, 1.0, 1.0], + [0.50696546, 0.98583317, 1.0], + [-0.6666319, 0.9827294, 1.0], + [-0.8286432, 0.98483706, 1.0], + [-0.065979086, 0.9988241, 1.0], + [0.0, 1.0, 1.0], + [0.67447394, 0.99499726, 1.0], + [-0.10373823, 0.9929153, 1.0], + [-0.19383939, 0.9964306, 1.0], + [-0.016325893, 0.9997172, 1.0], + [0.0, 1.0, 1.0], + [0.009086539, 0.99369854, 1.0], + [-0.13390036, 0.9848741, 1.0], + [-0.047612753, 0.9982364, 1.0], + [-0.0026982238, 0.99993587, 1.0], + [0.0, 1.0, 1.0], + [0.25034446, 0.95816684, 1.0], + [-1.1009699, 0.93954855, 1.0], + [-1.2868019, 0.968089, 1.0], + [-0.10685055, 0.99749947, 1.0], + [0.0, 1.0, 1.0], + [-0.17949252, 0.957553, 1.0], + [-1.1589056, 0.9355635, 1.0], + [-1.1646432, 0.97002524, 1.0], + [-0.09039519, 0.9978748, 1.0], + [0.0, 1.0, 1.0], + [-0.26302215, 0.9246536, 1.0], + [-1.1882069, 0.8875307, 1.0], + [-0.49334013, 0.97781765, 1.0], + [-0.041776404, 0.99893105, 1.0], + [0.0, 1.0, 1.0], + [-0.36667982, 0.84665173, 1.0], + [-1.4176713, 0.7761855, 1.0], + [-1.7322344, 0.91246706, 1.0], + [-0.9948195, 0.97528285, 1.0], + [0.0, 1.0, 1.0], + [-0.33352765, 0.8812866, 1.0], + [-1.3768619, 0.7867589, 1.0], + [-2.1349912, 0.8586197, 1.0], + [-1.0613863, 0.96087015, 1.0], + [0.0, 1.0, 1.0], + [-0.16149376, 0.95193124, 1.0], + [-0.5565226, 0.8689309, 1.0], + [-0.71723574, 0.8644273, 1.0], + [-0.036870863, 0.9551357, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs new file mode 100644 index 00000000..61a8f8c4 --- /dev/null +++ b/crates/catalog/src/folders.rs @@ -0,0 +1,226 @@ +//! The library's folder tree: where on disk the imported photos live. +//! +//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted, +//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the +//! ones above them, never a folder that holds nothing of the library, with how many photos each +//! holds — its subfolders included, like a year in By Date counts its months. +//! +//! Every folder is a row of its own, the ones above the imported photos included +//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two +//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see +//! [`crate::query::folder_key`]). +//! +//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share +//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`, +//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the +//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is +//! touched, so a share that is asleep or gone lists and counts like one that is there, and the +//! sidebar never waits on the network. +//! +//! Limits of reading only the stored spelling of a path: file names are compared as written +//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and +//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings +//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is +//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts. +//! +//! This is the library's own view of its photos; browsing any folder on disk without importing +//! is Local's job (see [`crate::local`]). + +use std::collections::{BTreeMap, HashMap}; + +use serde::Serialize; + +use crate::query::{folder_key, folder_within, split_path}; +use crate::{Catalog, Source}; + +/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its +/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it) +/// whatever a catalog holds. +pub const MAX_DEPTH: usize = 64; + +/// One row of the library's folder tree. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FolderNode { + /// What the row says: the folder's name; for a volume, its name (`nas`, `D:`, + /// `//server/share`, `/` for the startup disk). + pub name: String, + /// The folder the row stands for, written out from the names of the photos' paths (forward + /// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its + /// photos. + pub path: String, + /// Photos in this folder or any folder inside it. + pub count: usize, + /// Photos directly in this folder. + pub own: usize, + /// A disk rather than a folder in one; its `path` is where it is mounted. + pub volume: bool, + /// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not + /// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their + /// path covers those disks' photos too, so choosing them is not offered. + pub selectable: bool, + pub children: Vec, +} + +/// A folder while the tree is being built (children by identity key). +struct Level { + name: String, + path: String, + own: usize, + volume: bool, + children: Vec, +} + +/// Where a stored folder path lies: its volume and the folder names below it. +struct Placed { + mount: String, + volume: String, + names: Vec, +} + +/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`; +/// any other (`sftp:host=nas`) as it is. +fn mount_name(n: &str) -> String { + let field = |key: &str| { + let rest = n.strip_prefix("smb-share:")?; + rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty()) + }; + match (field("share"), field("server")) { + (Some(share), Some(server)) => format!("{share} on {server}"), + _ => n.to_string(), + } +} + +/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a +/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not +/// absolute or names no share. +fn place(dir: &str) -> Option { + let sp = split_path(dir, false); + let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect(); + let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used); + let (mount, volume, used) = if sp.prefix.starts_with("//") { + // `//server/share`: a server alone (no share) is no place to keep photos + if sp.prefix.ends_with('/') { + return None; + } + named(sp.prefix.clone(), &sp.prefix, 0) + } else if !sp.prefix.is_empty() { + named(sp.prefix.clone(), &sp.prefix, 0) + } else if sp.absolute { + match names.as_slice() { + ["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2), + ["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4), + ["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3), + ["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2), + // Linux desktops mount network places per user, naming server and share + ["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5), + // NFS automounts: Linux `/net/`, macOS `/Network/Servers/` + ["net", host, ..] => named(format!("/net/{host}"), host, 2), + ["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3), + _ => named("/".to_string(), "/", 0), + } + } else { + return None; + }; + Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() }) +} + +/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on, +/// from its stored path alone. +pub fn volume_of(p: &crate::Photo) -> Option { + let Source::File { path } = &p.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + place(dir).map(|pl| folder_key(&pl.mount)) +} + +/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a +/// folder in one. +pub fn is_disk_root(path: &str) -> bool { + place(path).is_some_and(|p| p.names.is_empty()) +} + +/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk. +pub fn is_startup_disk(path: &str) -> bool { + place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/") +} + +/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are +/// told apart; the path itself when it has no names (`/`). +pub fn folder_label(path: &str) -> String { + let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect(); + if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::>().join("/") } +} + +impl Catalog { + /// The library's volumes with their folders and photo counts: volumes by name + /// (case-insensitive), each folder with its subfolders the same way. + pub fn folder_tree(&self) -> Vec { + // photos per containing folder, as spelled (sorted, so the spelling that names a folder + // never depends on hash order) + let mut dirs: BTreeMap<&str, usize> = BTreeMap::new(); + for p in self.photos().filter(|p| p.in_library()) { + if let Source::File { path } = &p.source + && let Some(i) = path.rfind(['/', '\\']) + // a photo straight in the root has an empty folder part: the root itself + && let Some(dir) = path.get(..i.max(1)) + { + *dirs.entry(dir).or_default() += 1; + } + } + let mut levels: HashMap = HashMap::new(); + let mut roots: Vec = Vec::new(); + for (dir, n) in dirs { + let Some(placed) = place(dir) else { continue }; + let vkey = folder_key(&placed.mount); + if !levels.contains_key(&vkey) { + levels.insert( + vkey.clone(), + Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() }, + ); + roots.push(vkey.clone()); + } + let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() }; + let mut parent = vkey; + for name in &placed.names { + prefix.push('/'); + prefix.push_str(name); + let key = folder_key(&prefix); + if !levels.contains_key(&key) { + levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() }); + if let Some(l) = levels.get_mut(&parent) { + l.children.push(key.clone()); + } + } + parent = key; + } + if let Some(l) = levels.get_mut(&parent) { + l.own += n; + } + } + let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels)).collect(); + sort(&mut tree); + // a folder that holds another disk's mount covers that disk's photos too + let mounts: Vec = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect(); + mark_selectable(&mut tree, &mounts); + tree + } +} + +/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep). +fn build(key: &str, levels: &HashMap) -> Option { + let l = levels.get(key)?; + let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect(); + sort(&mut children); + let count = l.own + children.iter().map(|c| c.count).sum::(); + Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children }) +} + +fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) { + for n in nodes { + n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path))); + mark_selectable(&mut n.children, mounts); + } +} + +fn sort(v: &mut [FolderNode]) { + v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name))); +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 208a86d0..2934c7a6 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -14,6 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod dates; +pub mod folders; pub mod journal; pub mod keywords; pub mod local; @@ -29,6 +30,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use dates::{DateRun, GroupBy}; +pub use folders::FolderNode; pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming}; pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; @@ -612,6 +614,22 @@ impl Catalog { Op::Batch { ops } } + /// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the + /// catalog as it is now: albums lose all of them in one write each, and stacks are shortened + /// (or dissolved) once, however many of their photos go. + pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op { + let gone: std::collections::HashSet = ids.iter().copied().collect(); + let mut ops: Vec = self + .albums + .values() + .filter(|a| a.photos.iter().any(|p| gone.contains(p))) + .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() }) + .collect(); + ops.extend(self.remove_from_stacks_ops(ids)); + ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id })); + Op::Batch { ops } + } + // ---- persistence /// Full snapshot as JSON. @@ -655,6 +673,8 @@ mod tests; #[cfg(test)] mod tests_background; #[cfg(test)] +mod tests_folders; +#[cfg(test)] mod tests_format_version; #[cfg(test)] mod tests_journal; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index fe8d1b05..9c1050fa 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use lightcraft_develop::DevelopSettings; +use lightcraft_develop::{DevelopSettings, WbMode}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -336,12 +336,12 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera + /// The current ARW, NEF, RW2 and RAF readers have vendor WB multipliers but no measured camera /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { - self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -370,7 +370,12 @@ impl Photo { } /// Edited by the user: settings differ from what import gave the photo. pub fn is_edited(&self) -> bool { - *self.develop != self.import_defaults() && !self.develop.is_unedited() + let mut defaults = self.import_defaults(); + // As Shot reads the source's current WB; stored numbers can predate a decoder update. + if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot { + defaults.wb = self.develop.wb; + } + *self.develop != defaults && !self.develop.is_unedited() } /// Capture time if known, else import time (sort key). pub fn date(&self) -> &str { @@ -430,7 +435,7 @@ mod edited_tests { } #[test] - fn sony_and_nikon_raws_use_relative_white_balance() { + fn supported_raws_use_relative_white_balance() { for (name, format, relative) in [ ("a.arw", "ARW", true), ("a.nef", "NEF", true), @@ -438,6 +443,7 @@ mod edited_tests { ("a.rw2", "RW2", true), ("a.rwl", "RWL", true), ("a.raw", "RAW", true), + ("a.raf", "rAf", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); @@ -446,6 +452,12 @@ mod edited_tests { assert_eq!(p.relative_wb(), relative, "{format}"); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0)); + assert!(!p.is_edited(), "{format}: old As Shot values are not an edit"); + let mut custom = (*p.develop).clone(); + custom.wb.mode = WbMode::Custom; + p.develop = Arc::new(custom); + assert!(p.is_edited(), "{format}: Custom WB is still an edit"); // shown from the embedded preview: a rendered image, not a relative-WB raw p.preview_only = Some("unsupported".into()); assert!(!p.relative_wb()); diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index d06e1370..ad14cd0a 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -37,6 +37,10 @@ pub struct Filter { pub deleted: bool, /// Capture date prefix (`2026`, `2026-04`, `2026-04-12`). pub date: Option, + /// A folder the library's photos were imported from: those photos and the ones imported from + /// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part + /// of it, unlike [`Filter::folder`]. + pub library_folder: Option, /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, /// A person: photos with a named face region of this name (case-insensitive), as read from XMP. @@ -186,6 +190,7 @@ impl Filter { ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), + ("folder", &self.library_folder), ("imported", &self.imported), ] { if let Some(x) = val { @@ -213,6 +218,19 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { + self.matches_in(p, cat, self.library_root().as_deref()) + } + + /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. + fn library_root(&self) -> Option { + // as given: a folder may be named with spaces at its edge, and trimming would make it + // another folder + 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 { if p.deleted != self.deleted { return false; } @@ -285,6 +303,11 @@ impl Filter { { return false; } + if let Some(root) = root + && !photo_in_root(p, root) + { + return false; + } if let Some(d) = &self.imported && !p.imported.starts_with(d.as_str()) { @@ -317,7 +340,8 @@ impl Filter { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by id for stability). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect(); + let root = filter.library_root(); + let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); v.sort_by(|a, b| { let o = match sort.key { SortKey::CaptureDate => a.date().cmp(b.date()), @@ -384,7 +408,8 @@ impl Catalog { pub fn people_in(&self, filter: &Filter) -> Vec { let filter = Filter { person: None, ..filter.clone() }; let mut m: std::collections::HashMap = Default::default(); - for p in self.photos().filter(|p| filter.matches(p, self)) { + let root = filter.library_root(); + for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { let mut seen: Vec = 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 }; @@ -438,12 +463,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool { !rest.is_empty() && (deep || !rest.contains(['/', '\\'])) } -/// A folder path's identity, for telling whether two spellings name the same folder: `/` and -/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped, -/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive -/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased. -/// No file-system access: the paths compared should already be absolute. -pub fn folder_key(path: &str) -> String { +/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`, +/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute. +pub(crate) struct SplitPath { + pub prefix: String, + pub parts: Vec, + pub absolute: bool, +} + +/// Both separators, repeated separators (also between a server and its share), `.` and a +/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed; +/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is). +pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath { let mut s = path.replace('\\', "/"); if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) { s = match rest.strip_prefix("UNC/") { @@ -451,28 +482,39 @@ pub fn folder_key(path: &str) -> String { None => rest.to_string(), }; } - // the part `..` can't climb above: `//server/share`, `c:` or `/` let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) { - let mut it = unc.splitn(3, '/'); - let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or("")); - (format!("//{server}/{share}"), rest.to_string()) - } else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() { - (s[..2].to_ascii_lowercase(), s[2..].to_string()) + let mut segs = unc.split('/').filter(|x| !x.is_empty()); + let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or("")); + (format!("//{server}/{share}"), segs.collect::>().join("/")) + } else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) { + (if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string()) } else { (String::new(), s.clone()) }; let absolute = rest.starts_with('/') || !prefix.is_empty(); - let mut parts: Vec<&str> = Vec::new(); + let mut parts: Vec = Vec::new(); for c in rest.split('/') { match c { "" | "." => {} - ".." if parts.last().is_some_and(|p| *p != "..") => { + ".." if parts.last().is_some_and(|p| p != "..") => { parts.pop(); } ".." if absolute => {} - c => parts.push(c), + c => parts.push(c.to_string()), } } + SplitPath { prefix, parts, absolute } +} + +/// A folder path's identity, for telling whether two spellings name the same folder: `/` and +/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped, +/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive +/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased. +/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere +/// (macOS, a Samba share) two spellings that differ in case are two folders here. +/// No file-system access: the paths compared should already be absolute. +pub fn folder_key(path: &str) -> String { + let SplitPath { prefix, parts, absolute } = split_path(path, true); let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") }; if cfg!(windows) { key.to_lowercase() } else { key } } @@ -480,8 +522,40 @@ pub fn folder_key(path: &str) -> String { /// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled /// (see [`folder_key`]). pub fn folder_within(path: &str, root: &str) -> bool { - let (p, r) = (folder_key(path), folder_key(root)); - p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/'))) + key_within(&folder_key(path), &folder_key(root)) +} + +/// Whether a library photo (not one only browsed in Local) lies in the folder whose +/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing +/// separators) is its own identity, so it is read without building a key for it: the check runs +/// for every photo of the library on each refresh, and this keeps it cheap. +pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool { + if p.local { + return false; + } + let crate::Source::File { path } = &p.source else { return false }; + let plain = path.starts_with('/') + && !path.starts_with("//") + && !path.ends_with('/') + && !path.contains(['\\']) + && !path.contains("//") + && !path.contains("/."); + if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") { + let r = root.trim_end_matches('/'); + return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/')); + } + crate::local::folder_of(p).is_some_and(|f| key_within(&f, root)) +} + +/// [`folder_within`] for two paths already turned into their [`folder_key`]. +pub(crate) fn key_within(p: &str, r: &str) -> bool { + // `.` or `a/..` name no folder: not "everything" + if r.is_empty() { + return false; + } + // a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder + let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/')); + p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/')) } /// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`, diff --git a/crates/catalog/src/tests_folders.rs b/crates/catalog/src/tests_folders.rs new file mode 100644 index 00000000..556f77de --- /dev/null +++ b/crates/catalog/src/tests_folders.rs @@ -0,0 +1,404 @@ +//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]). +//! +//! Scenarios, in the words of someone browsing their library: +//! +//! * Given photos imported from several folders, the tree lists those folders and no others, +//! each with how many photos it holds, under the disk (volume) they are on. +//! * A folder's count includes the folders inside it. +//! * Every folder is a row of its own, the ones above the imported photos included. +//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere. +//! * However a folder is spelled, it is one folder. +//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly +//! as many photos as the row says. + +use crate::*; + +fn add(c: &mut Catalog, path: &str) -> PhotoId { + add_with(c, path, |_| {}) +} + +fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId { + let id = c.alloc_photo_id(); + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + tweak(&mut p); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn library(paths: &[&str]) -> Catalog { + let mut c = Catalog::new(); + paths.iter().for_each(|p| { + add(&mut c, p); + }); + c +} + +/// `(name, count, own)` of each node, depth first. +fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> { + fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) { + out.push((n.name.clone(), n.count, n.own)); + n.children.iter().for_each(|c| walk(c, out)); + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| walk(n, &mut out)); + out +} + +fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) { + (name.to_string(), count, own) +} + +fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> { + outline(&library(paths).folder_tree()) +} + +#[test] +fn an_empty_library_has_no_folders() { + assert!(Catalog::new().folder_tree().is_empty()); +} + +#[test] +fn only_folders_with_imported_photos_are_listed_with_their_counts() { + let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]); + let tree = c.folder_tree(); + assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]); + assert!(tree[0].volume && !tree[0].children[0].volume); + assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it"); +} + +#[test] +fn a_folders_count_includes_the_folders_inside_it() { + let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]); +} + +#[test] +fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() { + let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]); + let tree = c.folder_tree(); + assert_eq!( + outline(&tree), + vec![ + row("/", 2, 0), + row("Users", 2, 0), + row("me", 2, 0), + row("Pictures", 2, 0), + row("2024", 1, 1), + row("2025", 1, 1), + row("nas", 1, 0), + row("photos", 1, 1) + ] + ); + assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names"); +} + +#[test] +fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() { + assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]); +} + +#[test] +fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() { + let mut c = Catalog::new(); + add(&mut c, "/pics/kept/a.jpg"); + add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true); + add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true); + let id = c.alloc_photo_id(); + let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap(); + assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]); +} + +#[test] +fn however_a_folder_is_spelled_it_is_one_folder() { + let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]); +} + +#[test] +fn each_disk_is_a_volume_of_its_own() { + // macOS, Linux and Windows habits for where disks appear + let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]); + assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]); + let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]); + assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]); +} + +#[test] +fn a_network_share_is_a_volume_and_two_spellings_are_one() { + let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]); + assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]); + // the verbatim form Windows APIs give is the same share + let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]); + assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]); +} + +#[test] +fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() { + // Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix + let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]); + assert_eq!( + r, + vec![ + row("tokyo", 2, 0), + row("Photos", 2, 0), + row("2024", 1, 1), + row("2025", 1, 1), + row("tokyo-1", 1, 0), + row("old", 1, 1), + row("東京 NAS", 1, 1) + ] + ); + let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree(); + assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it"); +} + +#[test] +fn shares_the_other_systems_mount_are_volumes_too() { + // Linux desktops: gvfs mounts per user, with the server and share in the mount's name + let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos"; + let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]); + assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]); + // NFS automounts: Linux /net/, macOS /Network/Servers/ + let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]); + assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]); + // Windows: a share by name or by address, the WSL file system, a mapped drive + let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]); + assert_eq!( + r, + vec![ + row("//192.168.1.5/scans", 1, 1), + row("//tokyo.local/photos", 1, 1), + row("//wsl$/Ubuntu", 1, 0), + row("home", 1, 0), + row("me", 1, 1), + row("Z:", 1, 1) + ] + ); +} + +#[test] +fn a_share_that_is_not_mounted_costs_nothing() { + // the tree is made from the catalog's own records: it never looks at a disk, so a share that + // is asleep or gone lists (and counts) exactly like one that is there + let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]); + let tree = c.folder_tree(); + assert_eq!(tree.iter().map(|n| n.count).sum::(), 2); +} + +#[test] +fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() { + assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]); + assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]); +} + +#[test] +fn photos_with_no_folder_are_left_out() { + assert!(rows(&["x.jpg", ""]).is_empty()); +} + +#[test] +fn folders_are_listed_by_name_ignoring_case() { + let names: Vec = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect(); + assert_eq!(names, ["A", "b", "c"]); +} + +#[test] +fn absurdly_deep_paths_neither_crash_nor_lose_photos() { + let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/")); + let tree = library(&[&deep]).folder_tree(); + assert_eq!(tree.iter().map(|n| n.count).sum::(), 1); + assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2); +} + +#[test] +fn choosing_a_folder_shows_what_was_imported_from_it_and_below() { + let mut c = Catalog::new(); + let trip = add(&mut c, "/pics/trip/a.jpg"); + let day = add(&mut c, "/pics/trip/day1/b.jpg"); + let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder + let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true); + let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() }; + let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c); + assert!(shown(trip) && shown(day)); + assert!(!shown(sibling), "/pics/trip2 is another folder"); + assert!(!shown(browsed), "browsed photos are not part of the library's folders"); + assert_eq!(f.describe(), "folder /pics//trip/"); +} + +#[test] +fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() { + let mut c = Catalog::new(); + let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true); + let kept = add(&mut c, "/pics/trip/b.jpg"); + let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() }; + assert!(f.matches(c.photo(gone).unwrap(), &c)); + assert!(!f.matches(c.photo(kept).unwrap(), &c)); +} + +#[test] +fn an_empty_folder_choice_is_no_choice() { + let c = library(&["/pics/a.jpg"]); + let f = Filter { library_folder: Some(" ".into()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), 1); +} + +#[test] +fn every_row_shows_exactly_as_many_photos_as_it_says() { + let c = library(&[ + "/Users/me/Pictures/2024/a.jpg", + "/Users/me/Pictures/2024/b.jpg", + "/Users/me/Pictures/2025/c.jpg", + "/Users/me/Pictures/d.jpg", + "/Volumes/nas/photos/e.jpg", + "/Volumes/nas/f.jpg", + "/x.jpg", + "/a/../b/g.jpg", + "D:\\Photos\\2024\\h.jpg", + "d:/Photos/i.jpg", + "\\\\srv\\s1\\a\\j.jpg", + "\\\\?\\UNC\\srv\\s2\\k.jpg", + "/pics/trip/l.jpg", + "/pics/trip2/m.jpg", + "/Volumes/tokyo/Photos/2024/n.jpg", + "/Volumes/tokyo/o.jpg", + "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg", + "/net/osaka/export/q.jpg", + r"\\wsl$\Ubuntu\home\me\r.jpg", + // odd spellings the tree and the filter must read the same way + "//server/share/../other/1.jpg", + "//server/share/ok/2.jpg", + "//a b//A/f.jpg", + r"\\wsl$\Ubuntu\..\a\1.jpg", + // a folder that holds other disks as well as photos of its own + "/Volumes/1.jpg", + "/mnt/2.jpg", + "/mnt/disk/3.jpg", + ]); + fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) { + for n in nodes { + // the startup volume's row only opens: its path "/" would cover every other disk + if n.selectable { + let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path); + *seen += 1; + } + check(c, &n.children, seen); + } + } + let mut seen = 0; + check(&c, &c.folder_tree(), &mut seen); + assert!(seen > 10, "{seen} rows checked"); +} + +fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { + nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) }) +} + +#[test] +fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() { + let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree(); + let sel = |p: &str| find(&tree, p).map(|n| n.selectable); + assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk"); + assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk"); + assert_eq!(sel("/Volumes/tokyo"), Some(true)); + assert_eq!(sel("/Users/me"), Some(true)); + assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library"); +} + +#[test] +fn the_tree_and_the_filter_read_odd_spellings_the_same_way() { + // `..` cannot climb above a share, and repeated separators between server and share are one + let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree(); + assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]); +} + +#[test] +fn a_path_that_names_no_folder_chooses_nothing() { + let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]); + for dot in [".", "./", "a/..", "../.."] { + let f = Filter { library_folder: Some(dot.into()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything"); + } +} + +#[test] +fn a_folder_is_labelled_by_its_last_two_names() { + use crate::folders::folder_label; + assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel"); + assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo"); + assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024"); + assert_eq!(folder_label("/"), "/"); + assert_eq!(folder_label(""), ""); +} + +#[test] +fn disk_roots_are_told_from_folders() { + use crate::folders::{is_disk_root, is_startup_disk}; + for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] { + assert!(is_disk_root(p), "{p}"); + } + for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] { + assert!(!is_disk_root(p), "{p}"); + } + assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:")); +} + +#[test] +fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() { + // legal on macOS and Linux: `/p/shoot ` is not `/p/shoot` + let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]); + let tree = c.folder_tree(); + let shoot_space = find(&tree, "/p/shoot ").expect("its own row"); + let shoot = find(&tree, "/p/shoot").expect("and the other"); + for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] { + let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() }; + let ids = c.query(&f, &Sort::default()); + let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect(); + assert_eq!(names, [want], "{:?} shows its own photo", node.path); + } +} + +#[test] +fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() { + let mut c = Catalog::new(); + let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true); + let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() }; + assert!(!f.matches(c.photo(browsed).unwrap(), &c)); +} + +#[test] +fn the_quick_check_agrees_with_the_careful_one() { + use crate::query::{folder_key, key_within, photo_in_root}; + let paths = [ + "/a/b/c.jpg", + "/a/b/d/e.jpg", + "/a/bc/x.jpg", + "/a/./b/x.jpg", + "/a/../a/b/x.jpg", + "/a//b/x.jpg", + "/a\\b\\x.jpg", + "/a/b ", + "/a/b /x.jpg", + "/.hidden/x.jpg", + "/a/.b/x.jpg", + "/x.jpg", + "//srv/share/x.jpg", + "C:\\a\\b\\x.jpg", + "relative/a/b/x.jpg", + "/Volumes/tokyo/a/x.jpg", + "/日本/写真/x.jpg", + ]; + let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"]; + for p in paths { + let mut c = Catalog::new(); + let id = add(&mut c, p); + let photo = c.photo(id).unwrap(); + for r in roots { + let root = folder_key(r); + let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root)); + assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}"); + } + } +} diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d826b5ec..05824eb0 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -56,6 +56,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` ( EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear), `IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile). +`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour +space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light +resizing; a JPEG carrying its own metadata retains its existing interpretation. + ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3, Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc` emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding. diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index 65112259..ec9e8efe 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { }; p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)])); p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())])); - align_tags(&p.encode().unwrap_or_default()) + align_tags(&fixed_date(p.encode().unwrap_or_default())) +} + +/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend +/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode` +/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in +/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers +/// do not interpret the field; all zeros is the conventional "unspecified" value. +fn fixed_date(mut b: Vec) -> Vec { + if let Some(date) = b.get_mut(24..36) { + date.fill(0); + } + b } /// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to @@ -300,6 +312,23 @@ mod tests { let _ = SRGB; } + /// Two profiles for one space are byte-identical, and the header date is fixed: exports of the + /// same photo with the same settings hash the same (#180). + #[test] + fn written_profiles_are_deterministic() { + for n in NamedSpace::ALL { + let a = write_named(n); + let b = write_named(n); + assert_eq!(a, b, "{n:?}"); + assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time"); + // the profile ID is "not computed", not a hash of a timestamp + assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID"); + } + let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear); + assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear)); + assert_eq!(&lin[24..36], &[0u8; 12]); + } + #[test] fn garbage_is_none() { assert!(parse(&[]).is_none()); diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 0096f905..ce8712ad 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -105,6 +105,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { } pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + decode_with_fallback(bytes, opts, None) +} + +pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option) -> Result { let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; if m.components == 0 { return Err(Error::Malformed(F, "no frame header".into())); @@ -119,7 +123,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { } else { decode_zune(bytes, &m)? }; - let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() }; + // A raw container may carry the colour declaration of an otherwise untagged preview. + // Apply it before linearization/resizing; existing JPEG metadata always wins. + let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container)); + let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() }; finish(F, raw, meta, (m.width, m.height), opts) } @@ -246,6 +253,44 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { mod tests { use super::*; + #[test] + fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() { + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg}; + let pixels: Vec = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect(); + let encode = |icc, exif| { + encode_jpeg( + &EncodeImage::new(16, 16, 3, Samples::U8(&pixels)), + 100, + ChromaSubsampling::S444, + &EncodeMeta { icc, exif, ..Default::default() }, + ) + .unwrap() + }; + let untagged = encode(None, None); + let adobe = crate::icc::write_named(NamedSpace::AdobeRgb); + let tagged = encode(Some(&adobe), None); + for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] { + let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap(); + let expected = crate::decode(&tagged, opts).unwrap(); + assert_eq!(with.space.origin, SpaceOrigin::Container); + let (a, b) = (with.to_working(), expected.to_working()); + for (p, q) in a.data.iter().zip(&b.data) { + assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}"); + } + } + let srgb = crate::icc::write_named(NamedSpace::Srgb); + let exif = crate::exif::minimal_exif(6); + for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] { + let opts = DecodeOptions::fit(5, 5); + let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap(); + let expected = crate::decode(&bytes, opts).unwrap(); + assert_eq!(with.space, expected.space); + assert_eq!(with.image, expected.image); + assert_eq!(with.orientation, expected.orientation); + } + assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err()); + } + #[test] fn scaled_request_covers() { assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171)); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 708aa9b7..0328f33c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -156,6 +156,14 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result { std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into()))) } +/// Decode a JPEG preview with its enclosing container's colour space as a fallback. +/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied +/// before transfer decoding and linear-light resizing, never to already-resampled pixels. +pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result { + std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback))) + .unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into()))) +} + /// [`decode`] without the panic guard (for fuzzing our own code paths). #[doc(hidden)] pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index 3166c2dc..7de56c43 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -1,5 +1,6 @@ -//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette, -//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. +//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK, +//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette +//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path. use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; @@ -25,7 +26,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let icc = tag_bytes(&mut d, Tag::IccProfile); let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string()); let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16); - let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok(); let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2); let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default(); let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok(); @@ -45,6 +45,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), other => return Err(Error::Unsupported(F, unsupported_name(other))), }; + if bits < 8 && planar && n_samples > 1 { + return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte")); + } let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_)); let premultiplied = has_alpha && extra.first() == Some(&1); @@ -52,6 +55,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let (wu, hu) = (w as usize, h as usize); let n = wu * hu; let mut buf = match res { + DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?), DecodingResult::U8(v) => Buf::U8(v), DecodingResult::U16(v) => Buf::U16(v), // Rare integer widths: keep 16 significant bits (plenty for display-referred data). @@ -94,14 +98,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { return finish(F, raw, meta, (w, h), opts); } - // WhiteIsZero → invert. - if photometric == Some(0) { - match &mut buf { - Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x), - Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x), - Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x), - } - } + // WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and + // refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative. // Drop extra samples beyond colour + one alpha. let keep = n_color + has_alpha as usize; @@ -140,6 +138,29 @@ fn unsupported_name(ct: ColorType) -> &'static str { } } +/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most +/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to +/// 0–255 like any 8-bit sample. +fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result> { + if !matches!(bits, 1 | 2 | 4) { + return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits")); + } + let bits = bits as usize; + let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?; + let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8); + if row_bytes == 0 || packed.len() / row_bytes < height { + return Err(err("sample buffer too short")); + } + let max = (1u8 << bits) - 1; + let scale = 255 / max; // 255, 85, 17: exact + let mut out = Vec::with_capacity(per_row.saturating_mul(height)); + for row in packed.chunks_exact(row_bytes).take(height) { + let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max)); + out.extend(values.take(per_row).map(|v| v * scale)); + } + Ok(out) +} + fn interleave(buf: Buf, n: usize, s: usize) -> Buf { fn go(v: Vec, n: usize, s: usize) -> Vec { if v.len() < n * s { diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 4bce59de..a700ddf6 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() { assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); } +/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and +/// already packed into `strip`, one padded row after another. +fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec { + const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4; + let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat(); + let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat(); + let mut b = b"II*\0".to_vec(); + b.extend(8u32.to_le_bytes()); + b.extend(9u16.to_le_bytes()); + b.extend(short(256, width)); + b.extend(short(257, height)); + b.extend(short(258, bits)); + b.extend(short(259, 1)); + b.extend(short(262, photometric)); + b.extend(long(273, STRIP_AT)); + b.extend(short(277, 1)); + b.extend(short(278, height)); + b.extend(long(279, strip.len() as u32)); + b.extend(0u32.to_le_bytes()); + assert_eq!(b.len(), STRIP_AT as usize); + b.extend_from_slice(strip); + b +} + +/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these +/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative. +#[test] +fn tiff_white_is_zero_is_inverted_once() { + // Two pixels: the lowest sample, then the highest, at 8 and 16 bits. + for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] { + for (photometric, first) in [(0, 1.0), (1, 0.0)] { + let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap(); + let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]); + assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}"); + } + } +} + +/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or +/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short". +#[test] +fn tiff_samples_narrower_than_a_byte() { + let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1]; + + // 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored). + // Row 0 alternates set/clear bits; row 1 is clear but for its last pixel. + let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111]; + // What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0). + for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] { + let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap(); + assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1)); + assert!(d.grayscale); + let clear_bit = 1.0 - set_bit; + for x in 0..10 { + let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit }); + assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}"); + assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}"); + } + } + + // 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding). + let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap(); + for (x, v) in [0, 85, 170, 255].into_iter().enumerate() { + assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}"); + } + let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap(); + for (x, v) in [0, 255, 136].into_iter().enumerate() { + assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}"); + } + + // A strip shorter than its rows is an error, never a panic. + assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err()); +} + #[test] fn webp_lossless_exact() { let img = gradient(31, 19); diff --git a/crates/color/src/cct.rs b/crates/color/src/cct.rs index abd241dd..9cb2b96c 100644 --- a/crates/color/src/cct.rs +++ b/crates/color/src/cct.rs @@ -2,7 +2,9 @@ //! //! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that //! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in -//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`. +//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green. +//! A positive correction neutralises a green illuminant above the locus, so its source white uses +//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast. use crate::{Mat3, RgbSpace, Xy, bradford}; @@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) { if ny < 0.0 { (-nx, -ny) } else { (nx, ny) } } -/// Chromaticity of the white point for temperature `t` (K) and `tint`. +/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`. +/// Positive tint means a green source white, whose correction adds magenta to the image. pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy { let (u, v) = locus_uv(t); let (nu, nv) = locus_normal(t); - let duv = -tint / TINT_SCALE; + let duv = tint / TINT_SCALE; uv_to_xy(u + nu * duv, v + nv * duv) } @@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) { let (lu, lv) = locus_uv(t); let (nu, nv) = locus_normal(t); let duv = (u - lu) * nu + (v - lv) * nv; - (t, -duv * TINT_SCALE) + (t, duv * TINT_SCALE) } /// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it @@ -141,10 +144,18 @@ mod tests { let warm = temp_tint_to_xy(3000.0, 0.0); let cool = temp_tint_to_xy(9000.0, 0.0); assert!(warm.x > cool.x); - // positive tint is magenta: lower v + // A positive magenta correction specifies a greener source illuminant: higher v. let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0)); let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0)); - assert!(v1 < v0); + assert!(v1 > v0); + } + + /// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as + /// Lightroom's Daylight preset (5500 K, +10) has it (#188). + #[test] + fn daylight_has_a_positive_tint() { + let (_, tint) = xy_to_temp_tint(crate::D65); + assert!((5.0..15.0).contains(&tint), "{tint}"); } #[test] diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 800f229b..910cd07d 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -158,6 +158,7 @@ impl WbMode { pub struct WhiteBalance { pub mode: WbMode, pub temp: f64, + /// Correction direction: negative adds green, positive adds magenta. pub tint: f64, } @@ -539,8 +540,9 @@ impl Default for Optics { } } -/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted -/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop +/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3` +/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic / +/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop /// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts. /// LightCraft never uses Adobe LCP lens profiles. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c2d4e8a8..b92742b1 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } ab_glyph = { workspace = true } +# Apply the font’s vertical substitutions and origins to Japanese watermarks. +harfrust = { version = "0.12", default-features = false, features = ["std"] } lightcraft-segment = { workspace = true, optional = true } [features] diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index ac6a9a60..55dc2b11 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -93,7 +93,9 @@ fn main() { for path in &args { let bytes = std::fs::read(path).expect("read"); println!("{path}"); - let raw = lightcraft_raw::decode(&bytes).expect("decode"); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + // as the loader does: lens corrections are applied by the pipeline, not in the raw develop + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index cd0d475d..c43f416c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,5 +1,5 @@ //! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic -//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! RW2, Fujifilm RAF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, //! never output pixels or a replacement for RAW editing. //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals @@ -31,7 +31,7 @@ pub(crate) const PROFILE_PROXY: usize = 192; /// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the /// same formats by file extension. pub(crate) fn file_local_look(format: RawFormat) -> bool { - matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2) + matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf) } pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { @@ -40,13 +40,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf } let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; // A camera profile pooled from many photos knows colours this photo shows too little of; - // only the tone and chroma curves are fitted per photo (DRO and picture styles vary). + // try its colour first and fit tone/chroma per photo (DRO and picture styles vary). 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 look = fit_pairs_with(&sensor, &reference, colour)?; if lightcraft_pipeline::profiling() { eprintln!( - "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}", + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}", raw.format, look.matrix.0, look.tone, @@ -60,9 +60,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf /// 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). fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; let edge = (2 * size).max(384) as u32; - let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?; + let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { @@ -102,7 +101,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option Option { fit_pairs_with(sensor, reference, None) } +/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). +fn local_contrast(image: &Rgb32f, i: usize) -> f32 { + let (w, h) = (image.width, image.height); + if w == 0 { + return f32::INFINITY; + } + let (x, y) = (i % w, i / w); + let (mut lo, mut hi) = (f32::INFINITY, 0f32); + for row in y.saturating_sub(1)..(y + 2).min(h) { + for col in x.saturating_sub(1)..(x + 2).min(w) { + let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); + lo = lo.min(l); + hi = hi.max(l); + } + } + if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } +} + /// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights -/// (for the chroma curve); `None` when too few, or the photo has too little colour. +/// (for the chroma curve); `None` when too few, or the photo has too little colour. With +/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio +/// 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) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -161,7 +181,10 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. let mut bright = Vec::new(); let mut colour = 0; - for (input, output) in sensor.data.iter().zip(&reference.data) { + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } let y = luminance_2020(*output); if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { continue; @@ -174,7 +197,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> } let min = output.iter().copied().fold(f32::INFINITY, f32::min); let max = output.iter().copied().fold(0.0, f32::max); - colour += usize::from(max - min > 0.05); + colour += usize::from(max - min > min_chroma); pairs.push((input.map(f64::from), output.map(f64::from))); } (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) @@ -215,11 +238,36 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) } +/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt +/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", +/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px +/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched +/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction +/// off). Away from edges: 0.049. Issue #232. +const EDGE_CONTRAST: f32 = 3.0; + /// The photo's look: its own matrix (with and without a hue/saturation table), or the given /// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone -/// and chroma curve fitted to this photo. +/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates, +/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay +/// valid when the camera JPEG's geometry differs slightly from the raw's. fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - let (pairs, bright) = collect_pairs(sensor, reference)?; + let has_profile = colour.is_some(); + // A different picture style can make a camera profile fail the gates. Preserve the + // photo's own colour fit before resorting to the neutral fallback. + fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten()) +} + +/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on +/// the pixels away from edges (issue #232). +fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST))) +} + +fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, edge_limit: Option) -> Option { + // 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 candidates = match colour { Some(given) => vec![given], None => { @@ -270,9 +318,10 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt .sum(); if lightcraft_pipeline::profiling() { eprintln!( - "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})", (before / samples as f64).sqrt(), - (after / samples as f64).sqrt() + (after / samples as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } ); } if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { @@ -740,7 +789,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).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -767,6 +816,48 @@ mod tests { assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); } + #[test] + fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { + let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + reference.map_in_place(|p| [luminance_2020(p); 3]); + assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); + } + + #[test] + fn rejected_profile_preserves_a_usable_per_photo_fit() { + let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + *src = std::array::from_fn(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + 0.05 + (seed % 1000) as f32 * 0.0003 + }); + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); + } + let per_photo = fit_pairs(&sensor, &reference).unwrap(); + let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); + assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); + assert_eq!(look.tone, per_photo.tone); + } + #[test] fn hue_sat_passes_black_and_non_finite_pixels_through() { let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; @@ -778,9 +869,9 @@ mod tests { } #[test] - fn sony_nikon_and_panasonic_raws_get_a_file_local_look() { - assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look)); - assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look)); + fn supported_raws_get_a_file_local_look() { + assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf].into_iter().all(file_local_look)); + assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look)); } /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white @@ -818,6 +909,88 @@ mod tests { assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); } + /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is + /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate + /// (held-out RMS 0.113): it used to open grey with the neutral fallback. + #[test] + fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + } + + /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a + /// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree + /// everywhere away from the edges, and the look must still be found there. + #[test] + fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h, block, shift) = (96, 64, 8, 2); + // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. + let scene = |x: usize, y: usize| -> [f32; 3] { + let (bx, by) = (x / block, y / block); + let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; + [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] + }; + let camera = |p: [f32; 3]| { + let q = known.apply_f32(p); + let y = luminance_2020(q); + q.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, y); + // The JPEG shows the scene `shift` pixels to the right of where the raw has it. + reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); + } + } + let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); + // The look itself is right: on the aligned scene it reproduces the camera. + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let (mut error, mut n) = (0.0, 0); + for y in 0..h { + for x in 0..w { + let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); + let t = camera(scene(x, y)); + error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); + n += 3; + } + } + assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); + // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + } + + /// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes; + /// a poor reference keeps the fallback. Skips cleanly without these CC0 corpus files. + #[test] + fn corpus_raf_colour_and_white_balance() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", false)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); + let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); + let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); + assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); + assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); + assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 29efa4b8..97fb8ef2 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -1,6 +1,6 @@ //! Camera colour profiles of our own, pooled from many photos of one camera model. //! -//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera +//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera //! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a //! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate` //! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value @@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. -pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))]; +pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), + ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), +]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CameraProfile { @@ -196,10 +200,12 @@ pub struct Pool { const PAIRS_PER_FILE: usize = 4000; impl Pool { - /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or - /// NEF without colour matrices, no usable camera JPEG, too little colour). + /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW, + /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { - let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; + let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; + // colour only: geometric lens corrections would stop the sensor proxy from binning + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a8a9b448..26d6130b 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -251,6 +251,9 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { + // a library folder chosen at or below it follows too, as a source or as a filter + follow_library_folder(&mut s.library_folder, from, to); + follow_library_folder(&mut s.filter.library_folder, from, to); if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { @@ -260,6 +263,21 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { } } +/// A chosen library folder at or below `from` becomes the same place below `to`. Compared the +/// way the filter does, by folder identity, so a differently spelled choice is not widened to +/// the folder itself. +fn follow_library_folder(chosen: &mut Option, from: &str, to: &str) { + let Some(c) = chosen.as_deref() else { return }; + let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from)); + if fk.is_empty() { + return; + } + if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) { + let rest = rest.trim_start_matches('/'); + *chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) }); + } +} + /// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink /// the photos inside, as one undo step: undo renames the folder back and restores the photos' /// paths, redo repeats the move — each refused (and reported) when the destination is taken. diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 8f197429..e0aaaf95 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -2,7 +2,7 @@ use lightcraft_catalog::{Op, PhotoId, Version}; use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; -use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle}; +use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param}; @@ -37,6 +37,32 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) { if o.swaps_axes() { (h, w) } else { (w, h) } } +/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers. +fn numbers(v: Option<&Value>) -> Option> { + v?.as_array()?.iter().map(Value::as_f64).collect() +} + +/// A width × height lock stored as hundredths. Rejects non-finite, non-positive and +/// extreme (beyond 20:1) shapes, which would give an empty or unusable crop. +fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { + let ratio = x / y; + if !(x.is_finite() + && y.is_finite() + && x > 0.0 + && y > 0.0 + && ratio.is_finite() + && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&ratio)) + { + return Err(bad(c, "aspect needs two positive numbers with a ratio between 1:20 and 20:1")); + } + let to = |v: f64| (v * 100.0).round(); + let (a, b) = (to(x), to(y)); + if a < 1.0 || b < 1.0 || a > f64::from(u32::MAX) || b > f64::from(u32::MAX) { + return Err(bad(c, "aspect numbers are out of range")); + } + Ok((a as u32, b as u32)) +} + fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { d.crop.aspect = aspect; let a = aspect.map(|(aw, ah)| { @@ -395,6 +421,46 @@ pub fn specs() -> Vec { Ok(()) }) }), + cmd!( + "crop.drag", + "Drag Crop Handle", + [], + None, + "{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock", + has_active, + |s, p| { + let id = active(s, "crop.drag")?; + let (w, h) = crop_dims(s, id); + let handle = match p.get("handle") { + Some(Value::String(n)) => CropHandle::from_name(n), + Some(v) => v.as_u64().and_then(|i| u8::try_from(i).ok()).and_then(CropHandle::from_index), + None => None, + } + .ok_or_else(|| bad("crop.drag", "unknown handle"))?; + let point = |k: &str| -> Result { + match numbers(p.get(k)).as_deref() { + Some([x, y]) if x.is_finite() && y.is_finite() => Ok(Point::new(*x, *y)), + _ => Err(bad("crop.drag", format!("`{k}` needs [x, y]"))), + } + }; + let (from, to) = (point("from")?, point("to")?); + let given = match (p.get("start"), numbers(p.get("start")).as_deref()) { + (None | Some(Value::Null), _) => None, + (_, Some(&[x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => { + Some(Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1))) + } + _ => return Err(bad("crop.drag", "`start` needs 4 numbers")), + }; + edit(s, "crop.drag", "Crop", |d| { + let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle }; + // A locked crop keeps the shape it has: the lock stores a width:height tuple whose + // orientation is reinterpreted per image, so the frame itself is the truth. + let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0); + d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio); + Ok(()) + }) + } + ), cmd!( "crop.straighten", "Straighten", @@ -470,11 +536,12 @@ pub fn specs() -> Vec { let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?; let (x, y): (f64, f64) = (x.parse().map_err(|_| bad("crop.aspect", "bad number"))?, y.parse().map_err(|_| bad("crop.aspect", "bad number"))?); - Some(((x * 100.0) as u32, (y * 100.0) as u32)) - } - Some(Value::Array(a)) if a.len() == 2 => { - Some(((a[0].as_f64().unwrap_or(1.0) * 100.0) as u32, (a[1].as_f64().unwrap_or(1.0) * 100.0) as u32)) + Some(aspect_tuple("crop.aspect", x, y)?) } + Some(v @ Value::Array(_)) => match numbers(Some(v)).as_deref() { + Some(&[x, y]) => Some(aspect_tuple("crop.aspect", x, y)?), + _ => return Err(bad("crop.aspect", "use [w, h] with two numbers")), + }, _ => return Err(bad("crop.aspect", "missing aspect")), }; edit(s, "crop.aspect", "Crop Aspect", |d| { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 11a11f6c..a8b25023 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -23,7 +23,8 @@ impl Session { .into_iter() .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; - let mut out = preset.params; + // a preset saved by another version may hold keys this one doesn't know: not the caller's typo + let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); @@ -54,6 +55,8 @@ fn save(s: &mut Session, p: &Value) -> Result { if let Some(o) = params.as_object_mut() { o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); } + // a preset that app.export would refuse is refused here, where the typo is on screen + crate::export::ExportOptions::validate(ID, ¶ms)?; let preset = ExportPreset { name: name.to_string(), params }; match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = preset, diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 57632ea8..c37404b5 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1,7 +1,7 @@ //! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete, //! metadata, albums, import. -use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; +use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; @@ -66,7 +66,14 @@ pub fn import_params(s: &Session, p: &Value) -> Result { { return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); } + let on_deleted = match str_param(p, "onDeleted") { + Some(v) => { + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))? + } + None => Default::default(), + }; let opts = crate::import::ImportOptions { + on_deleted, mode, preset, keywords: strs(p, "keywords"), @@ -87,7 +94,9 @@ pub fn import_params(s: &Session, p: &Value) -> Result { /// `album`). pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { let mut report = serde_json::to_value(report).unwrap_or_default(); - let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + // photos an import restored from Recently Deleted take part like new ones (album, selection) + let imported: Vec = + ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); if album.is_none() && let Some(name) = album_name && !imported.is_empty() @@ -251,7 +260,7 @@ pub fn specs() -> Vec { "Show Source", [], None, - "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}", + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", always, |s, p| { let kind = str_param(p, "kind").unwrap_or("all"); @@ -268,6 +277,25 @@ pub fn specs() -> Vec { } LibrarySource::Album(a) } + "libraryFolder" => { + let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); + let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) { + return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); + } + s.library_folder = Some(path.to_string()); + // one folder at a time: a folder filter left over would be ignored + s.filter.library_folder = None; + LibrarySource::LibraryFolder + } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; let vis = s.visible_cloned(); @@ -282,15 +310,75 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}", + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); - s.filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let mut f = f; + // a blank folder is no folder: no hidden "filters active" state + f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); + if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { + return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); + } + s.filter = f; Ok(json!({"count": s.visible().len()})) } ), + cmd!( + query "library.folders", + "Library Folders", + [], + None, + "{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + always, + |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) + ), + cmd!( + "library.removeFolder", + "Remove Folder from Library", + [], + None, + "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", + always, + |s, p| { + const C: &str = "library.removeFolder"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + if lightcraft_catalog::query::folder_key(path).is_empty() { + return Err(bad(C, format!("{path}: not a folder"))); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad(C, "choose a folder, not the whole startup disk")); + } + if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); + } + let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + s.commit("Remove Folder from Library", Op::Batch { ops })?; + if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.filter.library_folder = None; + } + // the folder being shown is gone: back to everything, as for a deleted album + if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.library_folder = None; + if s.source == LibrarySource::LibraryFolder { + s.source = LibrarySource::All; + } + } + let vis = s.visible_cloned(); + s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + Ok(json!({"removed": ids.len()})) + } + ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); Ok(json!({"count": s.visible().len()})) @@ -356,36 +444,50 @@ pub fn specs() -> Vec { } ), // ---- selection - cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| { - let ids = ids_param(p).unwrap_or_default(); - let mode = str_param(p, "mode").unwrap_or("replace"); - s.end_interaction()?; - match mode { - "replace" => { - s.selection = - Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) }; + cmd!( + "library.select", + "Select Photos", + [], + None, + "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", + always, + |s, p| { + let ids = ids_param(p).unwrap_or_default(); + let active = p.get("active").and_then(Value::as_u64).map(PhotoId); + let mode = str_param(p, "mode").unwrap_or("replace"); + // An id that is not in the library is an error, and the selection stays as it was: + // selecting it would otherwise report success and leave the session with no usable + // active photo (#182). + if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { + return Err(bad("library.select", format!("no such photo {}", id.0))); } - "add" => { - for id in ids { - if !s.selection.contains(id) { - s.selection.ids.push(id); + s.end_interaction()?; + match mode { + "replace" => { + s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; + } + "add" => { + for id in ids { + if !s.selection.contains(id) { + s.selection.ids.push(id); + } + s.selection.active = Some(id); } - s.selection.active = Some(id); } - } - "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), - "range" => { - let vis = s.visible_cloned(); - if let Some(id) = ids.first() { - s.selection.extend_to(*id, &vis); + "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), + "range" => { + let vis = s.visible_cloned(); + if let Some(id) = ids.first() { + s.selection.extend_to(*id, &vis); + } } + other => return Err(bad("library.select", format!("unknown mode `{other}`"))), } - other => return Err(bad("library.select", format!("unknown mode `{other}`"))), + s.active_mask = None; + s.active_spot = None; + Ok(json!({"selected": s.selection.ids.len()})) } - s.active_mask = None; - s.active_spot = None; - Ok(json!({"selected": s.selection.ids.len()})) - }), + ), cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { let vis = s.visible_cloned(); s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; @@ -560,10 +662,28 @@ pub fn specs() -> Vec { id, deleted: false }))), + cmd!( + "library.emptyRecentlyDeleted", + "Empty Recently Deleted", + ["Photo"], + None, + "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", + always, + |s, _| { + let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + if ids.is_empty() { + return Ok(json!({"deleted": 0})); + } + let op = s.catalog.delete_photos_permanently_ops(&ids); + s.commit("Empty Recently Deleted", op)?; + s.selection = Selection::default(); + Ok(json!({"deleted": ids.len()})) + } + ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let t = s.targets(p); - let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::>(); - s.commit("Delete Permanently", Op::Batch { ops })?; + let op = s.catalog.delete_photos_permanently_ops(&t); + s.commit("Delete Permanently", op)?; s.selection = Selection::default(); Ok(json!({"deleted": t.len()})) }), @@ -725,8 +845,16 @@ pub fn specs() -> Vec { let rules = if bool_or(p, "fromView", false) { view_rules(s) } else { - let base = if bool_or(p, "replace", false) { Default::default() } else { cur }; - merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")? + let replace = bool_or(p, "replace", false); + let folder = cur.library_folder.clone(); + let base = if replace { Default::default() } else { cur }; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?; + // the rules dialog has no folder field: replacing its rules keeps the folder + // unless the call says (`libraryFolder: null`) to drop it + if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { + r.library_folder = folder; + } + r }; s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; Ok(json!({"count": s.catalog.album_count(id)})) @@ -956,7 +1084,7 @@ pub fn specs() -> Vec { "Import Photos", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", always, |s, p| { let req = import_params(s, p)?; @@ -967,9 +1095,12 @@ pub fn specs() -> Vec { let report = crate::import::import_with(s, &req.paths, &req.opts)?; let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; // one undo step for the whole import - let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; + let (imported, restored) = (ids_of("imported"), ids_of("restored")); let n = s.undo.len().saturating_sub(undo0); - s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" })); + let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; + s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); + let imported = if imported.is_empty() { restored } else { imported }; if let Some(f) = imported.first() { s.selection = Selection::single(PhotoId(*f)); } @@ -1051,6 +1182,16 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res Ok(f) } +/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup +/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of +/// one disk but a way to reach several. +fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { + ids.iter() + .filter_map(|id| s.catalog.photo(*id)) + .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) + .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) +} + /// The current view (source + filter) as smart-album rules. Viewing a smart album starts from /// its rules with the filter bar's settings on top. fn view_rules(s: &Session) -> lightcraft_catalog::Filter { @@ -1077,6 +1218,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter { None => { let mut f = s.source.to_filter(&s.filter, &s.catalog); f.deleted = false; + if s.source == LibrarySource::LibraryFolder { + f.library_folder = s.library_folder.clone(); + } f } } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index e7ffde14..824039e4 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{CONTROLS, controls}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, has_active}; -use crate::Session; +use crate::{LibrarySource, Session}; pub fn photo_summary(p: &Photo) -> Value { json!({ @@ -48,8 +48,13 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va v } +/// The photo a query is about: `id` when given (which must be in the library), else the active one. fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { - p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo")) + match p.get("id").and_then(Value::as_u64).map(PhotoId) { + Some(id) if s.catalog.photo(id).is_some() => Ok(id), + Some(id) => Err(bad(c, format!("no such photo {}", id.0))), + None => s.active().ok_or_else(|| bad(c, "no photo")), + } } pub fn specs() -> Vec { @@ -81,10 +86,14 @@ pub fn specs() -> Vec { })) }), cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| { - let label = s.source.label(&s.catalog); + let label = match (s.source, s.library_folder.as_deref()) { + (LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), + _ => s.source.label(&s.catalog), + }; let n = s.visible().len(); Ok(json!({ "source": s.source, + "libraryFolder": s.library_folder.as_ref().filter(|_| s.source == LibrarySource::LibraryFolder), "sourceLabel": label, "filter": s.filter, "sort": s.sort, diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6affb8b4..218baa30 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -258,6 +258,35 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec, + data: &harfrust::ShaperData, + ch: char, + px: f32, + left: f32, + top: f32, +) -> Option { + use ab_glyph::{Font, ScaleFont}; + let face = harfrust::FontRef::new(font.font_data()).ok()?; + let mut buf = harfrust::UnicodeBuffer::new(); + buf.add(ch, 0); + buf.set_direction(harfrust::Direction::TopToBottom); + buf.guess_segment_properties(); + let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); + if shaped.glyph_infos().len() != 1 { + return None; + } + let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?; + let pos = shaped.glyph_positions().first()?; + let sf = font.as_scaled(px); + let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); + let height = -(pos.y_advance as f32) * sy; + let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy); + Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)) +} + /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { let width = img.width; @@ -312,6 +341,11 @@ fn watermark_coverage( } let fonts = watermark_fonts(craft); let Some(latin) = fonts.first() else { return }; + let vertical_data: Vec<_> = if wm.vertical { + fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() + } else { + Vec::new() + }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); let mut glyphs = Vec::new(); @@ -335,6 +369,21 @@ fn watermark_coverage( prev = None; continue; } + if wm.vertical + && matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60) + && let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0) + && let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face)) + { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y) + && (glyph.id != font.glyph_id(original) + || !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】')) + { + glyphs.push((face, glyph, None)); + y += px; + continue; + } + } let vertical_form = match original { '、' => '︑', '。' => '︒', @@ -562,12 +611,264 @@ impl Default for ExportOptions { } } +/// The `app.export` params that are not export options: which photos, where to, a preset to start +/// from, and the desktop app's background flag. +pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; + +/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). +pub const OPTION_PARAMS: &[&str] = &[ + "format", + "quality", + "resize", + "longEdge", + "shortEdge", + "width", + "height", + "megapixels", + "percent", + "dontEnlarge", + "ppi", + "limitKb", + "sharpen", + "sharpenAmount", + "naming", + "startNumber", + "subfolder", + "conflict", + "tiffCompression", + "dngCompression", + "metadata", + "removeLocation", + "watermark", + "colorSpace", + "bitDepth", +]; + +/// The keys of a `watermark` object ([`Watermark`], camelCase). +pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"]; + +/// Text height as a fraction of the short edge: the range the renderer draws without clamping. +pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); +/// Margin as a fraction of the short edge. +pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); +/// A graphic's width as a fraction of the photo's width. +pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); + +/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). +fn edit_distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1; b.len() + 1]; + for (j, cb) in b.iter().enumerate() { + let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); + let del = prev.get(j + 1).copied().unwrap_or(0) + 1; + let ins = cur.get(j).copied().unwrap_or(0) + 1; + if let Some(c) = cur.get_mut(j + 1) { + *c = sub.min(del).min(ins); + } + } + prev = cur; + } + prev.last().copied().unwrap_or(0) +} + +/// `unknown parameter \`k\``, naming the closest known key when one is near. +fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { + let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); + match near { + Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), + None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), + } +} + +/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. +fn whole(v: &serde_json::Value) -> Option { + v.as_i64() +} + impl ExportOptions { + /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or + /// newer version may have written and this one doesn't know dropped, also inside `watermark`. + /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are + /// not the caller's typo, and refusing them would make a preset or Export with Previous fail + /// for good after an update. + pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); + if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { + w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); + } + } + p + } + + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an + /// unknown parameter or a value that cannot be read as what it is for is an error, never a + /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) + /// are accepted and left to the caller. + pub fn from_params(p: &serde_json::Value) -> crate::Result { + Self::validate("app.export", p)?; + Ok(Self::from_json(p)) + } + + /// Check `app.export` params for `cmd` (the command named in the error): every key must be an + /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the + /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] + /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. + pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; + let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); + let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { + whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) + }; + let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { + v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) + }; + fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { + v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) + } + let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; + let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { + 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("|")))) } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "format" => { + if ExportFormat::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original"))); + } + } + "quality" => { + int(k, v, 1, 100)?; + } + "ppi" => { + int(k, v, 1, u16::MAX as i64)?; + } + "limitKb" => { + int(k, v, 0, u32::MAX as i64)?; + } + "startNumber" => { + int(k, v, 0, 999_999)?; + } + "bitDepth" => { + if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { + return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); + } + } + "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { + num(k, v, 0.0, 1.0e9)?; + } + "resize" => { + let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; + if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { + return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); + } + serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; + } + "dontEnlarge" | "removeLocation" | "background" => { + boolean(k, v)?; + } + "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"])?, + "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, + "dngCompression" => { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + } + "metadata" => one_of(k, v, &["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"))); + } + } + "watermark" => Self::validate_watermark(cmd, v)?, + "id" => { + int(k, v, 0, i64::MAX)?; + } + "ids" => { + let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); + if !ok { + return Err(bad(format!("`{k}` must be an array of photo ids"))); + } + } + _ => return Err(bad(unknown_key("parameter", k, &known))), + } + } + Ok(()) + } + + /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). + fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let obj = match v { + Value::String(_) => return Ok(()), + Value::Object(o) => o, + _ => { + return Err(bad( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(), + )); + } + }; + let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { + match v.as_f64() { + Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), + _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), + } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "text" | "image" => { + v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; + } + "vertical" | "shadow" => { + v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; + } + "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, + "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, + "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, + "anchor" => { + serde_json::from_value::(v.clone()).map_err(|_| { + bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) + })?; + } + "color" => { + let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); + if !ok { + return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); + } + } + _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), + } + } + serde_json::from_value::(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + } + /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus /// `dontEnlarge` (default true). + /// + /// Lenient: anything unknown or unreadable falls back to the default, which suits stored + /// prefs and dialog prefill. A command that is about to export uses + /// [`ExportOptions::from_params`], which validates first. pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -1392,6 +1693,49 @@ mod tests { } } + #[test] + 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"); + return; + }; + let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); + // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. + assert_eq!(font.glyph_id('︑').0, 0); + assert_eq!(font.glyph_id('︒').0, 0); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); + for ch in ['、', '。'] { + let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap(); + assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); + let rect = font.outline_glyph(glyph).unwrap().px_bounds(); + assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); + } + let craft = Box::leak( + vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), + ); + let wm = Watermark { + text: "、。".into(), + vertical: true, + size: 0.1, + anchor: Anchor::TopLeft, + inset: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = Vec::new(); + watermark_coverage(400, 300, &wm, craft, |x, y, k, _| { + if k > 0.5 { + pixels.push((x, y)); + } + }); + assert!(!pixels.is_empty()); + for (x, y) in pixels { + assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); + } + } + #[test] fn japanese_watermarks_support_vertical_columns() { use ab_glyph::Font; @@ -1720,4 +2064,99 @@ mod tests { Err(e) => assert!(e.contains("not available"), "{e}"), } } + + /// Issue #181: a command about to export refuses what `from_json` would silently default — + /// unknown keys (naming the nearest known one) and values of the wrong kind or range. + #[test] + fn export_params_are_validated() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + let e = err(json!({"longEdgee": 400})); + assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); + assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); + assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); + assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); + assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); + assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); + assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); + assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); + assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); + assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); + assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); + assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); + assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); + assert!(err(json!("jpeg")).contains("params must be an object")); + assert!( + ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) + .unwrap_err() + .to_string() + .starts_with("invalid parameters for `export.savePreset`") + ); + // everything the presets, the dialog, Export with Previous and `render --opt` send passes + for p in builtin_presets() { + ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); + } + let full = ExportOptions { + resize: Some(Resize::default()), + limit_kb: Some(500), + watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + bit_depth: Some(16), + ..Default::default() + }; + let mut j = full.to_json(); + for (k, v) in [ + ("dir", json!("/tmp/out")), + ("background", json!(true)), + ("preset", json!("x")), + ("ids", json!([1, 2])), + ("id", json!(1)), + ("path", json!("a.jpg")), + ] { + j[k] = v; + } + assert_eq!(ExportOptions::from_params(&j).unwrap(), full); + assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); + // null is "not given", as a dropped key is + ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); + } + + /// 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] + fn watermark_params_are_validated() { + use serde_json::json; + let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + err(json!(5)) + .contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}") + ); + assert!( + err(json!({"text": "x", "bogus": 1})) + .contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)") + ); + assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); + for size in [json!(3), json!(0.5001), json!(0), json!("big")] { + let e = err(json!({"text": "x", "size": size})); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); + } + assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); + assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); + assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); + assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); + assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); + assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); + assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); + // the documented shapes pass and read as before + let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); + assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); + let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); + let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); + assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); + assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); + // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges + assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); + assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 62a1c467..7162a04f 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -43,8 +43,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } -/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for -/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the +/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the +/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; use lightcraft_geom::Point; @@ -87,10 +88,6 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option bool { - matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. }) -} - fn ext_upper(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() } @@ -224,13 +221,14 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< } Err(e) => return Err(e.to_string()), }; + // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in + // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). + let lens = embedded_lens(&raw.info()); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let xy = lightcraft_raw::color::as_shot_white_xy(&raw); let t = lightcraft_raw::color::camera_transform(&raw, xy); let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t); drop(bytes); - // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. - let lens = embedded_lens(&raw.info()); - raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. let t0 = web_time::Instant::now(); @@ -320,6 +318,21 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) } +/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting. +pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option { + let jpeg = lightcraft_raw::embedded_preview(bytes)?; + match lightcraft_raw::embedded_preview_color_space(bytes) { + Some(space) => { + let space = match space { + lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, + lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, + }; + lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() + } + None => lightcraft_codecs::decode(&jpeg, opts).ok(), + } +} + /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { let jpeg = lightcraft_raw::embedded_preview(bytes)?; @@ -333,8 +346,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { /// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?; + let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?; let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) @@ -343,8 +355,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S /// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the /// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; - let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?; + let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?; if d.image.width.max(d.image.height) > max_edge { d.image = fit(&d.image, max_edge, max_edge, Filter::Box); d.alpha = None; @@ -402,6 +413,131 @@ mod tests { use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG + /// mostly cancel, edges remain. + fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { + let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); + let mut out = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w { + let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); + let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); + out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; + } + } + out + } + + /// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its + /// normalised cross-correlation, or `None` when the patch has too little detail to tell. + fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { + let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); + let na = pa.iter().map(|v| v * v).sum::().sqrt(); + if na < 1e-3 * p as f32 { + return None; + } + let mut best = (f32::MIN, (0, 0)); + for dy in -r..=r { + for dx in -r..=r { + let (bx, by) = (x as i64 + dx, y as i64 + dy); + let (mut dot, mut nb) = (0.0f32, 0.0f32); + for i in 0..p { + for j in 0..p { + let v = b[(by as usize + i) * w + bx as usize + j]; + dot += pa[i * p + j] * v; + nb += v * v; + } + } + let c = dot / (na * nb.sqrt()).max(1e-12); + if c > best.0 { + best = (c, (dx, dy)); + } + } + } + Some((best.1, best.0)) + } + + /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the + /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile + /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide + /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. + #[test] + fn corpus_rw2_distortion_matches_the_camera_jpeg() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); + assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); + + // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) + let files = [ + ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners + ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % + ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % + ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % + ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction + ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % + ]; + const EDGE: usize = 640; + const P: usize = 64; + const R: i64 = 12; + for (name, off_at_least) in files { + let bytes = std::fs::read(dir.join(name)).unwrap(); + let probed = probe_bytes(name, &bytes).unwrap(); + assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); + let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); + assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); + // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` + let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); + assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); + let mut s = lightcraft_develop::DevelopSettings::default(); + let render = |s: &lightcraft_develop::DevelopSettings| { + lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image + }; + let before = render(&s); + s.optics.lens_profile = true; + let after = render(&s); + let (w, h) = (after.width, after.height); + assert_eq!((before.width, before.height), (w, h)); + // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre + let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); + let (cw, ch) = (cam.width as f64, cam.height as f64); + let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; + let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); + let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); + let lin = |v: u8| (v as f32 / 255.0).powf(2.2); + let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { + let p = im.data[y * im.width + x]; + 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) + }; + let reference = detail(w, h, |x, y| { + let p = cam.data[y * w + x]; + 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] + }); + let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); + let m = R as usize + 4; + let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; + let mut measured = 0; + for c in corners { + let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; + measured += 1; + assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); + if off_at_least > 0 + && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) + { + let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; + assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); + } + } + assert!(measured >= 2, "{name}: only {measured} corners with detail"); + } + } + /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. fn cr3_with_preview(w: u32, h: u32) -> Vec { let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 4895eee7..5df94988 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -48,9 +48,34 @@ pub enum ImportMode { Move, } +/// What an import does with a file the library already has in Recently Deleted. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OnDeleted { + /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). + #[default] + Skip, + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]). + Restore, + /// Delete the trashed record permanently and import the file as a new photo. + Fresh, +} + +impl OnDeleted { + pub fn parse(s: &str) -> Option { + match s { + "skip" => Some(Self::Skip), + "restore" => Some(Self::Restore), + "fresh" => Some(Self::Fresh), + _ => None, + } + } +} + /// What an import does besides adding the photos. #[derive(Clone, Debug, Default)] pub struct ImportOptions { + /// A file that is in Recently Deleted: skip (default), restore, or import afresh. + pub on_deleted: OnDeleted, pub mode: ImportMode, /// Applied to every imported photo (one History entry). pub preset: Option, @@ -147,12 +172,18 @@ pub struct Duplicate { pub existing: Option, /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). pub reason: &'static str, + /// That photo is in Recently Deleted (see [`OnDeleted`]). + #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] + pub existing_deleted: bool, } #[derive(Clone, Debug, Default, Serialize)] pub struct ImportReport { pub imported: Vec, pub duplicates: Vec, + /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub restored: Vec, /// Move: the originals that were moved (and removed from the source). #[serde(skip_serializing_if = "Vec::is_empty")] pub moved: Vec, @@ -638,6 +669,10 @@ impl ImportJob { let mut by_path = HashMap::new(); let mut by_hash = HashMap::new(); for p in s.catalog.photos() { + // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate + if p.deleted && opts.on_deleted == OnDeleted::Fresh { + continue; + } if let Source::File { path } = &p.source { by_path.insert(path.clone(), (p.id, p.local)); } @@ -840,6 +875,20 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare let mut placed: Vec = Vec::new(); // photos added by this batch, by content (a duplicate of a file earlier in the import names it) let mut new_hash: HashMap = HashMap::new(); + // Fresh: trashed photos a new import replaces (by content or by file), each removed once + let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); + if opts.on_deleted == OnDeleted::Fresh { + for p in s.catalog.photos().filter(|p| p.deleted) { + if let Some(h) = &p.content_hash { + trash_hash.insert(h.clone(), p.id); + } + if let Source::File { path } = &p.source { + trash_path.insert(path.clone(), p.id); + } + } + } + let mut purge: Vec = Vec::new(); + let mut purged: std::collections::HashSet = Default::default(); for it in prepared.items { match it { PreparedItem::Promote(id) => { @@ -853,7 +902,16 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare let h = hash?; new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) }); - report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason }); + let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); + match trashed { + Some(id) if opts.on_deleted == OnDeleted::Restore => { + if !report.restored.contains(&id.0) { + ops.push(Op::SetDeleted { id, deleted: false }); + report.restored.push(id.0); + } + } + _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), + } } PreparedItem::Failed(path, e) => report.failed.push((path, e)), PreparedItem::Kept(k) => report.kept.push(k), @@ -864,6 +922,11 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare if let Some(h) = &info.content_hash { new_hash.insert(h.clone(), id); } + for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { + if purged.insert(*old) { + purge.push(*old); + } + } let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); // a copy is catalogued under its new name let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); @@ -907,9 +970,18 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare } } } + // (built against the catalog as it is: one op list for all of them, ahead of the additions) + if !purge.is_empty() { + ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); + } let log0 = s.pending_log.len(); + let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { + ("Restore", report.restored.len()) + } else { + ("Add", report.imported.len().max(1)) + }; if !ops.is_empty() - && let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops }) + && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) { // nothing was catalogued: take the moves back (their sources were never touched) placed.iter().for_each(crate::import_move::rollback); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ec39222a..7506068e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ pub mod guard; pub mod import; mod import_move; pub mod library; +pub mod logging; pub mod media; pub mod memory; pub mod merge; @@ -46,7 +47,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort}; use lightcraft_develop::DevelopSettings; pub use media::{RenderJob, SourceLevel}; use serde_json::Value; -pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips}; +pub use view::{Browse, FilterChip, LibrarySource, Selection, SelectionState, filter_chips}; pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline}; #[derive(Debug, thiserror::Error)] @@ -134,6 +135,9 @@ pub struct Session { pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, + /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported + /// from it and from the folders inside it. + pub library_folder: Option, /// Copied metadata (`photo.copyMetadata`): photo.setMeta params. pub meta_clipboard: Option, /// The photo that was active before the current one (Paste Settings from Previous). @@ -234,6 +238,7 @@ impl Session { clipboard: None, meta_clipboard: None, browse: None, + library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), @@ -590,7 +595,8 @@ impl Session { /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { - let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse)); + let mut key = + (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); @@ -605,7 +611,18 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + if self.source == LibrarySource::LibraryFolder { + // no folder chosen: nothing (`.` names no folder) + f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); + } let mut visible = self.catalog.query(&f, &self.sort); + if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { + // the shown folder lost its last photo (deleted, moved, removed): everything, not + // an empty grid under the name of a folder that is gone from the sidebar + self.source = LibrarySource::All; + self.library_folder = None; + return self.visible(); + } if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source @@ -642,13 +659,20 @@ impl Session { &self.visible } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source + /// shows. + fn folder_holds_photos(&self) -> bool { + let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() }; + self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty() + } + /// Photos in the current source (folder, album, …) before the filter bar and search narrow /// them; `None` where that is not a separate number (Missing Photos). pub fn source_total(&mut self) -> Option { if self.source == LibrarySource::Missing { return None; } - let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse)); + let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { @@ -656,6 +680,9 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + if self.source == LibrarySource::LibraryFolder { + f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); + } let n = self.catalog.query(&f, &self.sort).len(); self.total = Some((key, n)); } @@ -709,6 +736,8 @@ mod tests_color; #[cfg(test)] mod tests_export; #[cfg(test)] +mod tests_folders; +#[cfg(test)] mod tests_forget_local; #[cfg(test)] mod tests_import; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6a63e378..3e89b824 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -102,6 +102,8 @@ struct PresetsFile { struct ViewFile { source: LibrarySource, browse: Option, + /// The folder a `libraryFolder` source shows. + library_folder: Option, // No filter: a library opens unfiltered. A date, keyword or person left over from the last session // would silently hide photos, with only a small badge to say so. sort: lightcraft_catalog::Sort, @@ -309,25 +311,12 @@ impl Session { self.pending_log.clear(); self.selection = Selection::default(); self.source = LibrarySource::All; + self.library_folder = None; if report.created && seed_demo { crate::demo::load(self); journal.snapshot(&self.catalog)?; } let mut settings = SettingsLoad::default(); - // presets - if let Some(f) = settings.read::(files.as_mut(), "presets.json") { - for p in &mut self.presets { - p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); - } - for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { - self.presets.push(u); - } - } - let known = |id: &String| crate::presets::profile(id).is_some(); - self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); - self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); - } // preferences let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; @@ -349,10 +338,34 @@ impl Session { if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); } + // presets + if let Some(f) = settings.read::(files.as_mut(), "presets.json") { + for p in &mut self.presets { + p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); + } + for u in f.user { + if !self.presets.iter().any(|p| p.id == u.id) { + self.presets.push(u); + } + } + let known = |id: &String| crate::presets::profile(id).is_some() || self.lut_profiles.iter().any(|p| &p.id == id); + self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); + self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); + } // view state if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; self.browse = v.browse; + self.library_folder = v.library_folder.filter(|f| !f.trim().is_empty()); + if self.source == LibrarySource::LibraryFolder { + // a folder that is gone (or none): everything, not an empty grid + let f = lightcraft_catalog::Filter { library_folder: self.library_folder.clone(), ..Default::default() }; + let any = self.library_folder.is_some() && !self.catalog.query(&f, &lightcraft_catalog::Sort::default()).is_empty(); + if !any { + self.source = LibrarySource::All; + self.library_folder = None; + } + } self.sort = v.sort; self.selection = v.selection; self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); @@ -500,7 +513,14 @@ impl Session { } fn view_json(&self) -> Vec { - let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() }; + let view = ViewFile { + source: self.source, + browse: self.browse.clone(), + // only while it is shown: a leftover would be a stale choice nobody made + library_folder: self.library_folder.clone().filter(|_| self.source == LibrarySource::LibraryFolder), + sort: self.sort, + selection: self.selection.clone(), + }; serde_json::to_vec_pretty(&view).unwrap_or_default() } diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs new file mode 100644 index 00000000..380bf61c --- /dev/null +++ b/crates/engine/src/logging.rs @@ -0,0 +1,118 @@ +//! `log` records on stderr for the native desktop app and CLI (#168). The web build installs +//! eframe's `WebLogger` instead. +//! +//! Warnings and errors are shown by default. `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level +//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `LIGHTCRAFT_LOG` wins when both are set. +//! `RUST_LOG` also accepts per-target directives (`lightcraft=debug,wgpu=warn`): a bare level +//! applies to everything, and the most verbose `lightcraft…=level` directive sets LightCraft's. +//! Below warnings only LightCraft's own records are shown, so `info` and `debug` aren't drowned +//! out by the GPU and windowing libraries. + +use log::{Level, LevelFilter, Metadata, Record}; + +/// Records shown at a level, written to stderr as `: LEVEL target: message`. +pub struct StderrLog { + prefix: &'static str, + level: LevelFilter, +} + +impl StderrLog { + pub fn new(prefix: &'static str, level: LevelFilter) -> Self { + Self { prefix, level } + } +} + +impl log::Log for StderrLog { + fn enabled(&self, m: &Metadata) -> bool { + m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with("lightcraft")) + } + fn log(&self, r: &Record) { + if self.enabled(r.metadata()) { + eprintln!("{}: {} {}: {}", self.prefix, r.level(), r.target(), r.args()); + } + } + fn flush(&self) {} +} + +fn parse_level(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "off" | "none" => Some(LevelFilter::Off), + "error" => Some(LevelFilter::Error), + "warn" | "warning" => Some(LevelFilter::Warn), + "info" => Some(LevelFilter::Info), + "debug" => Some(LevelFilter::Debug), + "trace" => Some(LevelFilter::Trace), + _ => None, + } +} + +/// The level that `LIGHTCRAFT_LOG` / `RUST_LOG` ask for; warnings when neither names one. +pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter { + if let Some(l) = lightcraft_log.and_then(parse_level) { + return l; + } + let Some(rust_log) = rust_log else { return LevelFilter::Warn }; + let mut bare = None; + let mut ours: Option = None; + for directive in rust_log.split(',') { + match directive.split_once('=') { + None => bare = parse_level(directive).or(bare), + Some((target, level)) if target.trim().starts_with("lightcraft") => { + if let Some(l) = parse_level(level) { + ours = Some(ours.map_or(l, |o| o.max(l))); + } + } + Some(_) => {} + } + } + ours.or(bare).unwrap_or(LevelFilter::Warn) +} + +/// Install the stderr logger at the level the environment asks for. Does nothing when a logger is +/// already installed. +pub fn install(prefix: &'static str) { + let level = level_from(std::env::var("LIGHTCRAFT_LOG").ok().as_deref(), std::env::var("RUST_LOG").ok().as_deref()); + static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new(); + if log::set_logger(LOGGER.get_or_init(|| StderrLog::new(prefix, level))).is_ok() { + log::set_max_level(level); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + + #[test] + fn warnings_by_default_and_either_variable_picks_the_level() { + assert_eq!(level_from(None, None), LevelFilter::Warn); + assert_eq!(level_from(None, Some("")), LevelFilter::Warn); + assert_eq!(level_from(None, Some("warn")), LevelFilter::Warn); + assert_eq!(level_from(None, Some("TRACE")), LevelFilter::Trace); + assert_eq!(level_from(None, Some("off")), LevelFilter::Off); + assert_eq!(level_from(Some("info"), None), LevelFilter::Info); + assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "LIGHTCRAFT_LOG wins"); + assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown LIGHTCRAFT_LOG is ignored"); + } + + #[test] + fn rust_log_directives_set_lightcraft_s_level() { + assert_eq!(level_from(None, Some("lightcraft=debug,wgpu=warn")), LevelFilter::Debug); + assert_eq!(level_from(None, Some("wgpu_core=trace")), LevelFilter::Warn, "other crates' directives don't apply"); + assert_eq!(level_from(None, Some("error,lightcraft_gpu=info")), LevelFilter::Info); + assert_eq!(level_from(None, Some("lightcraft_gpu=info,lightcraft_engine=trace")), LevelFilter::Trace); + assert_eq!(level_from(None, Some("info,naga=off")), LevelFilter::Info); + } + + #[test] + fn only_lightcraft_records_below_warnings() { + let log = StderrLog::new("test", LevelFilter::Debug); + let meta = |level, target| Metadata::builder().level(level).target(target).build(); + assert!(log.enabled(&meta(Level::Warn, "wgpu_core::device"))); + assert!(log.enabled(&meta(Level::Debug, "lightcraft_gpu::ctx"))); + assert!(!log.enabled(&meta(Level::Info, "wgpu_core::device"))); + assert!(!log.enabled(&meta(Level::Trace, "lightcraft_gpu::ctx"))); + let quiet = StderrLog::new("test", LevelFilter::Off); + assert!(!quiet.enabled(&meta(Level::Error, "lightcraft_engine"))); + } +} diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 76a9bf1a..7aafbf34 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 11; +pub const RENDER_CACHE_VERSION: u64 = 12; /// 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]; diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 57a82ac5..ec63cc23 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -446,6 +446,14 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> { pub const RECENT_PROFILES: usize = 5; impl Session { + /// A profile by id, looking up built-in profiles first and imported LUT profiles second. + pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> { + if let Some(p) = profile(id) { + return Some((p.name, p.group)); + } + self.lut_profiles.iter().find(|p| p.id == id).map(|p| (p.name.as_str(), p.group.as_str())) + } + /// Remember `id` as the most recently applied profile. pub fn note_profile_used(&mut self, id: &str) { self.profile_recent.retain(|p| p != id); diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index 3cf14712..75e3d11b 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -262,7 +262,7 @@ impl Segmenter { if mirrors.is_empty() { return Err(lightcraft_segment::fetch::no_mirrors_message()); } - self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default()) + self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options()) } #[cfg(not(feature = "sam"))] { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d005d508..0dd76887 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,24 +14,27 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { fn changing_one_wb_control_resolves_as_shot_without_stale_tint() { use lightcraft_catalog::{Photo, PhotoId, Source}; use lightcraft_develop::{DevelopSettings, WbMode}; - let mut s = demo(); - let id = PhotoId(100); - let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, ""); - // A catalog made by the old generic-matrix Kelvin inference. - p.as_shot_wb = Some((6829.0, -127.0)); - p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0)); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap(); - s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap(); - let d = active_dev(&s); - assert_eq!(d.wb.mode, WbMode::Custom); - assert_eq!(d.wb.temp, 8000.0); - assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference"); - s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); - s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap(); - assert_eq!(active_dev(&s).wb.temp, 6500.0); - s.execute("develop.reset", &json!({})).unwrap(); - assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0)); + for format in ["ARW", "NEF", "NRW", "RAF"] { + let mut s = demo(); + let id = PhotoId(100); + let name = format!("synthetic.{format}"); + let mut p = Photo::new(id, Source::File { path: name.clone() }, &name, format, 16, 16, ""); + // A catalog made by the old generic-matrix Kelvin inference. + p.as_shot_wb = Some((6829.0, -127.0)); + p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0)); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap(); + let d = active_dev(&s); + assert_eq!(d.wb.mode, WbMode::Custom); + assert_eq!(d.wb.temp, 8000.0); + assert_eq!(d.wb.tint, 0.0, "{format}: untouched tint comes from the current as-shot reference"); + s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap(); + assert_eq!(active_dev(&s).wb.temp, 6500.0, "{format}"); + s.execute("develop.reset", &json!({})).unwrap(); + assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0), "{format}"); + } } #[test] @@ -382,6 +385,61 @@ fn crop_aspect_lock_current_and_toggle() { assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks"); } +// Feature: dragging crop handles (issue 295) +#[test] +fn crop_drag_stops_at_the_image_edge_and_keeps_the_anchor() { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": [0.2, 0.2, 0.8, 0.8]})).unwrap(); + s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [1.4, 0.7]})).unwrap(); + let r = active_dev(&s).crop.geometry.rect; + assert!((r.x0 - 0.2).abs() < 1e-6 && (r.y0 - 0.2).abs() < 1e-6, "anchor moved: {r:?}"); + assert!((r.x1 - 1.0).abs() < 1e-6 && (r.y1 - 0.7).abs() < 1e-6, "{r:?}"); +} + +#[test] +fn crop_drag_keeps_a_locked_ratio_on_every_handle() { + let mut s = demo(); + s.execute("crop.aspect", &json!({"aspect": "16x9"})).unwrap(); + let d = active_dev(&s); + assert!(d.crop.aspect.is_some()); + let want = d.crop.geometry.rect.aspect(); + let r = d.crop.geometry.rect; + for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] { + s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap(); + let g = active_dev(&s).crop.geometry.rect; + assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect()); + } +} + +#[test] +fn crop_drag_rejects_unknown_handles_and_bad_points() { + let mut s = demo(); + assert!(s.execute("crop.drag", &json!({"handle": "sideways", "from": [0.5, 0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "to": [0.6, 0.6]})).is_err()); +} + +#[test] +fn crop_aspect_rejects_degenerate_custom_ratios() { + let mut s = demo(); + let before = active_dev(&s).crop; + for bad in [json!([0.004, 3]), json!([3, 0]), json!([-1, 2]), json!([1000, 0.01]), json!(["a", 2]), json!([1, 2, 3])] { + assert!(s.execute("crop.aspect", &json!({"aspect": bad.clone()})).is_err(), "{bad}"); + } + assert!(s.execute("crop.aspect", &json!({"aspect": "0x5"})).is_err()); + let after = active_dev(&s).crop; + assert_eq!(before.geometry, after.geometry, "a rejected ratio leaves the crop alone"); + s.execute("crop.aspect", &json!({"aspect": [2.5, 1]})).unwrap(); + assert!(!active_dev(&s).crop.geometry.rect.is_empty()); +} + +#[test] +fn crop_drag_rejects_non_numeric_points() { + let mut s = demo(); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": ["a", 0.5, 0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5, 0.5], "to": [0.6, 0.6], "start": [0.1, "x", 0.5, 0.5]})).is_err()); +} + #[test] fn paste_from_previous_and_copy_paste_metadata() { let mut s = demo(); @@ -817,3 +875,53 @@ fn face_job_follows_rotate_right() { let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); } + +/// Selecting an id that is not in the library is an error, and the selection stays as it was +/// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo). +#[test] +fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() { + let mut s = demo(); + let vis = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap(); + let before = s.selection.clone(); + for p in [ + json!({"ids": [9999]}), + json!({"ids": [9999], "active": 9999}), + json!({"ids": [vis[1].0], "active": 9999}), + json!({"ids": [vis[1].0, 9999]}), + json!({"ids": [9999], "mode": "add"}), + json!({"ids": [9999], "mode": "toggle"}), + json!({"ids": [9999], "mode": "range"}), + ] { + let e = s.execute("library.select", &p).unwrap_err().to_string(); + assert!(e.contains("no such photo 9999"), "{p}: {e}"); + assert_eq!(s.selection, before, "{p}"); + } + assert_eq!(s.active(), Some(vis[0])); + assert!(s.execute("develop.get", &json!({})).is_ok()); + // queries about an explicit id check it too + for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] { + let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string(); + assert!(e.contains("no such photo 9999"), "{c}: {e}"); + assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}"); + } + // an empty selection is still allowed + assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0); +} + +/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps +/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181). +#[test] +fn saved_presets_with_unknown_keys_still_export() { + use crate::export::{ExportOptions, ExportPreset}; + let mut s = demo(); + s.export_presets.push(ExportPreset { + name: "Old".into(), + params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}), + }); + let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!(o.format, crate::export::ExportFormat::Png); + assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string())); + assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err()); +} diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs new file mode 100644 index 00000000..6dd5fe22 --- /dev/null +++ b/crates/engine/src/tests_folders.rs @@ -0,0 +1,450 @@ +//! Exploring the library by folder: `library.folders` lists where the imported photos live and +//! `library.filter {libraryFolder}` shows one folder's photos (see `lightcraft_catalog::folders`); +//! `library.removeFolder` takes a folder's photos out of the library. +//! +//! * Given photos imported from two folders, an agent can list them with counts, then choose one +//! and see exactly its photos; the filter shows as a chip that clears just that choice. +//! * A path that is not a library folder shows nothing rather than everything. +//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and +//! leaves every file where it is. +//! * Renaming or moving a folder on disk keeps the chosen folder chosen. + +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::json; + +use crate::{LibrarySource, Session, filter_chips}; + +fn source_folder(s: &mut Session, path: &str) -> serde_json::Value { + s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap() +} + +fn add(s: &mut Session, path: &str) { + let id = s.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); +} + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-folders-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn session() -> Session { + let mut s = Session::new(); + for path in ["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"] { + add(&mut s, path); + } + s +} + +#[test] +fn the_folders_command_lists_where_the_photos_were_imported_from() { + let mut s = session(); + let r = s.execute("library.folders", &json!({})).unwrap(); + assert_eq!((r[0]["name"].as_str(), r[0]["volume"].as_bool(), r[0]["count"].as_u64()), (Some("/"), Some(true), Some(3))); + let pics = &r[0]["children"][0]; + assert_eq!(pics["name"], "pics"); + let kids: Vec<(&str, u64)> = + pics["children"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["count"].as_u64().unwrap())).collect(); + assert_eq!(kids, [("home", 1), ("trip", 2)]); +} + +#[test] +fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() { + let mut s = session(); + assert_eq!(s.visible().len(), 3); + let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); + assert_eq!(r["count"], 2); + let chips = filter_chips(&s.filter, &s.catalog); + assert_eq!(chips.len(), 1); + assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart"); + s.execute("library.filter", &chips[0].clear).unwrap(); + assert_eq!(s.visible().len(), 3, "clearing the chip shows everything again"); +} + +#[test] +fn a_path_that_is_not_a_library_folder_shows_nothing() { + let mut s = session(); + let r = s.execute("library.filter", &json!({"libraryFolder": "/elsewhere"})).unwrap(); + assert_eq!(r["count"], 0); +} + +#[test] +fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_back() { + let mut s = session(); + let r = s.execute("library.removeFolder", &json!({"path": "/pics//trip/"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(s.visible().len(), 1, "only /pics/home is left"); + let tree = s.execute("library.folders", &json!({})).unwrap(); + assert_eq!(tree[0]["count"], 1); + s.source = LibrarySource::RecentlyDeleted; + assert_eq!(s.visible().len(), 2, "they wait in Recently Deleted"); + s.source = LibrarySource::All; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 3, "one undo step"); +} + +#[test] +fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() { + let mut s = session(); + assert!(s.execute("library.removeFolder", &json!({"path": "/elsewhere"})).is_err()); + assert!(s.execute("library.removeFolder", &json!({"path": "/"})).is_err(), "choose a folder, not everything"); + assert!(s.execute("library.removeFolder", &json!({"path": " "})).is_err()); + assert!(s.execute("library.removeFolder", &json!({})).is_err()); + assert_eq!(s.visible().len(), 3, "nothing changed"); +} + +#[test] +fn removing_a_folder_leaves_browsed_and_already_deleted_photos_alone() { + let mut s = session(); + for (path, local, deleted) in [("/pics/trip/browsed.jpg", true, false), ("/pics/trip/gone.jpg", false, true)] { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + (p.local, p.deleted) = (local, deleted); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let r = s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(r["removed"], 2, "only the two library photos of the folder"); + let state: Vec<(bool, bool)> = s.catalog.photos().filter(|p| p.local || p.deleted).map(|p| (p.local, p.deleted)).collect(); + assert!(state.contains(&(true, false)) && state.contains(&(false, true)), "browsed stays browsed, the old deletion stays: {state:?}"); +} + +#[test] +fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone"); + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some("/pics")); +} + +#[test] +fn a_path_that_names_no_folder_removes_nothing() { + let mut s = session(); + for path in [".", "./", "a/..", "../..", "x/../.."] { + assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?}"); + } + assert_eq!(s.visible().len(), 3); +} + +#[test] +fn a_whole_disk_goes_only_when_asked_for_by_name_and_never_the_startup_disk() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Volumes/nas/2.jpg", "/Users/me/3.jpg", r"C:\x\4.jpg", r"\\srv\share\5.jpg"] { + add(&mut s, p); + } + for path in ["/Volumes/nas", "C:", "C:\\", r"\\?\C:\", "C:\\..", r"\\srv\share"] { + assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?} is a whole disk"); + } + for path in ["/", "//"] { + assert!(s.execute("library.removeFolder", &json!({"path": path, "disk": true})).is_err(), "{path:?}: never the startup disk"); + } + assert_eq!(s.visible().len(), 5, "nothing changed"); + let r = s.execute("library.removeFolder", &json!({"path": "/Volumes/nas", "disk": true})).unwrap(); + assert_eq!(r["removed"], 2, "everything on the nas, nothing elsewhere"); + assert_eq!(s.visible().len(), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 5); +} + +#[test] +fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Volumes/tokyo/2.jpg", "/Volumes/3.jpg"] { + add(&mut s, p); + } + assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take two disks' photos at once"); + assert_eq!(s.visible().len(), 3); + let r = s.execute("library.removeFolder", &json!({"path": "/Volumes", "disk": true})).unwrap(); + assert_eq!(r["removed"], 3); +} + +#[test] +fn the_folder_filter_takes_only_a_text_path() { + let mut s = session(); + assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err()); + assert_eq!(s.filter.library_folder, None, "refused, nothing changed"); + s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); + assert_eq!(s.visible().len(), 3, "a blank path is no choice"); + s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap(); + assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything"); +} + +#[test] +fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() { + let dir = Scratch::new("rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, renamed) = (dir.path("trip"), dir.path("holiday")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{trip}/day1/b.jpg")); + s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap(); + assert_eq!(s.visible().len(), 2); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder"); + assert_eq!(s.visible().len(), 2, "and still shows its photos"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str())); + assert_eq!(s.visible().len(), 2); +} + +#[test] +fn a_moved_folder_keeps_a_chosen_subfolder_chosen() { + let dir = Scratch::new("move"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + std::fs::create_dir_all(dir.0.join("archive")).unwrap(); + let (day1, moved) = (dir.path("trip/day1"), dir.path("archive/trip/day1")); + let mut s = Session::new(); + add(&mut s, &format!("{day1}/b.jpg")); + s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap(); + s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent"); + assert_eq!(s.visible().len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str())); +} + +#[test] +fn following_a_folder_reads_both_spellings_the_same_way() { + // the choice was typed with a `..` in it; the folder that moved was not + let mut s = Session::new(); + s.filter.library_folder = Some("/a/x/../b/sub".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent"); + s.filter.library_folder = Some("/elsewhere/b".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone"); +} + +#[test] +fn choosing_a_folder_replaces_the_source_the_way_an_album_does() { + let mut s = session(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + assert_eq!(s.visible().len(), 0, "no picks"); + let r = source_folder(&mut s, "/pics/trip"); + assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip"))); + assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip"); + let state = s.execute("library.state", &json!({})).unwrap(); + assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder"); + // the filter bar narrows it further, as it does an album + s.execute("library.filter", &json!({"rating": 1})).unwrap(); + assert_eq!(s.visible().len(), 0); + s.execute("library.clearFilter", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 2); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible().len(), 3, "back to everything"); +} + +#[test] +fn the_folder_source_takes_a_library_folder_path_only() { + let mut s = session(); + for params in [ + json!({"kind": "libraryFolder"}), + json!({"kind": "libraryFolder", "path": 5}), + json!({"kind": "libraryFolder", "path": " "}), + json!({"kind": "libraryFolder", "path": "."}), + ] { + assert!(s.execute("library.source", ¶ms).is_err(), "{params}"); + } + assert_eq!(s.source, LibrarySource::All, "refused, nothing changed"); + assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/elsewhere"})).is_err(), "no photo was imported from it"); + assert_eq!(s.source, LibrarySource::All); +} + +#[test] +fn removing_the_folder_being_shown_returns_to_all_photos() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + assert_eq!(s.visible().len(), 1); + // removing another folder leaves the source alone + let mut s = session(); + source_folder(&mut s, "/pics/home"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.source, LibrarySource::LibraryFolder); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() { + let dir = Scratch::new("source-rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{day1}/b.jpg")); + source_folder(&mut s, &day1); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent"); + assert_eq!(s.visible().len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.library_folder.as_deref(), Some(day1.as_str())); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn the_shown_folder_follows_reading_both_spellings_the_same_way() { + let mut s = Session::new(); + s.library_folder = Some("/a/x/../b/sub".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub")); +} + +#[test] +fn switching_from_one_folder_to_another_changes_what_is_shown() { + let mut s = session(); + assert_eq!(source_folder(&mut s, "/pics/trip")["count"], 2); + assert_eq!(s.source_total(), Some(2)); + assert_eq!(source_folder(&mut s, "/pics/home")["count"], 1, "same source, another folder"); + assert_eq!(s.visible().len(), 1); + assert_eq!(s.source_total(), Some(1)); +} + +#[test] +fn a_smart_album_made_from_a_folder_view_keeps_the_folder() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.visible().len(), 2); + let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap(); + assert_eq!(r["count"], 2, "the album matches the folder's photos, not the whole library: {r}"); +} + +#[test] +fn a_folder_source_and_a_folder_filter_never_disagree() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over"); + assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time"); + assert_eq!(s.visible().len(), 2); + s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_ok(), "fine while no folder is the source"); +} + +#[test] +fn a_folder_source_with_no_folder_falls_back_to_all_photos() { + let mut s = session(); + s.source = LibrarySource::LibraryFolder; + s.library_folder = None; + assert_eq!(s.visible().len(), 3, "everything, not an empty grid under no name"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + assert_eq!(s.source_total(), Some(3)); +} + +#[test] +fn only_a_folder_that_shows_exactly_its_own_photos_can_be_the_source() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg", "/Volumes/3.jpg"] { + add(&mut s, p); + } + for path in ["/", "//", "/elsewhere", "/Volumes"] { + assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).is_err(), "{path:?}"); + } + assert_eq!(s.source, LibrarySource::All, "refused, nothing changed"); + assert_eq!(source_folder(&mut s, "/Volumes/nas")["count"], 1, "a disk is a fine source"); + assert_eq!(source_folder(&mut s, "/Users/me")["count"], 1); +} + +#[test] +fn removing_a_folder_above_a_disk_needs_asking_by_name_even_with_one_disk() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg"] { + add(&mut s, p); + } + assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take the whole nas"); + assert_eq!(s.visible().len(), 2); + assert_eq!(s.execute("library.removeFolder", &json!({"path": "/Volumes/nas/a"})).unwrap()["removed"], 1, "a folder on the disk is just a folder"); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_folder_name_with_spaces_at_its_edge_is_never_mistaken_for_its_neighbour() { + let mut s = Session::new(); + for p in ["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"] { + add(&mut s, p); + } + assert_eq!(source_folder(&mut s, "/p/shoot ")["count"], 1); + let shown: Vec = s.visible().to_vec().iter().filter_map(|id| s.catalog.photo(*id)).map(|p| p.file_name.clone()).collect(); + assert_eq!(s.library_folder.as_deref(), Some("/p/shoot "), "kept as the tree gave it"); + assert_eq!(shown.len(), 1); + let before: Vec<_> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + assert!(before.is_empty()); + let r = s.execute("library.removeFolder", &json!({"path": "/p/shoot "})).unwrap(); + assert_eq!(r["removed"], 1); + let gone: Vec = s + .catalog + .photos() + .filter(|p| p.deleted) + .map(|p| match &p.source { + Source::File { path } => path.clone(), + Source::Demo { .. } => String::new(), + }) + .collect(); + assert_eq!(gone, ["/p/shoot /a.jpg"], "the folder named, not its neighbour"); +} + +#[test] +fn editing_a_smart_album_made_from_a_folder_view_keeps_the_folder() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + // the rules dialog saves what it shows (no folder field) with `replace` + let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"ruleSet": {"rules": []}}})).unwrap(); + assert_eq!(r["count"], 2, "the folder is not something the dialog can show, so it is kept: {r}"); + let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"libraryFolder": null}})).unwrap(); + assert_eq!(r["count"], 3, "and can be dropped on purpose"); +} + +#[test] +fn a_shown_folder_that_loses_its_last_photo_gives_way_to_all_photos() { + let mut s = session(); + source_folder(&mut s, "/pics/home"); + s.execute("photo.delete", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 2, "back to everything that is left"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + // a folder whose photos are only hidden by the filter bar stays + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + s.execute("library.filter", &json!({"rating": 5})).unwrap(); + assert_eq!(s.visible().len(), 0); + assert_eq!(s.source, LibrarySource::LibraryFolder, "the filters hide them, the folder is still there"); +} + +#[test] +fn the_state_names_a_folder_only_while_it_is_shown() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"], "/pics/trip"); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"].is_null(), "not a stale leftover"); +} + +#[test] +fn a_blank_folder_filter_is_no_filter() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); + assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state"); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index af106765..ce36c447 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -545,12 +545,17 @@ fn cube_luts_become_profiles() { ); s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap(); assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); + s.execute("profile.favorite", &json!({"id": id})).unwrap(); + assert!(s.profile_favorites.contains(&id)); // the library keeps it drop(s); lightcraft_pipeline::lut::unregister(&id); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert!(s.lut_profiles.iter().any(|p| p.id == id)); + assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); assert!(lightcraft_pipeline::lut::get(&id).is_none()); @@ -858,3 +863,147 @@ fn missing_photos_skip_local_browse_records() { s.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } + +/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the +/// report says it is in the trash; `onDeleted: restore` brings it back with its edits; +/// `onDeleted: fresh` replaces the trashed record with a new one. +fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { + let src = temp_dir(tag); + write_png(&src.join("a.png"), 1); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = r["imported"][0].as_u64().unwrap(); + s.execute("photo.rate", &json!({"ids": [id], "rating": 4})).unwrap(); + s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); + (s, src, id) +} + +#[test] +fn duplicate_in_recently_deleted_is_reported_as_such() { + let (mut s, src, id) = trashed_photo("trash-report"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&r, "imported"), 0, "{r}"); + assert_eq!(r["duplicates"][0]["existing"], id, "{r}"); + assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn import_can_restore_a_duplicate_from_recently_deleted() { + let (mut s, src, id) = trashed_photo("trash-restore"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore"})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4, "restored with its edits"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn import_can_replace_a_duplicate_from_recently_deleted() { + let (mut s, src, id) = trashed_photo("trash-fresh"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + let new = r["imported"][0].as_u64().unwrap(); + assert_ne!(new, id); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none(), "the trashed record is gone"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap(); + assert!(!p.deleted && p.rating == 0, "a new photo without the old edits"); + assert_eq!(s.catalog.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn unknown_on_deleted_is_an_error() { + let mut s = Session::new().with_fs(); + let e = s.execute("library.import", &json!({"paths": [], "onDeleted": "bogus"})).unwrap_err().to_string(); + assert!(e.contains("onDeleted"), "{e}"); +} + +#[test] +fn empty_recently_deleted_removes_only_trashed_photos() { + let (mut s, src, id) = trashed_photo("trash-empty"); + write_png(&src.join("b.png"), 2); + let r = s.execute("library.import", &json!({"paths": [src.join("b.png").to_string_lossy()]})).unwrap(); + let keep = r["imported"][0].as_u64().unwrap(); + let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); + assert_eq!(r["deleted"], 1, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none()); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(keep)).is_some()); + // nothing to empty is not an error + assert_eq!(s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap()["deleted"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + let _ = std::fs::remove_dir_all(&src); +} + +/// Two trashed photos that share an album and a two-photo stack: emptying (or permanently +/// deleting) them together must not fail or leave dangling references. +fn two_trashed_in_album_and_stack(tag: &str) -> (Session, std::path::PathBuf, [u64; 2], u64) { + let src = temp_dir(tag); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let ids = [r["imported"][0].as_u64().unwrap(), r["imported"][1].as_u64().unwrap()]; + let al = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": al, "ids": ids})).unwrap(); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("stack.group", &json!({"ids": ids})).unwrap(); + s.execute("photo.delete", &json!({"ids": ids})).unwrap(); + (s, src, ids, al) +} + +#[test] +fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-empty-refs"); + let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2, "{r}"); + assert_eq!(s.catalog.len(), 0); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty(), "album still lists a removed photo"); + assert!(ids.iter().all(|i| s.catalog.stack_of(lightcraft_catalog::PhotoId(*i)).is_none())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.len(), 2, "one undo step brings both back"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2); + assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some()); + assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn deleting_several_photos_permanently_leaves_nothing_dangling() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs"); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap(); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn fresh_import_of_two_trashed_photos_in_one_album_and_stack_works() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-fresh-refs"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids_len(&r), 2, "{r}"); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); + assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none())); + let _ = std::fs::remove_dir_all(&src); +} + +fn ids_len(r: &Value) -> usize { + ids(r, "imported") +} + +#[test] +fn restore_import_applies_the_album_to_restored_photos() { + let (mut s, src, ids, _) = trashed_photo_ids("trash-restore-album"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore", "albumName": "Back"})).unwrap(); + assert_eq!(r["restored"], json!(ids), "{r}"); + let album = r["album"].as_u64().expect("album created for the restored photos"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64) { + let (s, src, id) = trashed_photo(tag); + (s, src, [id], 0) +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 45a56b2e..f7b89225 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -212,6 +212,39 @@ fn profile_favorites_and_recent_survive_reopen() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_shown_library_folder_survives_reopen() { + let dir = temp_dir("libfolder"); + let mut s = open(&dir, true); + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: "/pics/trip/a.jpg".into() }, + "a.jpg", + "JPEG", + 60, + 40, + "2026-01-01T10:00:00", + ); + s.commit("Add", lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap(); + s.save_view(); + drop(s); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip"))); + drop(s); + // a folder that holds none of the library's photos any more opens on everything too + std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}, "library_folder": "/no/such/folder"}"#).unwrap(); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder.clone()), (crate::LibrarySource::All, None)); + drop(s); + // a view file that names the source but no folder opens on everything, never an empty grid + std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap(); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Smart albums with a rule set: all / any / none, nested groups, validation, live updates. #[test] fn smart_album_rule_sets() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 757adfe9..16c9f8a0 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -19,6 +19,9 @@ pub enum LibrarySource { Folder, /// Photos whose original file can't be found (`library.missing`). Missing, + /// A folder the library's photos were imported from ([`crate::Session::library_folder`]), + /// and the folders inside it (`library.folders`). + LibraryFolder, } /// The folder a [`LibrarySource::Folder`] view shows. @@ -49,7 +52,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => f.deleted = true, LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick), // the folder itself is filled in by the session (it holds the path) - LibrarySource::Folder | LibrarySource::Missing => {} + LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {} } f } @@ -62,6 +65,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => "Recently Deleted".into(), LibrarySource::Picks => "Picks".into(), LibrarySource::Folder => "Folder".into(), + LibrarySource::LibraryFolder => "Folder".into(), LibrarySource::Missing => "Missing Photos".into(), } } @@ -74,7 +78,27 @@ pub struct Selection { pub active: Option, } +/// How a photo relates to the selection: what every view draws (grid cell, filmstrip cell…). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionState { + NotSelected, + /// Selected, but not the photo the loupe and panels act on. + Selected, + /// The most-selected photo. + Active, +} + impl Selection { + /// What views draw for `id`: active, selected or neither (one answer for grid, filmstrip, …). + pub fn state_of(&self, id: PhotoId) -> SelectionState { + if self.active == Some(id) { + SelectionState::Active + } else if self.contains(id) { + SelectionState::Selected + } else { + SelectionState::NotSelected + } + } pub fn single(id: PhotoId) -> Selection { Selection { ids: vec![id], active: Some(id) } } @@ -173,6 +197,11 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(d) = &f.date { add(format!("Date: {}", date_label(d)), json!({"date": Null})); } + if let Some(d) = f.library_folder.as_deref().filter(|d| !d.trim().is_empty()) { + // the last two names, so two folders called "Pictures" are told apart + let name = lightcraft_catalog::folders::folder_label(d); + add(format!("Folder: {name}"), json!({"libraryFolder": Null})); + } if let Some(d) = &f.imported { add(format!("Imported: {}", date_label(d)), json!({"imported": Null})); } @@ -203,3 +232,18 @@ fn date_label(d: &str) -> String { _ => d.to_string(), } } + +#[cfg(test)] +mod selection_state_tests { + use super::*; + use lightcraft_catalog::PhotoId; + + #[test] + fn select_all_marks_the_others_selected_and_one_active() { + let ids: Vec = (1..=3).map(PhotoId).collect(); + let s = Selection { ids: ids.clone(), active: Some(ids[0]) }; + assert_eq!(s.state_of(ids[0]), SelectionState::Active); + assert_eq!(s.state_of(ids[1]), SelectionState::Selected); + assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected); + } +} diff --git a/crates/fetch/README.md b/crates/fetch/README.md new file mode 100644 index 00000000..60ac1d32 --- /dev/null +++ b/crates/fetch/README.md @@ -0,0 +1,7 @@ +# lightcraft-fetch + +Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty. + +Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host. + +Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests. diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 332f0147..e900850a 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -60,7 +60,10 @@ pub struct Url { impl Url { pub fn parse(s: &str) -> Result { - let bad = |why: &str| HttpError::BadUrl(format!("{s}: {why}")); + let bad = |why: &str| HttpError::BadUrl(why.to_string()); + if s.len() > 16 * 1024 { + return Err(bad("URL exceeds 16 KiB")); + } let s = s.trim(); if s.chars().any(|c| c.is_control() || c == ' ') { return Err(bad("contains spaces or control characters")); @@ -509,6 +512,12 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result { + pub name: &'a str, /// Exact size in bytes, when pinned (then the download resumes and is checked against it). pub size: Option, /// SHA-256 (lower-case hex), when pinned. - pub sha256: Option<&'static str>, + pub sha256: Option<&'a str>, /// Largest size accepted when `size` is not pinned. pub max: u64, } @@ -79,11 +79,14 @@ pub struct Options { pub stall_timeout: Duration, /// Tries per mirror and file (each resumes where the last one stopped). pub attempts: u32, + /// The environment variable holding a bearer token, sent to each configured mirror's own host (only over https, + /// never to a host a redirect leads to), so list only mirrors that may see it. `None`: nothing is ever sent, so one model's token cannot leak to another's server. + pub token_env: Option<&'static str>, } impl Default for Options { fn default() -> Self { - Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3 } + Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3, token_env: None } } } @@ -103,6 +106,8 @@ pub struct Progress { pub enum DownloadError { /// No mirror is configured. NoMirrors, + /// Invalid filename, size, hash or aggregate bound supplied by the caller. + Invalid(String), Cancelled, /// A local file problem (folder not writable, disk full…). Disk(String), @@ -116,6 +121,7 @@ pub enum DownloadError { impl std::fmt::Display for DownloadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + DownloadError::Invalid(e) => write!(f, "invalid download: {e}"), DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"), DownloadError::Cancelled => write!(f, "the download was cancelled"), DownloadError::Disk(e) => write!(f, "{e}"), @@ -132,16 +138,45 @@ enum Fail { Disk(String), } -/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Files already there -/// and verified are kept. `progress` is called as bytes arrive (often: throttle in it). +/// Fetch a single file from its exact URL, preserving any query string. The local filename +/// remains `file.name`; it never comes from redirects or response headers. +pub fn download_url( + file: &FileSpec<'_>, + url: &str, + dir: &Path, + opts: &Options, + cancel: &AtomicBool, + progress: &mut dyn FnMut(&Progress), +) -> Result<(), DownloadError> { + download_source(std::slice::from_ref(file), &[url.to_string()], dir, opts, cancel, progress, true) +} + +/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Verified files +/// are kept. `progress` is called as bytes arrive (often: throttle in it). pub fn download( - files: &[FileSpec], + files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, opts: &Options, cancel: &AtomicBool, progress: &mut dyn FnMut(&Progress), ) -> Result<(), DownloadError> { + download_source(files, mirrors, dir, opts, cancel, progress, false) +} + +fn download_source( + files: &[FileSpec<'_>], + mirrors: &[String], + dir: &Path, + opts: &Options, + cancel: &AtomicBool, + progress: &mut dyn FnMut(&Progress), + exact_urls: bool, +) -> Result<(), DownloadError> { + let total = validate_files(files)?; + if mirrors.len() > MAX_MIRRORS { + return Err(DownloadError::Invalid("too many mirrors".into())); + } std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?; // what's left to fetch let mut todo = Vec::new(); @@ -156,14 +191,13 @@ pub fn download( if mirrors.is_empty() { return Err(DownloadError::NoMirrors); } - let total: u64 = files.iter().map(|f| f.size.unwrap_or(0)).sum(); let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum(); for f in todo { let mut errors = Vec::new(); let mut ok = false; 'mirrors: for m in mirrors { - let url = format!("{m}/{}", f.name); - for _ in 0..opts.attempts.max(1) { + let url = if exact_urls { m.clone() } else { format!("{m}/{}", f.name) }; + for _ in 0..opts.attempts.clamp(1, 16) { if cancel.load(Ordering::Relaxed) { return Err(DownloadError::Cancelled); } @@ -184,11 +218,11 @@ pub fn download( Err(Fail::Cancelled) => return Err(DownloadError::Cancelled), Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)), Err(Fail::Retry(e)) => { - log::warn!("download of {url}: {e} (retrying)"); + log::warn!("download of {}: {e} (retrying)", short(&url)); errors.push(format!("{}: {e}", short(m))); } Err(Fail::NextMirror(e)) => { - log::warn!("download of {url}: {e}"); + log::warn!("download of {}: {e}", short(&url)); errors.push(format!("{}: {e}", short(m))); continue 'mirrors; } @@ -203,13 +237,56 @@ pub fn download( Ok(()) } +/// Validate before touching disk, including names supplied by a user model catalog. +fn validate_files(files: &[FileSpec<'_>]) -> Result { + if files.len() > 256 { + return Err(DownloadError::Invalid("too many files".into())); + } + let mut seen = std::collections::HashSet::new(); + let mut total = 0u64; + for f in files { + let stem = f.name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || stem + .strip_prefix("COM") + .or_else(|| stem.strip_prefix("LPT")) + .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + if reserved + || f.name.ends_with(['.', ' ']) + || f.name.ends_with(".part") + || f.name.is_empty() + || f.name.len() > 255 + || f.name.starts_with('.') + || f.name.contains(['/', '\\', ':']) + || f.name.chars().any(char::is_control) + || !seen.insert(f.name) + { + return Err(DownloadError::Invalid("a filename must be a unique, ordinary filename".into())); + } + if f.max == 0 + || f.size.is_some_and(|n| n == 0 || n > f.max) + || f.sha256.is_some_and(|h| h.len() != 64 || !h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + { + return Err(DownloadError::Invalid("invalid file size or SHA-256".into())); + } + total = total.checked_add(f.size.unwrap_or(0)).ok_or_else(|| DownloadError::Invalid("total size overflow".into()))?; + } + Ok(total) +} + +/// The bearer token to send to `url`: only a non-empty one, only over https, and only to the host the download began +/// at (`origin`), never to the host a redirect leads to. +fn bearer<'a>(token: Option<&'a str>, url: &Url, origin: &str) -> Option<&'a str> { + token.map(str::trim).filter(|t| !t.is_empty() && url.tls && url.host == origin) +} + /// A mirror for messages (no query string: it may hold a token). fn short(m: &str) -> String { Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into()) } /// Whether `path` is already the right file (exact size and hash when pinned). -fn verified(f: &FileSpec, path: &Path) -> Result { +fn verified(f: &FileSpec<'_>, path: &Path) -> Result { let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; if !meta.is_file() || meta.len() == 0 { return Ok(false); @@ -243,7 +320,7 @@ fn part_path(dir: &Path, name: &str) -> PathBuf { /// Fetch one file from one mirror into `.part` (resuming it when its size is pinned), /// check it and move it into place. -fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> { +fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> { let part = part_path(dir, f.name); let limit = f.size.unwrap_or(f.max); let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display())); @@ -268,12 +345,8 @@ fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &Atom headers.push(("Range", format!("bytes={have}-"))); } // a token for the configured host only (never sent on to a redirect's host) - if let Ok(t) = std::env::var("LIGHTCRAFT_SAM3_TOKEN") - && !t.trim().is_empty() - && url.tls - && url.host == origin - { - headers.push(("Authorization", format!("Bearer {}", t.trim()))); + if let Some(t) = bearer(opts.token_env.and_then(|name| std::env::var(name).ok()).as_deref(), &url, &origin) { + headers.push(("Authorization", format!("Bearer {t}"))); } let r = http::get(&url, &headers, &limits).map_err(http_fail)?; match r.status { diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs index 57c168a2..4a828c6d 100644 --- a/crates/fetch/src/tests.rs +++ b/crates/fetch/src/tests.rs @@ -22,7 +22,7 @@ fn leak(s: String) -> &'static str { Box::leak(s.into_boxed_str()) } -fn pinned() -> FileSpec { +fn pinned() -> FileSpec<'static> { let p = payload(); FileSpec { name: "model.bin", size: Some(p.len() as u64), sha256: Some(leak(sha_hex(&p))), max: p.len() as u64 } } @@ -137,10 +137,10 @@ fn tmp(name: &str) -> PathBuf { } fn quick() -> Options { - Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2 } + Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2, token_env: None } } -fn run(files: &[FileSpec], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec) { +fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec) { let mut seen = Vec::new(); let r = download(files, mirrors, dir, &quick(), cancel, &mut |p| seen.push(p.clone())); (r, seen) @@ -314,3 +314,75 @@ fn content_ranges() { assert_eq!(parse_content_range("bytes 9-1/300"), None); assert_eq!(parse_content_range("items 1-2/3"), None); } + +/// A bearer token goes only to https, only to the host the download began at, and only when there is one. +#[test] +fn a_token_is_sent_only_over_https_to_the_first_host() { + let u = |s: &str| Url::parse(s).unwrap(); + let first = u("https://huggingface.co/x/model.bin"); + assert_eq!(bearer(Some(" hf_abc \n"), &first, "huggingface.co"), Some("hf_abc")); + // the storage host a redirect leads to, plain http, no token, an empty one + assert_eq!(bearer(Some("hf_abc"), &u("https://cdn-lfs.example/x"), "huggingface.co"), None); + assert_eq!(bearer(Some("hf_abc"), &u("http://huggingface.co/x"), "huggingface.co"), None); + assert_eq!(bearer(None, &first, "huggingface.co"), None); + assert_eq!(bearer(Some(" "), &first, "huggingface.co"), None); + // a caller that names no variable sends nothing, whatever the environment holds + assert!(Options::default().token_env.is_none()); +} + +#[test] +fn exact_url_keeps_query_and_owned_catalog_values() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/download?file=weights&sig=secret", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(socket.try_clone().unwrap()); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + assert_eq!(request.trim(), "GET /download?file=weights&sig=secret HTTP/1.1"); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line.trim().is_empty() { + break; + } + } + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc").unwrap(); + }); + let name = String::from("catalog.onnx"); + let hash = sha_hex(b"abc"); + let spec = FileSpec { name: &name, size: Some(3), sha256: Some(&hash), max: 3 }; + let dir = tmp("exact"); + let mut progress = Vec::new(); + download_url(&spec, &url, &dir, &quick(), &AtomicBool::new(false), &mut |p| progress.push(p.clone())).unwrap(); + worker.join().unwrap(); + assert_eq!(std::fs::read(dir.join(name)).unwrap(), b"abc"); + assert!(progress.iter().all(|p| !p.mirror.contains("secret"))); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn hostile_file_specs_are_rejected_before_touching_disk() { + let dir = tmp("invalid"); + let base = FileSpec { name: "valid.onnx", size: Some(1), sha256: None, max: 1 }; + let cancel = AtomicBool::new(false); + for name in ["", ".hidden", "../escape", "a/b", "a\\b", "C:escape", "bad\nname", "trailing.", "CON.onnx", "a.part"] { + let spec = FileSpec { name, ..base.clone() }; + assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))), "{name}"); + assert!(!dir.exists()); + } + for spec in [ + FileSpec { max: 0, ..base.clone() }, + FileSpec { size: Some(0), ..base.clone() }, + FileSpec { size: Some(2), ..base.clone() }, + FileSpec { sha256: Some("not a SHA"), ..base.clone() }, + ] { + assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_)))); + assert!(!dir.exists()); + } + let huge = FileSpec { size: Some(u64::MAX), max: u64::MAX, ..base.clone() }; + let other = FileSpec { name: "other.onnx", ..huge.clone() }; + for specs in [vec![huge, other], vec![base.clone(); 257], vec![base.clone(), base]] { + assert!(matches!(run(&specs, &[], &dir, &cancel).0, Err(DownloadError::Invalid(_)))); + assert!(!dir.exists()); + } +} diff --git a/crates/geom/src/crop_drag.rs b/crates/geom/src/crop_drag.rs new file mode 100644 index 00000000..f600de6d --- /dev/null +++ b/crates/geom/src/crop_drag.rs @@ -0,0 +1,483 @@ +//! Interactive crop editing: what dragging a crop handle does. +//! +//! The crop rectangle lives in the straightened (rotated) frame and must stay inside the rotated +//! image. A drag is a *domain operation* on the crop, not a UI detail: the handle being dragged +//! decides which edges are anchored, an optional aspect lock decides the shape, and the image +//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app, +//! CLI, control channel and MCP agree. + +use crate::{CropGeometry, Point, Rect}; + +/// Smallest crop side, as a fraction of the image's shorter side. +pub const MIN_CROP_FRACTION: f64 = 0.02; + +/// Most elongated locked crop shape (width:height or its inverse) the drag supports. +pub const MAX_RATIO: f64 = 20.0; + +/// A grab point on the crop frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CropHandle { + TopLeft, + TopRight, + BottomRight, + BottomLeft, + Top, + Right, + Bottom, + Left, + /// Drag the whole frame. + Move, +} + +impl CropHandle { + /// The overlay's numbering: 0..3 corners (clockwise from top-left), 4..7 edges (top, right, + /// bottom, left), 8 = move. + pub fn from_index(i: u8) -> Option { + Some(match i { + 0 => CropHandle::TopLeft, + 1 => CropHandle::TopRight, + 2 => CropHandle::BottomRight, + 3 => CropHandle::BottomLeft, + 4 => CropHandle::Top, + 5 => CropHandle::Right, + 6 => CropHandle::Bottom, + 7 => CropHandle::Left, + 8 => CropHandle::Move, + _ => return None, + }) + } + + /// Parse a command-parameter name (`"topLeft"`, `"top"`, `"move"`, …). + pub fn from_name(n: &str) -> Option { + Some(match n { + "topLeft" => CropHandle::TopLeft, + "topRight" => CropHandle::TopRight, + "bottomRight" => CropHandle::BottomRight, + "bottomLeft" => CropHandle::BottomLeft, + "top" => CropHandle::Top, + "right" => CropHandle::Right, + "bottom" => CropHandle::Bottom, + "left" => CropHandle::Left, + "move" => CropHandle::Move, + _ => return None, + }) + } +} + +/// Drag `handle` of `start` from `grab` to `pointer` (both normalized, in the straightened frame) +/// on a `w × h` image. `aspect` is the locked width/height ratio in pixels, if any. +/// +/// Guarantees (for any input, including hostile numbers): +/// - the result lies inside the rotated image; +/// - the edges opposite the handle stay where they were (a move keeps the size); +/// - with `aspect`, the frame keeps that shape on every handle (when `start` had it); +/// - the drag stops at the image edge instead of pushing the frame around, and slides along it. +pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: Point, w: f64, h: f64, aspect: Option) -> CropGeometry { + let sane = |v: f64| v.is_finite(); + let r0 = start.rect; + if ![w, h, grab.x, grab.y, pointer.x, pointer.y, start.angle, r0.x0, r0.y0, r0.x1, r0.y1].iter().copied().all(sane) + || w <= 0.0 + || h <= 0.0 + || r0.is_empty() + || aspect.is_some_and(|a| !(a.is_finite() && a > 0.0)) + { + return start; + } + // Start from a valid frame, then work in pixels of the straightened frame. + let mut base = start.constrained(w, h); + if base.angle == 0.0 { + // absorb the 1e-6 tolerance of `is_within_image` so the exact test below accepts the start + let r = base.rect; + base.rect = Rect::new(r.x0.clamp(0.0, 1.0), r.y0.clamp(0.0, 1.0), r.x1.clamp(0.0, 1.0), r.y1.clamp(0.0, 1.0)); + } + let sr = base.rect_px(w, h); + // Moving further than twice the image never matters; clamping keeps the searches well-conditioned. + let (dx, dy) = (((pointer.x - grab.x) * w).clamp(-2.0 * w, 2.0 * w), ((pointer.y - grab.y) * h).clamp(-2.0 * h, 2.0 * h)); + // Ratios beyond 20:1 make even the minimum frame larger than the image; treat them as 20:1. + let aspect = aspect.map(|a| a.clamp(1.0 / MAX_RATIO, MAX_RATIO)); + // Unrotated, the frame must be exactly inside the image (`is_within_image` tolerates 1e-6, which + // would leak out as a sliver past the edge and, after any clamp, a shifted ratio). + let feasible = |r: Rect| { + if base.angle == 0.0 { + r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= w && r.y1 <= h + } else { + CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: base.angle }.is_within_image(w, h) + } + }; + let min_side = MIN_CROP_FRACTION * w.min(h); + + let out = match (handle, aspect) { + (CropHandle::Move, _) => { + let slide = |cur: Rect, ax: f64, ay: f64| { + let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t)))); + cur.translate(crate::Vec2::new(ax * t, ay * t)) + }; + let cur = slide(sr, dx, 0.0); + slide(cur, 0.0, dy) + } + (hd, None) => { + let (mx0, mx1, my0, my1) = sides(hd); + let grow = |cur: Rect, tx: f64, ty: f64| { + // `tx`/`ty`: how far the moving edges would go; interpolate towards it + let target = Rect::new( + if mx0 { (cur.x0 + tx).min(cur.x1 - min_side) } else { cur.x0 }, + if my0 { (cur.y0 + ty).min(cur.y1 - min_side) } else { cur.y0 }, + if mx1 { (cur.x1 + tx).max(cur.x0 + min_side) } else { cur.x1 }, + if my1 { (cur.y1 + ty).max(cur.y0 + min_side) } else { cur.y1 }, + ); + let at = |t: f64| lerp_rect(cur, target, t); + at(bisect(0.0, 1.0, |t| feasible(at(t)))) + }; + let cur = grow(sr, dx, 0.0); + grow(cur, 0.0, dy) + } + (hd, Some(a)) => { + let (mx0, _, my0, _) = sides(hd); + let (min_w, min_h) = (min_side.max(min_side * a), min_side.max(min_side / a)); + let c = sr.center(); + // Anchor: the opposite corner / edge midpoint. `build(n, o)` is the frame of width `n` that + // keeps it, shifted by `o` along the anchored edge (edge handles only). + let (target_w, build): (f64, Box Rect>) = match hd { + CropHandle::Top | CropHandle::Bottom => { + let (ay, sy, edge) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) }; + let nh = (sy * (edge + dy - ay)).max(min_h); + (nh * a, Box::new(move |nw, o| vband(c.x + o, ay, sy, nw, a))) + } + CropHandle::Left | CropHandle::Right => { + let (ax, sx, edge) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) }; + let nw = (sx * (edge + dx - ax)).max(min_w); + (nw, Box::new(move |nw, o| hband(ax, sx, c.y + o, nw, a))) + } + _ => { + let (ax, sx, ex) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) }; + let (ay, sy, ey) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) }; + let (wx, wy) = (sx * (ex + dx - ax), sy * (ey + dy - ay)); + let nw = ((wx + wy * a) / 2.0).max(min_w); + (nw, Box::new(move |nw, _| corner_rect(ax, sx, ay, sy, nw, a))) + } + }; + let target_w = target_w.min(2.0 * w.max(h * a)); + let slides = matches!(hd, CropHandle::Top | CropHandle::Bottom | CropHandle::Left | CropHandle::Right); + let extent = if matches!(hd, CropHandle::Top | CropHandle::Bottom) { w } else { h }; + // Frame of width `n`, centred on the anchored edge if it fits, else slid the least distance. + let place = |n: f64| -> Option { + let at = |o: f64| build(n, o); + if feasible(at(0.0)) { + return Some(at(0.0)); + } + if !slides { + return None; + } + const STEPS: u32 = 128; + let step = extent / STEPS as f64; + for k in 1..=STEPS { + for sign in [1.0, -1.0] { + let hi = sign * k as f64 * step; + if feasible(at(hi)) { + // closest feasible offset: bisect between the last infeasible and this one + let (mut bad, mut good) = (sign * (k - 1) as f64 * step, hi); + for _ in 0..40 { + let mid = (bad + good) / 2.0; + if feasible(at(mid)) { + good = mid; + } else { + bad = mid; + } + } + return Some(at(good)); + } + } + } + None + }; + if place(min_w).is_some() { + let n = bisect_range(min_w, target_w.max(min_w), |n| place(n).is_some()); + place(n).unwrap_or(sr) + } else { + sr + } + } + }; + let rect = out.scale(1.0 / w, 1.0 / h); + if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start } +} + +/// Which sides a handle moves: (left, right, top, bottom). +fn sides(h: CropHandle) -> (bool, bool, bool, bool) { + use CropHandle::*; + ( + matches!(h, TopLeft | BottomLeft | Left), + matches!(h, TopRight | BottomRight | Right), + matches!(h, TopLeft | TopRight | Top), + matches!(h, BottomLeft | BottomRight | Bottom), + ) +} + +fn lerp_rect(a: Rect, b: Rect, t: f64) -> Rect { + let l = |p: f64, q: f64| p + (q - p) * t; + Rect::new(l(a.x0, b.x0), l(a.y0, b.y0), l(a.x1, b.x1), l(a.y1, b.y1)) +} + +/// Frame of width `nw` and ratio `a` extending from corner (`ax`, `ay`) in directions `sx`, `sy`. +fn corner_rect(ax: f64, sx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(ax, ay), Point::new(ax + sx * nw, ay + sy * nh)) +} + +/// Frame whose top/bottom edge (at `ay`, growing in direction `sy`) is anchored, centred on `cx`. +fn vband(cx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(cx - nw / 2.0, ay), Point::new(cx + nw / 2.0, ay + sy * nh)) +} + +/// Frame whose left/right edge (at `ax`, growing in direction `sx`) is anchored, centred on `cy`. +fn hband(ax: f64, sx: f64, cy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(ax, cy - nh / 2.0), Point::new(ax + sx * nw, cy + nh / 2.0)) +} + +/// Largest `t` in `[lo, hi]` for which `ok` holds, given that it holds at `lo` and the set of good +/// `t` is an interval (the feasible region is convex). +fn bisect_range(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { + if ok(hi) { + return hi; + } + let (mut good, mut bad) = (lo, hi); + for _ in 0..48 { + let mid = (good + bad) / 2.0; + if ok(mid) { + good = mid; + } else { + bad = mid; + } + } + good +} + +fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { + bisect_range(lo, hi, ok) +} + +#[cfg(test)] +mod tests { + use super::*; + use CropHandle::*; + + const W: f64 = 600.0; + const H: f64 = 400.0; + const EPS: f64 = 1e-6; + + fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { + CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } + } + fn p(x: f64, y: f64) -> Point { + Point::new(x, y) + } + fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { + drag_crop(g, hd, from, to, W, H, aspect) + } + fn px_aspect(g: &CropGeometry) -> f64 { + g.rect.width() * W / (g.rect.height() * H) + } + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < EPS + } + + // Feature: dragging a crop handle + // Scenario: a free corner drag inside the image + #[test] + fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); + assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); + } + + // Scenario: dragging past the right edge (the weird behaviour of issue 295) + #[test] + fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); + assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); + assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); + } + + #[test] + fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { + let a = 1.5; + let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); + assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); + assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); + } + + #[test] + fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { + let a = 2.0; + let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); + let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); + assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); + assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); + assert!(near(px_aspect(&g), a)); + assert!(g.rect.height() > start.rect.height()); + } + + #[test] + fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); + assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); + } + + #[test] + fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { + let start = geo(0.5, 0.25, 0.9, 0.65); + let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); + assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); + assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); + assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); + } + + #[test] + fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); + assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); + assert!(near(g.rect.x0, 0.2)); + } + + #[test] + fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { + let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); + let c = start.rect.center(); + for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { + for aspect in [None, Some(1.5)] { + let g = drag(start, hd, c, p(3.0, -3.0), aspect); + assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); + assert_eq!(g.angle, 12.0); + } + } + } + + #[test] + fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { + let s = geo(0.2, 0.2, 0.8, 0.8); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); + } + assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); + } + + // Scenario: a locked edge drag when the frame already touches the image edge (review finding) + #[test] + fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { + let a = 1.5; + let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); + let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); + assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); + assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); + assert!(near(px_aspect(&g), a)); + assert!(g.is_within_image(W, H)); + } + + #[test] + fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { + let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); + let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); + assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); + } + + // Scenario: extreme or hostile magnitudes + #[test] + fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { + let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); + let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); + assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); + } + + #[test] + fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { + let a = 1.5; + let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); + assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); + } + + #[test] + fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); + assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); + } + + #[test] + fn handle_numbering_matches_the_overlay() { + assert_eq!(CropHandle::from_index(0), Some(TopLeft)); + assert_eq!(CropHandle::from_index(6), Some(Bottom)); + assert_eq!(CropHandle::from_index(8), Some(Move)); + assert_eq!(CropHandle::from_index(9), None); + assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); + assert_eq!(CropHandle::from_name("nope"), None); + } + + mod props { + use super::*; + use proptest::prelude::*; + + fn any_handle() -> impl Strategy { + (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) + } + + proptest! { + /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. + #[test] + fn drag_is_always_inside_anchored_and_shaped( + angle in -45.0f64..45.0, + ratio in prop::option::of(0.3f64..4.0), + hd in any_handle(), + tx in -4.0f64..5.0, ty in -4.0f64..5.0, + shrink in 0.2f64..1.0, + ) { + let base = crate::crop_fit_angle(W, H, angle, ratio); + // a smaller start crop so there is room to move + let c = base.rect.center(); + let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; + prop_assume!(start.is_within_image(W, H)); + let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); + prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); + prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); + let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; + prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); + if let Some(a) = ratio { + prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); + } + // anchors: edges opposite the handle do not move + let (s, r) = (start.rect, g.rect); + let keep_l = matches!(hd, TopRight | BottomRight | Right); + let keep_r = matches!(hd, TopLeft | BottomLeft | Left); + let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); + let keep_b = matches!(hd, TopLeft | TopRight | Top); + { + if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } + if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } + if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } + if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } + } + if hd == Move { + prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); + } + } + + #[test] + fn drag_never_panics_on_arbitrary_numbers( + x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, + angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, + ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), + ) { + let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); + prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); + } + } + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 6de121b0..1d81279d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -6,10 +6,12 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod crop; +mod crop_drag; mod homography; mod real; pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale}; +pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop}; pub use homography::Homography; pub use real::{Interval, Real}; diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 5c50ca04..fcdea55f 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -155,8 +155,16 @@ pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> /// If the init marker `path` is present — the last process died while creating the GPU device — /// remove it and return what it recorded. pub fn take_init_marker(path: &std::path::Path) -> Option { - let text = std::fs::read_to_string(path).ok()?; + let text = read_init_marker(path)?; let _ = std::fs::remove_file(path); + Some(text) +} + +/// What the init marker `path` recorded, if it is present — without removing it. For sessions +/// that must leave the disk as they found it (`--memory`, issues #164 and #169): GPU rendering +/// still starts off, and the marker stays for the next ordinary launch to report and clear. +pub fn read_init_marker(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; Some(text.trim().to_string()) } @@ -225,10 +233,13 @@ mod tests { set_init_marker(None); assert!(seen, "the marker exists while the device is created"); assert!(!m.exists(), "and is removed afterwards"); - // a marker left behind by a crashed process is reported once + // a marker left behind by a crashed process is reported once; reading it leaves it in place std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap(); + assert!(read_init_marker(&m).unwrap().contains("DX12")); + assert!(m.exists(), "read_init_marker leaves the marker for the next launch"); assert!(take_init_marker(&m).unwrap().contains("DX12")); assert_eq!(take_init_marker(&m), None); + assert_eq!(read_init_marker(&m), None); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index d6a838b0..bb30bc14 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -905,7 +905,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho Some(2) } MaskShape::ColorRange { samples, refine } => { - let tol = 0.04 + 0.16 * (*refine as f32 / 100.0); + let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine); p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]); aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32))); Some(3) diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index c16b46cb..00cac8bc 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -639,3 +639,24 @@ fn output_spaces_match() { } } } + +#[test] +fn tint_directions_match_cpu_for_raw_and_rendered_sources() { + if !gpu() { + return; + } + let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3])); + for info in [ + SourceInfo::default(), + SourceInfo { raw: true, relative_wb: true, ..Default::default() }, + SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() }, + ] { + for delta in [-50.0, 50.0] { + let mut s = DevelopSettings::default(); + s.wb.mode = WbMode::Custom; + s.wb.temp = info.as_shot_temp; + s.wb.tint = info.as_shot_tint + delta; + check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64)); + } + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 162ccce4..bfc5ea1e 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -61,7 +61,7 @@ impl Headless { fn export(&mut self, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch}; let p = &self.session.export_params(p)?; - let mut opts = ExportOptions::from_json(p); + let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); } @@ -107,7 +107,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?, limitKb?, colorSpace?, bitDepth?, sharpen?, metadata?, watermark?, naming?}", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c613bf37..ee17e1ad 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,6 +120,25 @@ fn path_writes_never_replace_an_original() { let _ = std::fs::remove_dir_all(&base); } +/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless +/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default. +#[test] +fn export_refuses_unknown_params_and_bad_values() { + let mut b = Headless::demo(); + let out = "/nonexistent-lc-test/x.jpg"; + let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err(); + assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err(); + assert!(e.contains("`longEdge` must be a number"), "{e}"); + let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err(); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}"); + // `export` tool and preset expansion go through the same check + let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101})); + assert!(r.is_error, "{r:?}"); + let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string(); + assert!(e.contains("did you mean `quality`"), "{e}"); +} + /// The `import` helper moves: renamed into the folder template, the source removed. #[test] fn import_tool_moves_with_a_folder_template() { @@ -205,3 +224,24 @@ fn resources() { } assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002); } + +/// `select_photos` with an id that is not in the library is a tool error, and the photo that was +/// active stays active (#182). +#[test] +fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() { + let mut b = Headless::demo(); + let first = b.session.visible_cloned()[0]; + let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]})); + assert!(!r.is_error, "{r:?}"); + let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]})); + assert!(r.is_error, "{r:?}"); + assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}"); + assert_eq!(b.session.active(), Some(first)); + let r = call_tool(&mut b, "get_develop", &json!({})); + assert!(!r.is_error, "{r:?}"); + let r = call_tool(&mut b, "get_develop", &json!({"id": 9999})); + assert!(r.is_error, "{r:?}"); + let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}})); + assert!(r.is_error, "{r:?}"); + assert_eq!(b.session.active(), Some(first)); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c5419425..1b41d86a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,6 +102,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, "destination": {"type": "string", "description": "Copy or move: destination folder"}, "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, + "onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"}, "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["paths"], @@ -205,7 +206,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, - "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], ), @@ -475,7 +476,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { return ToolResult::error(format!("no photos found in {paths:?}")); } let mut p = json!({"paths": files}); - for k in ["album", "mode", "destination", "organize", "rename"] { + for k in ["album", "mode", "destination", "organize", "rename", "onDeleted"] { if let Some(v) = args.get(k).filter(|v| !v.is_null()) { p[k] = v.clone(); } diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index df016b5d..360c7424 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result if lightcraft_raw::probe(bytes).is_some() { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; // geometric lens corrections stay with the develop settings ("profile corrections") - raw.opcodes - .list3 - .retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); 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()))?; diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index c34d3311..45100a1e 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -191,3 +191,28 @@ mod bw_tests { assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]); } } + +#[cfg(test)] +mod tint_tests { + use super::*; + + #[test] + fn auto_wb_and_picker_correct_green_with_positive_tint() { + // The picker delegates a sampled patch to this same auto_wb implementation. + for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] { + let img = Rgb32f::filled(16, 16, rgb); + let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() }; + let (temp, tint) = auto_wb(&img, &info); + assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}"); + let mut s = DevelopSettings::default(); + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = temp; + s.wb.tint = tint; + let mut corrected = img; + crate::local::white_balance(&mut corrected, &info, &s); + let p = corrected.get(0, 0); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread < 0.003, "{rgb:?} -> {p:?}"); + } + } +} diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index a71667bc..96911e8b 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -127,7 +127,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R } } MaskShape::ColorRange { samples, refine } => { - let tol = 0.04 + 0.16 * (*refine as f32 / 100.0); + let tol = color_range_tolerance(*refine); let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); for (v, c) in out.data.iter_mut().zip(&img.data) { let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); @@ -257,6 +257,21 @@ pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] { c.map(|v| v / (1.0 + v)) } +/// Colour range: the OkLab distance (a, b at full weight, L at a quarter) at which a pixel leaves +/// the selection for a Refine amount of 0..100. A pixel is fully selected up to half of it and +/// fades out over the other half (CPU here, `mask.wgsl` kind 3; both take this value). +/// +/// Issue #157: `0.04 + 0.16·refine` selected the whole photo. Through [`tonemap_for_select`] a +/// slightly teal mid-grey (sRGB 120,150,155) and a neutral light grey (200,198,195) are only ~0.063 +/// apart — L 0.60 vs 0.72, a/b ≤ 0.02 — so at the default Refine 50 (full selection below 0.06) +/// the grey patch was 99 % selected, and at Refine 85 / 100 still most of it. A JND is ~0.01 on +/// this scale, so Refine 50 now holds a surface up to ~2 JND from its sample and drops everything +/// beyond ~4.5 (full to 0.0225, gone at 0.045); Refine 85 is gone at 0.066, past that grey patch; +/// Refine 100 reaches 0.075 and Refine 0 is the sample's own shade (full to 0.0075, gone at 0.015). +pub fn color_range_tolerance(refine: f64) -> f32 { + 0.015 + 0.06 * (refine / 100.0).clamp(0.0, 1.0) as f32 +} + /// A brush stroke resolved to output pixels: dab centres (spacing r/4 along the path), radius, /// hard-core radius, flow and density (0..1), and whether it erases. pub struct BrushDabs { @@ -598,4 +613,42 @@ mod tests { let (plain, sharp) = (step(&soft), step(&refined)); assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); } + + /// Issue #157: a colour-range sample of one flat patch must not select a clearly different + /// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), + /// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). + #[test] + fn color_range_keeps_to_the_sampled_colour() { + use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; + let (w, h) = (120usize, 40usize); + let m = SRGB.to_space(&REC2020); + let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); + let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); + let log_l = img.map(crate::local::log_lum); + let f = Frame::new(w, h, &Default::default(), true); + let mut sample = [0f64; 3]; + for y in 19..=21 { + for x in 29..=31 { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); + for k in 0..3 { + sample[k] += lab[k] as f64 / 9.0; + } + } + } + let alpha = |refine| { + let shape = MaskShape::ColorRange { samples: vec![sample], refine }; + let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0); + (a.get(30, 20), a.get(90, 20)) + }; + for refine in [0.0, 50.0, 85.0] { + let (l, r) = alpha(refine); + assert!(l > 0.95, "refine {refine}: sampled patch {l}"); + assert!(r < 0.05, "refine {refine}: other patch {r}"); + } + // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the + // other patch stays mostly out + let (l, r) = alpha(100.0); + assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); + } } diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index ad789fc8..f99a3cd8 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -11,7 +11,8 @@ //! dimensionless scales), so a 400 px preview and a full-size export are warped identically. //! //! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`]. -//! Profile corrections use only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`). +//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the +//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion). use std::sync::Mutex; diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 00578bda..697a0f97 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -359,3 +359,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { assert!(blue(&pro) > 0); assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } + +#[test] +fn tint_negative_is_green_and_positive_is_magenta() { + use lightcraft_develop::WbMode; + let src = Rgb32f::filled(16, 16, [0.18; 3]); + // Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint. + for info in [ + SourceInfo::default(), + SourceInfo { raw: true, relative_wb: true, ..Default::default() }, + SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() }, + ] { + let mut s = DevelopSettings::default(); + let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8); + assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1); + assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1); + s.wb.mode = WbMode::Custom; + s.wb.temp = info.as_shot_temp; + for delta in [-50.0, 50.0] { + s.wb.tint = info.as_shot_tint + delta; + let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8); + let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64; + assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}"); + } + } +} diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index ec7c7f7d..a40fcc43 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -24,3 +24,5 @@ zune-core = { workspace = true } [dev-dependencies] jpeg-encoder = { workspace = true } proptest = { workspace = true } +# Pinned CC0 corpus identities; not a product dependency. +sha2 = { version = "0.10", default-features = false, features = ["std"] } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f67649de..599a82a5 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -13,10 +13,10 @@ //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), //! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer -//! and X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, +//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, //! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed). //! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF, -//! compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources +//! compressed ORF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] @@ -43,7 +43,7 @@ pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; pub use lightcraft_raster::Rgb32f; pub use opcodes::{Opcode, OpcodeLists}; -pub use preview::embedded_preview; +pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space}; use lightcraft_color::Xy; use lightcraft_tiff::{Tiff, TiffError}; @@ -159,9 +159,9 @@ pub fn decode(bytes: &[u8]) -> Result { /// measure them from the samples). /// /// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon -/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF -/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to -/// decompress) and dropped. +/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA +/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for +/// those the samples are read (unpacked, nothing to decompress) and dropped. pub fn probe_info(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Header).map(RawImage::into_info) } @@ -185,7 +185,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Raf => vendor::raf::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), - RawFormat::Orf => vendor::orf::decode(bytes), + RawFormat::Orf => vendor::orf::decode(bytes, mode), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index f63eaf17..bca39774 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -98,6 +98,13 @@ impl Opcode { pub fn is_applied(&self) -> bool { !matches!(self, Opcode::Unknown { .. } | Opcode::TrimBounds { .. } | Opcode::WarpFisheye { .. }) } + + /// Whether this is a lens correction (`WarpRectilinear`, `FixVignetteRadial`): from a DNG, or the equivalent of + /// a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these through the + /// "profile corrections" of the develop settings remove them before demosaicing. + pub fn is_lens_correction(&self) -> bool { + matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. }) + } } /// The three opcode lists of a DNG raw IFD. diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index ded35d29..42b5d70b 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -5,6 +5,30 @@ use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::tags as t; use lightcraft_tiff::{Ifd, Tiff, makernote}; +/// Colour space of an embedded JPEG when the enclosing raw supplies it instead of the JPEG. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreviewColorSpace { + Srgb, + AdobeRgb, +} + +/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation. +/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence. +pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { + let tiff = Tiff::parse(bytes).ok()?; + let make = tiff.ifds.first()?.string(t::MAKE)?; + if !make.to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + match mn.ifd.u16(0x001e)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + /// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless. fn is_dct_jpeg(b: &[u8]) -> bool { if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 { diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index d29e45f4..d6529ece 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -15,14 +15,15 @@ //! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image //! area; the masked columns left of it give the black level. //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known -//! offsets and accept the first plausible quadruple. +//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED +//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. //! - sRAW / mRAW (YCbCr, subsampled) are not supported yet. use super::{black_from_columns, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; -use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t}; const CR2_SLICE: u16 = 0xc640; const SRAW_TYPE: u16 = 0xc6c5; @@ -46,6 +47,17 @@ fn cfa_from_tag(v: Option) -> Option { Some(Cfa::bayer_static(name)) } +/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED +/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4; +/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by +/// one. Other types are returned value by value. +fn words(v: &Value, order: ByteOrder) -> Vec { + match v { + Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(), + _ => v.to_u64_vec(), + } +} + /// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array. fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] { @@ -134,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v)); + let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let black = if active.x >= 8 { black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active) @@ -175,6 +187,11 @@ pub(crate) mod tests { /// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame. pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec, Vec) { + synthetic_cr2_note(w, h, slices, None) + } + + /// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored). + fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> (Vec, Vec) { let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); let widths: Vec = match slices { Some([n, sw, last]) => { @@ -200,6 +217,9 @@ pub(crate) mod tests { // maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header let mut exif = IfdBuilder::new(); exif.set(t::ISO_SPEED, Value::Short(vec![400])); + if let Some(n) = note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } ifd0.set_child(t::EXIF_IFD, exif); let mut ifd3 = IfdBuilder::new(); ifd3.set(t::COMPRESSION, Value::Short(vec![6])); @@ -251,4 +271,62 @@ pub(crate) mod tests { assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4); assert!(wb_from_color_balance(&[0; 10]).is_none()); } + + #[test] + fn wb_from_undefined_bytes() { + // hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641 + let mut b = vec![0u8; 2 * 120]; + b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let le = words(&Value::Undefined(b.clone()), ByteOrder::Little); + assert_eq!(&le[71..75], &[1988, 745, 745, 1641]); + let wb = wb_from_color_balance(&le).unwrap(); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5); + // read as single bytes (the old behaviour) nothing is plausible + assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::>()).is_none()); + // big-endian note: same words, bytes swapped + let mut be = b.clone(); + be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1)); + assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le); + // SHORT values pass through, an odd trailing byte is ignored + assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]); + assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]); + } + + /// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain + /// IFD whose value offset is relative to the TIFF header (patched once the note's position is known). + fn note_with_color_data(ty: u16, data: &[u8]) -> Vec { + let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32; + let mut note = Vec::new(); + note.extend_from_slice(&1u16.to_le_bytes()); + note.extend_from_slice(&COLOR_BALANCE.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below + note.extend_from_slice(&0u32.to_le_bytes()); // next IFD + note.extend_from_slice(data); + note + } + + fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data))); + let tiff = Tiff::parse(&bytes).unwrap(); + let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let off = e.offset as usize; + bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes()); + crate::decode(&bytes).unwrap() + } + + /// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71, + /// exactly as the same words stored as SHORT do. + #[test] + fn color_data_stored_as_bytes() { + let mut bytes = vec![0u8; 2 * 120]; + bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641 + for ty in [7, 3] { + let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB")); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}"); + } + } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 2de155ef..0ef04778 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -7,6 +7,7 @@ pub mod nefc; pub mod orf; pub mod pef; pub mod raf; +mod rafc; pub mod rw2; use crate::{BlackLevel, Rect}; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index bf386bb4..8d1898e0 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -17,6 +17,19 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote}; const WB_RB_LEVELS: u16 = 0x000c; const BLACK_LEVEL: u16 = 0x003d; const LINEARIZATION_TABLE: u16 = 0x0096; +const CROP_AREA: u16 = 0x0045; + +/// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation). +/// Keep the active area/CFA origin unchanged: the default crop is applied after demosaicing. +fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { + let fallback = Rect::new(0, 0, w, h); + let Some(values) = mn.and_then(|m| m.ifd.u64s(CROP_AREA)) else { return fallback }; + let [x, y, width, height] = values.as_slice() else { return fallback }; + if *width == 0 || *height == 0 || x.checked_add(*width).is_none_or(|v| v > w as u64) || y.checked_add(*height).is_none_or(|v| v > h as u64) { + return fallback; + } + Rect::new(*x as usize, *y as usize, *width as usize, *height as usize) +} fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() @@ -80,9 +93,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { }; let white = white_from_data(samples, bits); let active_w = trailing_masked_columns(samples, w, h, white); + let crop = default_crop(mn.as_ref(), active_w, h); let mut metadata = lightcraft_meta::from_tiff(&tiff); - metadata.width = Some(active_w as u32); - metadata.height = Some(h as u32); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -94,7 +108,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { black, white: vec![white], active_area: Rect::new(0, 0, active_w, h), - crop: Rect::new(0, 0, active_w, h), + crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), color: ColorData::default(), wb_multipliers: wb, @@ -181,6 +195,42 @@ mod tests { TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + #[test] + fn preview_color_space_is_read_from_the_nikon_note() { + for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] { + let mut mn = IfdBuilder::new(); + mn.set(0x001e, Value::Short(vec![value])); + let bytes = nef_with_note(1, 14, vec![vec![0; 128]], 8, 8, 8, Some(mn)); + assert_eq!(crate::embedded_preview_color_space(&bytes), expected); + } + assert_eq!(crate::embedded_preview_color_space(&nef(1, 14, vec![vec![0; 128]], 8, 8, 8)), None); + assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None); + } + + #[test] + fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() { + let (w, h) = (32, 24); + let words: Vec = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect(); + for (values, expected) in [ + (vec![2, 4, 28, 16], Rect::new(2, 4, 28, 16)), + (vec![31, 4, 28, 16], Rect::new(0, 0, 32, 24)), + (vec![2, 4, 0, 16], Rect::new(0, 0, 32, 24)), + (vec![u32::MAX, 4, 28, 16], Rect::new(0, 0, 32, 24)), + (vec![2, 4, 28], Rect::new(0, 0, 32, 24)), + ] { + let mut mn = IfdBuilder::new(); + mn.set(CROP_AREA, Value::Long(values)); + let bytes = nef_with_note(1, 14, vec![words.clone()], w, h, h, Some(mn)); + let raw = crate::decode(&bytes).unwrap(); + assert_eq!(raw.crop, expected); + assert_eq!(raw.active_area, Rect::new(0, 0, 32, 24)); + assert_eq!(raw.metadata.width, Some(expected.width as u32)); + assert_eq!(raw.metadata.height, Some(expected.height as u32)); + let developed = raw.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (expected.width, expected.height)); + } + } + #[test] fn black_level_tag_is_in_14_bit_units() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/src/vendor/nefc.rs b/crates/raw/src/vendor/nefc.rs index c0260d95..8ac9e73c 100644 --- a/crates/raw/src/vendor/nefc.rs +++ b/crates/raw/src/vendor/nefc.rs @@ -30,7 +30,13 @@ //! first two pixels of the previous row of the same parity, starting at the seeds of maker note `0x0096`. //! - Lossless (`0x0096` version `0x46`): the decoded values are the samples. Lossy (version `0x44`): they index a //! curve. Type 1 (`0x44 0x10`) stores the full curve; type 2 (`0x44 0x20`, `0x44 0x40`) stores 257 points, -//! one every `2^bits / 256` codes, linearly interpolated. +//! one every `2^bits / 256` codes (`0x20`) or `2^bits / 1024` codes (`0x40`), linearly interpolated. The span +//! was established from the files: in every `0x20` file the first interval of the 257 points rises by +//! `2^bits / 256` (16 at 12 bit, 64 at 14 bit) and in every `0x40` file by `2^bits / 1024` (4 and 16), i.e. +//! the curve is the identity in the dark part, one code per sample. Read with a point every `2^bits / 256` +//! codes instead, the 12 `0x40` files that are not split decoded to values that never exceeded 255 (12 bit) or +//! 1023 (14 bit) and whose darkest 0.01 % sat at a quarter of the black level of maker note `0x003d` (63 against +//! 252 in 12-bit units); with the first-interval step they sit at that black level. //! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes). //! //! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead): @@ -127,8 +133,10 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result range || !range.is_multiple_of(n - 1) { return Err(RawError::Unsupported(format!("NEF: {n}-point lossy curve for {bits}-bit data"))); } @@ -355,6 +363,60 @@ mod tests { } } + /// Maker note `0x0096` as bytes, written out by hand: version `0x44 `, four seeds, 257 points (little-endian + /// as newer bodies write it) of a curve whose first 256 points rise by `step` and whose last is the white + /// level, and a zero split value at offset 562. + fn hand_table(v1: u8, seed: u16, step: u16, white: u16) -> Vec { + let mut t = vec![0x44, v1]; + for _ in 0..4 { + t.extend_from_slice(&seed.to_le_bytes()); + } + t.extend_from_slice(&257u16.to_le_bytes()); + for k in 0..256u16 { + t.extend_from_slice(&(k * step).to_le_bytes()); + } + t.extend_from_slice(&white.to_le_bytes()); + t.resize(624, 0); + t + } + + /// Known answers for the `0x44 0x40` curve, from bytes and bit strings written by hand (nothing here uses the + /// decoder's span rule or the test encoder): the first interval of the points rises by `2^bits / 1024`, so + /// the curve is the identity for one code per sample and spans `2^bits / 4` codes. The `0x20` curve of the + /// same sizes keeps its span of all `2^bits` codes. + #[test] + fn lossy_type2_version_0x40_spans_a_quarter_of_the_range() { + // 12 bit: points 0, 4, 8 … 1020, 4095 + let t = parse_table(&hand_table(0x40, 252, 4, 4095), ByteOrder::Little, 12).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!(curve.len(), 1025); + assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]); + assert_eq!((curve[252], curve[255], curve[1020]), (252, 255, 1020)); + assert_eq!((curve[1021], curve[1022], curve[1023], curve[1024]), (1788, 2557, 3326, 4095)); + // the pixels p0 = 252 + 4, p1 = 252, p2 = p0 - 1, p3 = p1, coded as 011 100 | 11110 | 1110 0 | 11110 + // (LOSSY_12: category 3 = 011, 0 = 11110, 1 = 1110; extra bits 100 = +4, 0 = -1) + let out = decode(&[0x73, 0xdc, 0xf0], 4, 1, 12, &t).unwrap(); + assert_eq!(out, [256, 252, 255, 252]); + // 14 bit: points 0, 16, 32 … 4080, 16383 + let t = parse_table(&hand_table(0x40, 1008, 16, 16383), ByteOrder::Little, 14).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!(curve.len(), 4097); + assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]); + assert_eq!((curve[1008], curve[4080], curve[4096]), (1008, 4080, 16383)); + // the same pixels coded as 1100 100 | 111110 | 11110 0 | 111110 + // (LOSSY_14: category 3 = 1100, 0 = 111110, 1 = 11110) + let out = decode(&[0xc9, 0xf7, 0x9f, 0x00], 4, 1, 14, &t).unwrap(); + assert_eq!(out, [1012, 1008, 1011, 1008]); + // version 0x20 with the same points is a curve over all 2^bits codes: the points are 16 codes apart + let t = parse_table(&hand_table(0x20, 252, 16, 4095), ByteOrder::Little, 12).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!((curve.len(), curve[16], curve[17], curve[4095]), (4097, 16, 17, 4094)); + // 200 points can't be spaced evenly over the 1024 codes of a 12-bit 0x40 table + let mut odd = hand_table(0x40, 252, 4, 4095); + odd[10..12].copy_from_slice(&200u16.to_le_bytes()); + assert!(parse_table(&odd, ByteOrder::Little, 12).is_err()); + } + #[test] fn lossy_type1_full_curve() { let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5]; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 52f597d2..59c5f14c 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -1,10 +1,10 @@ //! Olympus ORF — uncompressed variants. //! -//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), the ExifTool -//! Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — `0x0101/0x0102` -//! PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` BlackLevel2, -//! `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from raw.pixls.us -//! (E-1, E-400, XZ-2): +//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), Exif 2.3 (`CFAPattern`, +//! tag `0xa302`), the ExifTool Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — +//! `0x0101/0x0102` PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` +//! BlackLevel2, `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from +//! raw.pixls.us (E-1, E-400, XZ-2): //! //! - 16 bits per sample: little-endian words; some bodies (E-1, E-400) store 12-bit values in the top bits (the low //! four bits zero in more than 99% of samples), which we shift down. @@ -12,14 +12,16 @@ //! candidate bit orders for the smoothest image). //! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively //! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works. -//! - The files carry no CFA tag. The green diagonal is found from the data (the diagonal whose two samples differ -//! least); bodies with greens on the main diagonal (E-1, E-400) are GRBG, the others (XZ-2) RGGB — verified by -//! colour renders. +//! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where +//! the colours of the decoded mosaic follow the embedded JPEG best with exactly that layout +//! (`corpus_orf_cfa_patterns`). The samples' active areas start at even offsets, so they can't tell whether the +//! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back +//! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed). use super::white_from_data; use crate::tiffraw::{Packing, read_image}; use crate::unpack::unpack_msb; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::makernote::MakerNote; @@ -33,6 +35,8 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; +/// Exif `CFAPattern`. +const EXIF_CFA_PATTERN: u16 = 0xa302; /// The Olympus maker note. pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { @@ -66,23 +70,52 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } -/// GRBG when the greens sit on the main diagonal of the 2×2 cell at the sensor origin, else RGGB. +/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either +/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell. +fn cfa_from_exif(tiff: &Tiff) -> Option { + let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None }; + let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2)); + let name = match [s0, s1, s2, s3] { + [0, 1, 1, 2] => "RGGB", + [2, 1, 1, 0] => "BGGR", + [1, 0, 2, 1] => "GRBG", + [1, 2, 0, 1] => "GBRG", + _ => return None, + }; + (two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name)) +} + +/// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main +/// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both +/// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the +/// same light, red and blue rarely do. Totals rather than single pixels, so that fine texture, which makes +/// neighbouring greens differ, doesn't outweigh a small difference between red and blue. pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { - let (x0, y0) = (a.x + ((a.width / 8) & !1), a.y + ((a.height / 8) & !1)); - let (x1, y1) = (a.x + a.width * 7 / 8, a.y + a.height * 7 / 8); + const BLOCK: usize = 32; + let (x0, y0) = (a.x.saturating_add(1) & !1, a.y.saturating_add(1) & !1); + let across = a.x.saturating_add(a.width).min(w).saturating_sub(x0) / BLOCK; + let down = a.y.saturating_add(a.height).saturating_sub(y0) / BLOCK; let (mut main, mut anti) = (0u64, 0u64); - for y in (y0..y1.saturating_sub(1)).step_by(16) { - for x in (x0..x1.saturating_sub(1)).step_by(8) { - let (x, y) = (x & !1, y & !1); - let p = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as i64; - main += (p(0, 0) - p(1, 1)).unsigned_abs(); - anti += (p(1, 0) - p(0, 1)).unsigned_abs(); + for by in (0..down).step_by(4) { + for bx in (0..across).step_by(4) { + let mut sums = [0i64; 4]; + for y in 0..BLOCK { + let row = (y0 + by * BLOCK + y).checked_mul(w).and_then(|r| r.checked_add(x0 + bx * BLOCK)); + // rows past the end of the samples: nothing more to read + let Some(row) = row.and_then(|start| d.get(start..start.checked_add(BLOCK)?)) else { break }; + for (x, &v) in row.iter().enumerate() { + sums[(y & 1) * 2 + (x & 1)] += v as i64; + } + } + let [s0, s1, s2, s3] = sums; + main += (s0 - s3).unsigned_abs(); + anti += (s1 - s2).unsigned_abs(); } } Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let info = ifd0.image()?; @@ -90,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); + let stated = cfa_from_exif(&tiff); let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -97,9 +131,16 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { (d, 16) } else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?; - let stride = src.len() / h; - let mut d = vec![0u16; n]; - d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + // the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its + // colour-filter layout + let d = if mode == Mode::Full || stated.is_none() { + let stride = src.len() / h; + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + d + } else { + Vec::new() + }; (RawData::U16(d), 12) } else { return Err(RawError::Unsupported("Olympus compressed ORF".into())); @@ -128,7 +169,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch), _ => Rect::new(0, 0, w, h), }; - let cfa = cfa_from_data(samples, w, active); + let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active)); let black = match ip.as_ref().and_then(|i| i.f64s(BLACK)).as_deref() { Some(v @ [_, _, _, _]) => { let a = cfa.shifted(active.x, active.y); @@ -165,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -185,6 +226,11 @@ mod tests { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter}; fn orf(w: u32, h: u32, bits: u16, strip: Vec) -> Vec { + orf_with(w, h, bits, strip, None) + } + + /// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`. + fn orf_with(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>) -> Vec { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); @@ -193,12 +239,120 @@ mod tests { ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into())); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + if let Some(p) = cfa_pattern { + let mut exif = IfdBuilder::new(); + exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); b[2] = b'R'; b[3] = b'O'; b } + /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). + fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec { + (0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect() + } + + /// Smooth scene texture shared by the four sites of a cell. + fn shade(cx: usize, cy: usize) -> u16 { + ((cx * 7 + cy * 13) % 23) as u16 + } + + const BGGR: &[u8] = &[2, 0, 2, 0, 2, 1, 1, 0]; + const GRBG: &[u8] = &[2, 0, 2, 0, 1, 0, 2, 1]; + // repeat counts in the other byte order + const RGGB: &[u8] = &[0, 2, 0, 2, 0, 1, 1, 2]; + const GBRG: &[u8] = &[0, 2, 0, 2, 1, 2, 0, 1]; + + fn layout(bytes: &[u8]) -> String { + let r = crate::decode(bytes).unwrap(); + assert_eq!(crate::probe_info(bytes).unwrap(), r.info()); + r.cfa.unwrap().name() + } + + #[test] + fn exif_cfa_pattern_states_the_layout() { + let (w, h) = (160usize, 144usize); + // samples whose green diagonal alone would say RGGB + let px = mosaic(w, h, |cx, cy| [600, 1000, 1000, 300].map(|v| v + shade(cx, cy))); + for (tag, name) in [(BGGR, "BGGR"), (GRBG, "GRBG"), (RGGB, "RGGB"), (GBRG, "GBRG")] { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name); + } + // not a 2×2 Bayer cell (four greens, greens side by side, a fourth colour), other repeat counts, wrong + // lengths: the samples decide + let unusable: [&[u8]; 8] = [ + &[2, 0, 2, 0, 1, 1, 1, 1], + &[2, 0, 2, 0, 1, 1, 0, 2], + &[2, 0, 2, 0, 0, 1, 1, 3], + &[3, 0, 3, 0, 0, 1, 1, 2], + &[2, 2, 2, 0, 0, 1, 1, 2], + &[2, 0, 2, 0, 1, 0], + &[2, 0, 2, 0, 2, 1, 1, 0, 0], + &[], + ]; + for (site, name) in [([600, 1000, 1000, 300], "RGGB"), ([1000, 600, 300, 1000], "GRBG")] { + let px = mosaic(w, h, |cx, cy| site.map(|v| v + shade(cx, cy))); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px.clone())), name); + for tag in unusable { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name, "{tag:?}"); + } + } + } + + #[test] + fn packed_12_bit_probe_agrees_with_decode() { + let (w, h) = (16usize, 4usize); + let strip: Vec = (0..w * h * 3 / 2).map(|i| (i * 37 % 251) as u8).collect(); + for tag in [Some(BGGR), None] { + let bytes = orf_with(w as u32, h as u32, 12, strip.clone(), tag); + let r = crate::decode(&bytes).unwrap(); + assert_eq!((r.bits, r.data.len()), (12, w * h)); + assert_eq!(r.cfa.as_ref().unwrap().name(), if tag.is_some() { "BGGR" } else { "RGGB" }); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + } + } + + #[test] + fn data_fallback_looks_past_fine_texture() { + let (w, h) = (160usize, 144usize); + // greens on the main diagonal, red and blue close; a checkerboard of detail makes the two greens of a cell + // differ by far more than red differs from blue + let detail = |cx: usize, cy: usize| if (cx + cy) & 1 == 0 { 150 } else { -150i32 }; + let site = |cx: usize, cy: usize| { + let (g, d) = (1000 + shade(cx, cy) as i32, detail(cx, cy)); + [(g + d) as u16, 500, 520, (g - d) as u16] + }; + assert_eq!(layout(&orf(w as u32, h as u32, 16, mosaic(w, h, site))), "GRBG"); + // the same scene with the greens on the other diagonal + let px = mosaic(w, h, |cx, cy| { + let [g0, r, b, g1] = site(cx, cy); + [r, g0, g1, b] + }); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px)), "RGGB"); + } + + #[test] + fn data_fallback_survives_any_area() { + let d = vec![100u16; 64 * 64]; + let big = usize::MAX; + for a in [ + Rect::new(0, 0, 64, 64), + Rect::new(0, 0, 0, 0), + Rect::new(0, 0, 31, 31), + Rect::new(63, 63, 1, 1), + Rect::new(0, 0, 4096, 4096), + Rect::new(big, big, big, big), + Rect::new(0, big - 40, 64, 40), + ] { + for w in [64, 0, 1, big] { + assert_eq!(cfa_from_data(&d, w, a).name(), "RGGB", "{a:?} width {w}"); + assert_eq!(cfa_from_data(&[], w, a).name(), "RGGB", "{a:?} width {w}"); + } + } + } + #[test] fn word16_shifted_and_packed12() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 2505db13..9d950121 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -1,4 +1,4 @@ -//! Fujifilm RAF — uncompressed Bayer and X-Trans. +//! Fujifilm RAF — uncompressed, lossless compressed and lossy compressed Bayer and X-Trans. //! //! Sources: the ExifTool FujiFilm tag-name documentation (RAF header record tags `0x0100` RawImageFullSize, //! `0x0110` RawImageCropTopLeft, `0x0111` RawImageCroppedSize, `0x0131` XTransLayout; raw-IFD tags `0xf001` @@ -17,7 +17,7 @@ //! is anchored at raw pixel (0, 0) — verified on X-Trans I and III samples by minimising the difference //! between horizontally / vertically adjacent "green" samples over all 72 orientations/phases, and by colour //! renders. Bayer bodies (X-A series) use RGGB at (0, 0), verified by colour renders. -//! - Fujifilm's compressed RAF variants are reported as unsupported (their embedded preview still works). +//! - Striped predictive compression is decoded by `rafc`; see `docs/raf-compression.md`. use super::white_from_data; use crate::unpack::{unpack_lsb, unpack_msb}; @@ -118,35 +118,56 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; let ifd = raw_ifd(raw)?; - let get = |t: u16| ifd.u64(t).map(|v| v as usize); - let (w, hgt) = (get(0xf001).unwrap_or(0), get(0xf002).unwrap_or(0)); - let bits = get(0xf003).unwrap_or(0) as u32; - let (off, len) = (get(0xf007).unwrap_or(0), get(0xf008).unwrap_or(0)); + let get = |t: u16| ifd.u64(t).map(|v| usize::try_from(v).map_err(|_| RawError::Limit("RAF IFD value too large"))).transpose(); + let (w, hgt) = (get(0xf001)?.unwrap_or(0), get(0xf002)?.unwrap_or(0)); + let bits = ifd.u64(0xf003).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let (off, len) = (get(0xf007)?.unwrap_or(0), get(0xf008)?.unwrap_or(0)); if w == 0 || hgt == 0 || !(8..=16).contains(&bits) { return Err(RawError::Corrupt(format!("RAF raw IFD: {w}x{hgt}, {bits} bits"))); } let n = w.checked_mul(hgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; - // samples of any depth may be stored in 16-bit words (e.g. 14-bit X-T20); otherwise packed at `bits` - let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits }; - let packed_row = if store == 16 { w * 2 } else { (w * store as usize).div_ceil(8) }; - let stride = match get(0xf005).unwrap_or(0) { - 0 => packed_row, - words => (words * 4).max(packed_row), + let src = raw + .get(off..off.checked_add(len).ok_or_else(|| RawError::Corrupt("RAF strip size overflow".into()))?) + .ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; + let cfa = cfa(&h); + let compression = be32(bytes, 108).unwrap_or(0); + let compressed = match compression { + 0 => false, + 2 | 3 => true, + _ => return Err(RawError::Unsupported(format!("Fujifilm RAF compression mode {compression}"))), }; - if (len as u64) < (stride as u64) * (hgt as u64 - 1) + packed_row as u64 { - return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)"))); - } - let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; let mut data = Vec::new(); - if mode == Mode::Full { - data = vec![0u16; n]; - data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); - unpack_row(s, store, row); - }); + if compressed { + if cfa.width == 6 && cfa != Cfa::xtrans() { + return Err(RawError::Unsupported("Fujifilm compressed RAF with an unknown X-Trans layout".into())); + } + let flag = if compression == 2 { 1 } else { 0 }; + if src.get(2) != Some(&flag) { + return Err(RawError::Corrupt("RAF container and stream compression modes disagree".into())); + } + data = super::rafc::decode(src, w, hgt, bits, cfa.width == 6, mode)?; + } else { + // Samples may use 16-bit words (e.g. 14-bit X-T20); otherwise they are packed at `bits`. + let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits }; + let packed_row = w.checked_mul(store as usize).map(|n| n.div_ceil(8)).ok_or(RawError::Limit("RAF row too large"))?; + let stride = match get(0xf005)?.unwrap_or(0) { + 0 => packed_row, + words => words.checked_mul(4).ok_or(RawError::Limit("RAF stride too large"))?.max(packed_row), + }; + let required = stride.checked_mul(hgt - 1).and_then(|n| n.checked_add(packed_row)).ok_or(RawError::Limit("RAF strip too large"))?; + if len < required { + return Err(RawError::Unsupported(format!("Fujifilm RAF packing ({len} bytes for {w}x{hgt} {bits}-bit)"))); + } + if mode == Mode::Full { + data = vec![0u16; n]; + data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + // `required <= src.len()` proves these row offsets fit, including the final row. + let s = src.get(y * stride..y * stride + packed_row).unwrap_or(&[]); + unpack_row(s, store, row); + }); + } } - let cfa = cfa(&h); let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) { (Some((top, left)), Some((ch, cw))) if ch > 0 && cw > 0 => Rect::new(left, top, cw, ch).clipped(w, hgt), _ => Rect::new(0, 0, w, hgt), @@ -194,7 +215,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; /// Build a minimal RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD). @@ -247,6 +268,10 @@ mod tests { dir.extend_from_slice(&recs); let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); out.resize(108, 0); + if strip.starts_with(b"IS") { + let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 }; + out.extend_from_slice(&compression.to_be_bytes()); + } let jpeg_off = 160u32; let dir_off = jpeg_off + jpeg.len() as u32; let raw_off = dir_off + dir.len() as u32; @@ -310,8 +335,24 @@ mod tests { } let bytes = raf(w, h, 16, pack(&[0; 96], 16), None, b""); assert_eq!(crate::decode(&bytes).unwrap().cfa.unwrap().name(), "RGGB"); + let mut px = vec![0; 96]; + px[0] = 0x5349; + let mut bytes = raf(w, h, 16, pack(&px, 16), None, b""); + bytes[108..112].copy_from_slice(&0u32.to_be_bytes()); + assert_eq!(crate::decode(&bytes).unwrap().data, RawData::U16(px)); // too little data for the size: compressed let bytes = raf(w, h, 14, vec![0; 20], None, b""); assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_)))); } + + #[test] + fn overflowing_row_stride_is_an_error() { + let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]); + let off = be32(&bytes, 100).unwrap(); + // Replace WB with a LONG8 stride, pointing to the existing out-of-line WB storage. + bytes[off + 100..off + 104].copy_from_slice(&[0x05, 0xf0, 16, 0]); + bytes[off + 104..off + 108].copy_from_slice(&1u32.to_le_bytes()); + bytes[off + 116..off + 124].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!(crate::decode(&bytes), Err(RawError::Limit(_)))); + } } diff --git a/crates/raw/src/vendor/rafc.rs b/crates/raw/src/vendor/rafc.rs new file mode 100644 index 00000000..f3b925b5 --- /dev/null +++ b/crates/raw/src/vendor/rafc.rs @@ -0,0 +1,505 @@ +//! Fujifilm's striped predictive RAW compression. +//! +//! Clean-room implementation: Fabian's prose description of lossless RAF compression +//! (https://capnfabs.net/posts/fuji-raf-compression-algorithm/) supplied the broad structure; +//! headers, colour-vector placement, predictors, contexts and adaptation were recovered +//! from sample bytes and black-box comparisons of decoded sensor arrays. No other RAW +//! decoder's source was consulted. See docs/raf-compression.md for verification details. + +use crate::{Mode, RawError, Result}; +use rayon::prelude::*; + +const BLOCK: usize = 768; +const XTRANS: [[usize; 6]; 6] = + [[1, 1, 0, 1, 1, 2], [1, 1, 2, 1, 1, 0], [2, 0, 1, 0, 2, 1], [1, 1, 2, 1, 1, 0], [1, 1, 0, 1, 1, 2], [0, 2, 1, 2, 0, 1]]; +const MASKS: [[u8; 2]; 6] = [[10, 15], [15, 10], [14, 10], [15, 11], [11, 15], [10, 14]]; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("Fujifilm compressed RAF: {why}")) +} + +fn be16(src: &[u8], at: usize) -> Result { + let s = src.get(at..at.saturating_add(2)).ok_or_else(|| corrupt("truncated header"))?; + let [a, b] = s else { return Err(corrupt("truncated header")) }; + Ok(u16::from_be_bytes([*a, *b]) as usize) +} + +struct Stripe<'a> { + bytes: &'a [u8], + quantizers: Option<&'a [u8]>, + width: usize, +} + +pub(super) fn decode(src: &[u8], width: usize, height: usize, bits: u32, xtrans: bool, mode: Mode) -> Result> { + let h = src.get(..16).ok_or_else(|| corrupt("truncated header"))?; + if !h.starts_with(b"IS") { + return Err(corrupt("missing compression signature")); + } + let flag = h.get(2).copied().ok_or_else(|| corrupt("truncated header"))?; + if flag > 1 { + return Err(corrupt("invalid compression flag")); + } + let sensor = h.get(3).copied().ok_or_else(|| corrupt("truncated header"))?; + if sensor != if xtrans { 16 } else { 0 } { + return Err(corrupt("compression sensor type disagrees with CFA")); + } + if !matches!(bits, 12 | 14 | 16) { + return Err(RawError::Unsupported(format!("Fujifilm compressed RAF at {bits} bits"))); + } + let count = h.get(13).copied().ok_or_else(|| corrupt("truncated header"))? as usize; + if h.get(4).copied() != Some(bits as u8) + || be16(h, 5)? != height + || be16(h, 9)? != width + || count == 0 + || count != width.div_ceil(BLOCK) + || be16(h, 7)? != count * BLOCK + || be16(h, 11)? != BLOCK + || height == 0 + || !height.is_multiple_of(6) + || be16(h, 14)? != height / 6 + { + return Err(corrupt("inconsistent compression dimensions")); + } + let n = width.checked_mul(height).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let table_end = 16 + count * 4; + let mut offset = table_end.div_ceil(16) * 16; + let quantizer_start = offset; + let quantizer_stride = (height / 6).div_ceil(16) * 16; + if flag == 0 { + offset += quantizer_stride * count; + if src.get(quantizer_start..offset).is_none() { + return Err(corrupt("truncated quantizer tables")); + } + } + let mut stripes = Vec::with_capacity(count); + for i in 0..count { + let entry = src.get(16 + i * 4..20 + i * 4).ok_or_else(|| corrupt("truncated stripe size table"))?; + let [a, b, c, d] = entry else { return Err(corrupt("truncated stripe size table")) }; + let size = u32::from_be_bytes([*a, *b, *c, *d]) as usize; + let end = offset.checked_add(size).ok_or_else(|| corrupt("stripe size overflow"))?; + let bytes = src.get(offset..end).filter(|s| !s.is_empty()).ok_or_else(|| corrupt("stripe outside file"))?; + let quantizers = if flag == 0 { + Some( + src.get(quantizer_start + i * quantizer_stride..quantizer_start + i * quantizer_stride + height / 6) + .ok_or_else(|| corrupt("truncated quantizer table"))?, + ) + } else { + None + }; + stripes.push(Stripe { bytes, quantizers, width: (width - i * BLOCK).min(BLOCK) }); + offset = end; + } + if mode == Mode::Header { + return Ok(Vec::new()); + } + let decoded: Vec> = stripes.par_iter().map(|s| decode_stripe(s, height, bits, xtrans)).collect::>()?; + let mut output = vec![0; n]; + for (i, (stripe, data)) in stripes.iter().zip(&decoded).enumerate() { + for (row, samples) in output.chunks_exact_mut(width).zip(data.chunks_exact(stripe.width)) { + row.get_mut(i * BLOCK..i * BLOCK + stripe.width).ok_or_else(|| corrupt("invalid stripe placement"))?.copy_from_slice(samples); + } + } + Ok(output) +} + +struct Bits<'a> { + src: &'a [u8], + pos: usize, + acc: u64, + available: u32, +} + +impl<'a> Bits<'a> { + fn new(src: &'a [u8]) -> Self { + Self { src, pos: 0, acc: 0, available: 0 } + } + + #[inline] + fn get(&mut self, n: u32) -> Result { + while self.available < n { + let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?; + self.pos += 1; + self.acc = (self.acc << 8) | u64::from(b); + self.available += 8; + } + self.available -= n; + Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32) + } + + fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result { + let mut zeros = 0; + while self.get(1)? == 0 { + zeros += 1; + if zeros > limit { + return Err(corrupt("invalid residual escape")); + } + } + let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? }; + if code > range { + return Err(corrupt(&format!( + "residual {code} outside sample range {range} (k={k}, prefix={zeros}, bits={bits}, position={})", + self.pos * 8 - self.available as usize + ))); + } + Ok(if code & 1 == 0 { (code / 2) as i32 } else { -((code / 2) as i32) - 1 }) + } +} + +#[derive(Clone, Copy)] +struct State { + sum: u32, + count: u32, +} + +impl State { + fn k(self) -> u32 { + let mut k = 0; + while self.count << k < self.sum { + k += 1; + } + k + } + + fn update(&mut self, delta: i32) { + self.sum += delta.unsigned_abs(); + if self.count == 64 { + self.sum >>= 1; + self.count >>= 1; + } + self.count += 1; + } +} + +#[inline] +fn quant(v: i32, near: u32) -> i32 { + let t = [18 + 3 * near, 67 + 5 * near, 276 + 7 * near]; + let a = v.unsigned_abs(); + let bucket = if a <= near { + 0 + } else if a < t[0] { + 1 + } else if a < t[1] { + 2 + } else if a < t[2] { + 3 + } else { + 4 + }; + if v < 0 { -bucket } else { bucket } +} + +#[inline] +fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 { + let (a, b, c) = ((nw - n).abs(), (ne - n).abs(), (nn - n).abs()); + let pair = if a > b && a > c { + ne + nn + } else if b > a && b > c { + nw + nn + } else { + nw + ne + }; + (pair + 2 * n) >> 2 +} + +#[inline] +fn sample(rows: &[Vec], y: usize, x: usize) -> Result { + rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position")) +} + +fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result> { + let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 }; + let mut previous = vec![vec![vec![0; size + 2]; 2]; 3]; + let max = (1u32 << bits) - 1; + let range = |near: u32| (max + 2 * near) / (2 * near + 1) + 1; + let seed = |near: u32| State { sum: ((range(near) + 32) / 64).max(2), count: 1 }; + let mut states = [[[seed(0); 41]; 2]; 3]; + // Smooth regions retain finer precision and independent state across block quantizer changes. + let mut fine = [[[[seed(0); 5]; 2]; 3], [[[seed(1); 5]; 2]; 3], [[[seed(2); 5]; 2]; 3]]; + let mut last_near = None; + let mut reader = Bits::new(stripe.bytes); + let n = height.checked_mul(stripe.width).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let mut output = Vec::with_capacity(n); + for group in 0..height / 6 { + let near = match stripe.quantizers { + Some(q) => u32::from(*q.get(group).ok_or_else(|| corrupt("truncated quantizer table"))?), + None => 0, + }; + if last_near != Some(near) { + states = [[[seed(near); 41]; 2]; 3]; + last_near = Some(near); + } + let mut rows = previous; + for pair in 0..6 { + let colours = if pair % 2 == 0 { [0, 1] } else { [1, 2] }; + for &colour in &colours { + let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?; + let mut row = vec![0; size + 2]; + let prev = lines.len() - 1; + let left = sample(lines, prev, 1)?; + let right = sample(lines, prev, size)?; + if let Some(v) = row.first_mut() { + *v = left; + } + if let Some(v) = row.last_mut() { + *v = right; + } + lines.push(row); + } + for even in (0..size + 8).step_by(2) { + for (odd, position) in [(0, Some(even)), (1, even.checked_sub(7))] { + let Some(pos) = position.filter(|&x| x < size) else { continue }; + let x = pos + 1; + for (which, &colour) in colours.iter().enumerate() { + let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?; + let y = lines.len() - 1; + let (nw, north, ne, nn) = + (sample(lines, y - 1, x - 1)?, sample(lines, y - 1, x)?, sample(lines, y - 1, x + 1)?, sample(lines, y - 2, x)?); + let west = sample(lines, y, x - 1)?; + let east = sample(lines, y, x + 1)?; + let (predict, d1, d2) = if odd == 0 { + (even_prediction(nw, north, ne, nn), north - nn, nw - north) + } else { + let p = if (nw.min(ne)..=nw.max(ne)).contains(&north) { (west + east) >> 1 } else { (west + east + 2 * north) >> 2 }; + (p, north - nw, nw - west) + }; + let real = !xtrans || MASKS.get(pair).and_then(|m| m.get(which)).is_some_and(|m| (m >> (pos % 4)) & 1 != 0); + let value = if real { + let contrast = d1.unsigned_abs() + d2.unsigned_abs(); + let precision = match contrast { + 0..=5 => 0, + 6 => near.min(1), + 7 => near.min(2), + _ => near, + }; + let (context, state) = if precision < near { + let sign = |v: i32| if v.unsigned_abs() <= precision { 0 } else { v.signum() }; + let context = 3 * sign(d1) + sign(d2); + let state = fine + .get_mut(precision as usize) + .and_then(|f| f.get_mut(pair % 3)) + .and_then(|s| s.get_mut(odd)) + .and_then(|s| s.get_mut(context.unsigned_abs() as usize)) + .ok_or_else(|| corrupt("invalid fine coding context"))?; + (context, state) + } else { + let context = 9 * quant(d1, near) + quant(d2, near); + let state = states + .get_mut(pair % 3) + .and_then(|s| s.get_mut(odd)) + .and_then(|s| s.get_mut(context.unsigned_abs() as usize)) + .ok_or_else(|| corrupt("invalid coding context"))?; + (context, state) + }; + let coded_range = range(precision); + let coded_bits = 32 - (coded_range - 1).leading_zeros(); + let mut delta = reader.residual(state.k(), coded_bits, coded_range, 4 * bits - coded_bits - 1)?; + state.update(delta); + if context < 0 { + delta = -delta; + } + let step = (2 * precision + 1) as i32; + let mut value = predict + delta * step; + if value < -(precision as i32) { + value += coded_range as i32 * step; + } else if value > (max + precision) as i32 { + value -= coded_range as i32 * step; + } + value.clamp(0, max as i32) + } else { + predict + }; + *lines.get_mut(y).and_then(|r| r.get_mut(x)).ok_or_else(|| corrupt("invalid predictor position"))? = value; + } + } + } + } + for y in 0..6 { + for x in 0..stripe.width { + let colour = if xtrans { + XTRANS.get(y).and_then(|r| r.get(x % 6)).copied().ok_or_else(|| corrupt("invalid CFA position"))? + } else { + match (y % 2, x % 2) { + (0, 0) => 0, + (1, 1) => 2, + _ => 1, + } + }; + let row = 2 + if colour == 1 { y } else { y / 2 }; + let col = 1 + if xtrans { 2 * (x / 3) + (x % 3).min(1) } else { x / 2 }; + output.push(sample(rows.get(colour).ok_or_else(|| corrupt("invalid colour vector"))?, row, col)? as u16); + } + } + previous = rows.into_iter().map(|mut r| r.split_off(r.len() - 2)).collect(); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Cfa; + use proptest::prelude::*; + + /// Procedural zero residuals: no camera media or encoder dependency. + fn zero_stream(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec { + let groups = quantizers.map_or(3, <[u8]>::len); + let mut main = [[(1u32 << (bits - 6), 1u32); 2]; 3]; + let mut fine = main; + let mut previous = None; + let mut stream = Vec::new(); + for group in 0..groups { + let q = quantizers.map_or(0, |q| q[group]); + if previous != Some(q) { + main = [[(1u32 << (bits - 6), 1u32); 2]; 3]; + previous = Some(q); + } + for pair in 0..6 { + let width = if xtrans { 512 } else { 384 }; + for even in (0usize..width + 8).step_by(2) { + for (parity, column) in [(0, Some(even)), (1, even.checked_sub(7))] { + let Some(column) = column.filter(|&x| x < width) else { continue }; + for colour in 0..2 { + if xtrans && (MASKS[pair][colour] >> (column % 4)) & 1 == 0 { + continue; + } + let (sum, count) = &mut if q == 0 { &mut main } else { &mut fine }[pair % 3][parity]; + let k = 32 - (sum.saturating_sub(1) / *count).leading_zeros(); + stream.push(true); + stream.extend(std::iter::repeat_n(false, k as usize)); + if *count == 64 { + *sum /= 2; + *count /= 2; + } + *count += 1; + } + } + } + } + } + stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect() + } + + fn compressed(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec { + let groups = quantizers.map_or(3, <[u8]>::len); + let stream = zero_stream(bits, xtrans, quantizers); + // Two stripes; the second is mostly padding, which must never enter the output. + let mut out = b"IS".to_vec(); + out.extend_from_slice(&[u8::from(quantizers.is_none()), if xtrans { 16 } else { 0 }, bits as u8]); + for v in [groups as u16 * 6, 1536, 780, 768] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(2); + out.extend_from_slice(&(groups as u16).to_be_bytes()); + for _ in 0..2 { + out.extend_from_slice(&(stream.len() as u32).to_be_bytes()); + } + out.resize(32, 0); + if let Some(q) = quantizers { + for _ in 0..2 { + out.extend_from_slice(q); + out.resize(out.len() + q.len().div_ceil(16) * 16 - q.len(), 0); + } + } + out.extend_from_slice(&stream); + out.extend_from_slice(&stream); + out + } + + #[test] + fn lossless_and_lossy_bayer_and_xtrans_stripes() { + for bits in [12, 14, 16] { + for xtrans in [false, true] { + for q in [None, Some([2, 0, 5].as_slice())] { + let src = compressed(bits, xtrans, q); + let out = decode(&src, 780, 18, bits, xtrans, Mode::Full).unwrap(); + assert_eq!(out, vec![0; 780 * 18]); + assert!(decode(&src, 780, 18, bits, xtrans, Mode::Header).unwrap().is_empty()); + let layout = xtrans.then(|| { + let mut l = [0; 36]; + for (to, from) in l.iter_mut().zip(XTRANS.iter().flatten().rev()) { + *to = *from as u8; + } + l + }); + let raf = super::super::raf::tests::raf(780, 18, bits, src, layout, &[]); + let image = crate::decode(&raf).unwrap(); + assert_eq!(crate::probe_info(&raf).unwrap(), image.info()); + assert_eq!(image.data, crate::RawData::U16(out)); + } + } + } + } + + #[test] + fn malformed_headers_and_truncated_entropy_are_errors() { + let valid = compressed(14, true, Some(&[1, 2, 0])); + for len in (0..96).chain([valid.len() / 2, valid.len() - 1]) { + assert!(decode(&valid[..len], 780, 18, 14, true, Mode::Full).is_err(), "length {len}"); + } + for at in [2, 3, 4, 5, 7, 9, 11, 13, 14, 16] { + let mut src = valid.clone(); + src[at] ^= 0xff; + assert!(decode(&src, 780, 18, 14, true, Mode::Full).is_err(), "header byte {at}"); + } + assert!(decode(&valid, usize::MAX, usize::MAX, 14, true, Mode::Full).is_err()); + let mut reader = Bits::new(&[0; 64]); + assert!(reader.residual(0, 14, 16384, 41).is_err()); + } + + #[test] + fn unsupported_container_modes_and_cfa_are_not_silently_decoded() { + let layout = std::array::from_fn(|i| Cfa::xtrans().pattern[35 - i]); + let src = compressed(14, true, None); + let raf = super::super::raf::tests::raf(780, 18, 14, src, Some(layout), &[]); + let mut mismatched = raf.clone(); + mismatched[108..112].copy_from_slice(&3u32.to_be_bytes()); + assert!(crate::decode(&mismatched).is_err(), "declared lossy mode disagrees with lossless stream"); + let bad_layout = layout.map(|c| { + if c == 0 { + 2 + } else if c == 2 { + 0 + } else { + c + } + }); + let raf = super::super::raf::tests::raf(780, 18, 14, compressed(14, true, None), Some(bad_layout), &[]); + assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "compression uses a fixed X-Trans arrangement"); + let mut raf = super::super::raf::tests::raf(780, 18, 16, vec![0; 780 * 18 * 2], None, &[]); + for mode in [1u32, 4, u32::MAX] { + raf[108..112].copy_from_slice(&mode.to_be_bytes()); + assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "unknown mode {mode}"); + assert!(matches!(crate::probe_info(&raf), Err(RawError::Unsupported(_))), "header-only mode {mode}"); + } + } + + #[test] + fn residual_sign_wrap_and_escape() { + // Ordinary codes 0, 1, 2, 3 with k=2; then the escape for -8192. + let mut stream = vec![true, false, false, true, false, true, true, true, false, true, true, true]; + stream.extend(std::iter::repeat_n(false, 41)); + stream.push(true); + let escaped = 16382u32; + stream.extend((0..14).rev().map(|i| (escaped >> i) & 1 != 0)); + let bytes: Vec = stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect(); + let mut reader = Bits::new(&bytes); + for delta in [0, -1, 1, -2, -8192] { + assert_eq!(reader.residual(2, 14, 16384, 41).unwrap(), delta); + } + assert_eq!(even_prediction(1112, 858, 1104, 1152), 983); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + fn malformed_streams_never_panic(data in prop::collection::vec(any::(), 0..2048), quantizer in any::(), bits in prop::sample::select(vec![12,14,16]), xtrans in any::()) { + let mut src = compressed(bits, xtrans, Some(&[quantizer])); + let start = 64; + src.truncate(start); + for i in [16, 20] { src[i..i+4].copy_from_slice(&(data.len() as u32).to_be_bytes()); } + src.extend_from_slice(&data); + src.extend_from_slice(&data); + let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full); + } + } +} diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 10ade627..7fe6d0e4 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -43,7 +43,9 @@ //! reads as repeats of one code only in this order; MSB-first reading gives code frequencies of exactly //! 2^−length, the signature of random bits]. Tag `0x0040` lists (length, code) for 17 prefix codes, the difference //! categories 0–16 of ITU-T T.81 (category `c`, then `c` additional bits, a leading 0 meaning negative). Pixels -//! are coded two rows at a time, cell by 2×2 cell (top-left, top-right, bottom-left, bottom-right); each is +//! are coded two rows at a time, cell by 2×2 cell, column by column (top-left, bottom-left, top-right, +//! bottom-right) [in row order the two greens trade sites: an edge's even and odd rows of green then sit ~2 px +//! apart with red and blue between them]; each is //! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the //! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a //! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same @@ -67,12 +69,38 @@ //! - Active area: the sensor borders. Default crop: `0x002f`–`0x0032`, the in-camera aspect ratio (e.g. 4:3 or 1:1 //! on a 3:2 sensor; the embedded JPEG always shows the whole sensor). Some bodies write zeros there: ignored. //! +//! Lens distortion (tag `0x0119`, ExifTool name DistortionInfo; ExifTool's names for its words were the only outside +//! input, their meaning below is our own measurement). The tag holds 16 little-endian 16-bit words. The camera's +//! own correction, which its embedded JPEG shows, is applied as an `OpcodeList3` `WarpRectilinear` (the DNG form +//! LightCraft already applies under "Enable Profile Corrections"): +//! +//! - Word 7, low 4 bits: 1 = correction on [0 on the DMC-GH1, whose JPEG matches the uncorrected render]. +//! - Word 12: the normalisation radius in pixels, the half diagonal of the active area [2870 for 4592 × 3448 +//! (2871.2), 3240 for 5184 × 3888, 3611 for 5776 × 4336, 3288 for the FZ1000 II's whole 5472 × 3648 sensor +//! even in its 4:3 mode]. +//! - Words 8, 4 and 11 (ExifTool: DistortionParam08, 04, 11), divided by 32768, are `k2`, `k4`, `k6` of the +//! correction: a sensor point at radius ρ (in units of word 12, about the centre of the active area) appears in +//! the corrected image at ρ·(1 + k2 ρ² + k4 ρ⁴ + k6 ρ⁶). +//! - Word 5 (ExifTool: DistortionScale), divided by 32768, plus 1, is the zoom `z` the camera crops by afterwards: +//! output radius = corrected radius / z [z < 1 when the corrected corners would otherwise be empty]. +//! +//! [These rules were chosen among alternative word orders, powers, directions and readings of the zoom by fitting +//! radial curves between our uncorrected renders and the embedded JPEGs of 38 DMC-GX85 photos (12–32 mm and 25 mm +//! lenses). With no free parameter the words then predict the curves of 35 held-out photos from 12 other bodies and +//! 9 other lenses (9.1–105 mm: two compacts, Panasonic, Leica, Olympus, Sigma and Samyang lenses) within 0.12 % of +//! the half diagonal, against up to 13 % uncorrected; corrected renders of all 79 photos line up with the camera +//! JPEG within 0.75 px at 1440 px at every corner measured.] Words 2, 3, 6, 9, 10 +//! and 13 are not used (word 13 is always word 9 + 500; word 10 is fixed per body); words 0, 1, 14 and 15 change +//! from shot to shot. The correction is radial, so it is fitted by the DNG form's odd polynomial `u·(kr0 + kr1 u² + +//! kr2 u⁴ + kr3 u⁶)` over the output (within 0.4 px on a 20 MP sensor); implausible values (a radius far from the +//! active area's, a zoom beyond ±25 %, a correction that folds over) leave the photo uncorrected. +//! //! Not supported ([`RawError::Unsupported`], the embedded preview is shown instead): compression 34826, raw formats //! 1 and 3, and bit depths other than the ones above (no samples of any of them). use super::pef::{Bits, Huffman, diff}; use super::white_from_data; -use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t}; use rayon::prelude::*; @@ -113,6 +141,14 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ (0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), (0x0043, &[1]), ]; +/// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs. +const DISTORTION_INFO: u16 = 0x0119; +/// Fixed-point one of the distortion coefficients and zoom. +const DISTORTION_ONE: f64 = 32768.0; +/// Output radii sampled to fit the inverse mapping. +const DISTORTION_SAMPLES: usize = 64; +/// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. +const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; /// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). const MAX_STRIPS: usize = 64; @@ -401,7 +437,7 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result for (k, v) in cell.iter_mut().enumerate() { let d = diff(&mut bits, codes).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: invalid code in row {}", 2 * pair)))?; *v = v.saturating_add(d); - if let Some(px) = rows.get_mut(2 * pair + k / 2).and_then(|r| r.get_mut(2 * cx + k % 2)) { + if let Some(px) = rows.get_mut(2 * pair + k % 2).and_then(|r| r.get_mut(2 * cx + k / 2)) { *px = (*v).clamp(0, u16::MAX as i32) as u16; } } @@ -586,13 +622,121 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, }; img.validate_for(mode)?; Ok(img) } +/// The camera's distortion correction (tag `0x0119`, see the module docs) as the equivalent DNG `WarpRectilinear` +/// opcode for `OpcodeList3` (relative to the active area), so it reaches the same "profile corrections" as a DNG's +/// embedded warp. `None` when the camera recorded the correction as off, when it is the identity, or when the +/// values are implausible (the photo is then shown uncorrected rather than wrongly warped). +fn distortion(ifd: &Ifd, active: Rect) -> Option { + let b = ifd.bytes(DISTORTION_INFO)?; + let word = |i: usize| -> Option<[u8; 2]> { b.get(2 * i..2 * i + 2)?.try_into().ok() }; + let signed = |i: usize| word(i).map(|w| i16::from_le_bytes(w) as f64); + if u16::from_le_bytes(word(7)?) & 0x000f != 1 { + return None; + } + let (k2, k4, k6) = (signed(8)? / DISTORTION_ONE, signed(4)? / DISTORTION_ONE, signed(11)? / DISTORTION_ONE); + let zoom = 1.0 + signed(5)? / DISTORTION_ONE; + let radius = u16::from_le_bytes(word(12)?) as f64; + if k2 == 0.0 && k4 == 0.0 && k6 == 0.0 && zoom == 1.0 { + return None; + } + // DNG's normalisation: the distance from the centre to the farthest corner, in pixel-index units + let half_diag = (active.width as f64 - 1.0).hypot(active.height as f64 - 1.0) / 2.0; + if !(half_diag >= 1.0 && (0.5 * half_diag..=2.0 * half_diag).contains(&radius) && (0.75..=1.25).contains(&zoom)) { + return None; + } + // the camera's correction: a source point at radius ρ (in units of `radius`) moves to ρ·g(ρ) + let corrected = |r: f64| { + let r2 = r * r; + r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6))) + }; + // the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens) + let t_max = half_diag * zoom / radius; + let mut top = None; + let mut prev = 0.0; + for i in 1..=4096 { + let r = 3.0 * t_max * i as f64 / 4096.0; + let v = corrected(r); + if v.is_nan() || v <= prev { + return None; + } + prev = v; + if v >= t_max { + top = Some(r); + break; + } + } + let top = top?; + let source = |t: f64| { + let (mut lo, mut hi) = (0.0, top); + for _ in 0..60 { + let m = 0.5 * (lo + hi); + if corrected(m) < t { lo = m } else { hi = m } + } + 0.5 * (lo + hi) + }; + // least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1] + let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) + .map(|i| { + let u = i as f64 / DISTORTION_SAMPLES as f64; + (u, source(u * t_max) * radius / half_diag) + }) + .collect(); + let basis = |u: f64| { + let u2 = u * u; + [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] + }; + let mut a = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + for &(u, v) in &samples { + let f = basis(u); + for (i, fi) in f.iter().enumerate() { + for (j, fj) in f.iter().enumerate() { + a[i][j] += fi * fj; + } + rhs[i] += fi * v; + } + } + let k = solve4(a, rhs)?; + let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::(); + let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR { + return None; + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) +} + +/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for c in 0..4 { + let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; + if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { + return None; + } + a.swap(c, p); + b.swap(c, p); + for r in c + 1..4 { + let f = a[r][c] / a[c][c]; + for k in c..4 { + a[r][k] -= f * a[c][k]; + } + b[r] -= f * b[c]; + } + } + let mut x = [0.0; 4]; + for c in (0..4).rev() { + let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); + x[c] = (b[c] - s) / a[c][c]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + #[cfg(test)] mod tests { use super::*; @@ -853,6 +997,76 @@ mod tests { } } + /// A tag-0x0119 block: 16 little-endian words, the given ones set. + fn distortion_block(words: &[(usize, i16)]) -> Value { + let mut w = [0i16; 16]; + for &(i, v) in words { + w[i] = v; + } + Value::Undefined(w.iter().flat_map(|v| v.to_le_bytes()).collect()) + } + + fn distorted(info: Option) -> Vec { + let (w, h) = (90usize, 60usize); + let px: Vec = (0..w * h).map(|i| (i % 4000) as u16 + 1).collect(); + let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, 12), short(RAW_FORMAT, 5)]; + tags.extend(info.map(|v| (DISTORTION_INFO, v))); + let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(crate::probe_info(&bytes).unwrap().opcodes, r.opcodes, "headers-only probe reads the same correction"); + r.opcodes.list3 + } + + /// Known answer: correction k2 = 4096/32768 = 1/8 (word 8), zoom 1 − 1024/32768 (word 5), radius 51 px (word 12) + /// on an 86 × 58 active area. A sensor point at ρ appears at ρ + ρ³/8, so the output corner (corrected radius + /// t = half diagonal · zoom / 51) shows the root of ρ³ + 8ρ − 8t = 0, here by Cardano's formula. + #[test] + fn distortion_correction_becomes_a_rectilinear_warp() { + let on = |more: &[(usize, i16)]| { + let mut w = vec![(7, 0x0001), (8, 4096), (5, -1024), (12, 51)]; + w.extend_from_slice(more); + distortion_block(&w) + }; + let ops = distorted(Some(on(&[]))); + let [Opcode::WarpRectilinear { planes, center }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!((planes.len(), *center), (1, [0.5, 0.5])); + let k = planes[0]; + assert_eq!((k[4], k[5]), (0.0, 0.0), "radial only"); + let half_diag = 85f64.hypot(57.0) / 2.0; + for u in [0.1, 0.25, 0.5, 0.75, 1.0] { + let t = u * half_diag * (1.0 - 1024.0 / 32768.0) / 51.0; + let (p, q) = (8.0, -8.0 * t); + let d = (q * q / 4.0 + p * p * p / 27.0).sqrt(); + let rho = (-q / 2.0 + d).cbrt() + (-q / 2.0 - d).cbrt(); + let want = rho * 51.0 / half_diag; + let got = u * (k[0] + k[1] * u * u + k[2] * u.powi(4) + k[3] * u.powi(6)); + // the 4-term odd polynomial approximates the exact inverse (5e-5 of the half diagonal ≈ 0.2 px at 20 MP) + assert!((got - want).abs() < 5e-5, "u {u}: source radius {got}, want {want}"); + } + + // no correction: no block, recorded as off, or the identity + assert!(distorted(None).is_empty()); + assert!(distorted(Some(distortion_block(&[(7, 0x0000), (8, 4096), (12, 51)]))).is_empty(), "off"); + assert!(distorted(Some(distortion_block(&[(7, 0x7f00u16 as i16), (8, 4096), (12, 51)]))).is_empty(), "off, other bits set"); + assert!(distorted(Some(distortion_block(&[(7, 0x0001), (12, 51)]))).is_empty(), "identity"); + // hostile values are ignored, never a panic or a wild warp + for (what, block) in [ + ("truncated", Value::Undefined(vec![1, 0, 2, 0, 3])), + ("radius 0", on(&[(12, 0)])), + ("radius far beyond the sensor", on(&[(12, -1)])), + ("zoom 0", on(&[(5, -32768)])), + ("folds over", on(&[(8, -32768)])), + ("folds over at high order", on(&[(11, -32768), (4, -32768)])), + ("runs away", on(&[(8, 32767), (4, 32767), (11, 32767)])), + ] { + let ops = distorted(Some(block)); + assert!(ops.iter().all(|op| !matches!(op, Opcode::WarpRectilinear { .. })) || what == "runs away", "{what}: {ops:?}"); + if let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() { + assert!(planes[0].iter().all(|c| c.is_finite()), "{what}"); + } + } + } + #[test] fn packed_and_word_formats() { let (w, h) = (90usize, 200usize); // > one chunk of data @@ -962,7 +1176,7 @@ mod tests { let mut cell = first; for cx in 0..w / 2 { for (k, p) in cell.iter_mut().enumerate() { - let v = at(2 * cx + k % 2, 2 * pair + k / 2); + let v = at(2 * cx + k / 2, 2 * pair + k % 2); let d = v - *p; let c = 32 - d.unsigned_abs().leading_zeros(); let (len, code) = table[c as usize]; @@ -1026,6 +1240,26 @@ mod tests { } } + #[test] + fn format8_cell_is_coded_column_by_column() { + // One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values + // 1, 2, 3, 4 in stream order. Row order would put the two greens of an RGGB cell on each other's sites. + let mut bits: Vec = vec![]; + let mut put = |n: u32, v: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1)); + for v in [1u32, 2, 3, 4] { + let c = 32 - v.leading_zeros(); + let (len, code) = TABLE14[c as usize]; + put(len, code); + put(c, v); + } + let n = bits.len() as u64; + let mut data: Vec = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect(); + data.extend([0; 8]); + let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]); + decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap(); + assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right"); + } + fn replace(tags: &mut Vec<(u16, Value)>, tag: u16, v: Value) { tags.retain(|(t, _)| *t != tag); tags.push((tag, v)); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e4761eae..e457c3da 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -15,10 +15,9 @@ fn corpus_root() -> PathBuf { /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. const KNOWN_UNSUPPORTED: &[&str] = &[ - "cr3-", // CR3 / CRX (M11.1) - "raf-fuji-xt20-compressed", // Fujifilm compressed RAF - "orf-olympus-em", // Olympus compressed ORF - "sraw", // Canon sRAW / mRAW + "cr3-", // CR3 / CRX (M11.1) + "orf-olympus-em", // Olympus compressed ORF + "sraw", // Canon sRAW / mRAW ]; #[test] @@ -166,6 +165,34 @@ fn corpus_cr2_cfa_patterns() { eprintln!("CR2 CFA layouts checked on {seen} files"); } +/// The as-shot white balance of Canon CR2s whose ColorData is stored as SHORT words stays readable (the PowerShot / +/// EOS M files that store it as UNDEFINED bytes are covered by the unit test in `vendor/cr2.rs`; none is in this corpus). +/// Daylight-ish CR2s have R/G above 1 and B/G between 1 and 2.5 (observed 1.4-2.7 on these nine bodies). +#[test] +fn corpus_cr2_as_shot_white_balance() { + let dir = corpus_root().join("raw"); + let mut seen = 0; + for name in [ + "cr2-canon-40d.cr2", + "cr2-canon-550d.cr2", + "cr2-canon-5d2.cr2", + "cr2-canon-5d3.cr2", + "cr2-canon-5dsr.cr2", + "cr2-canon-6d.cr2", + "cr2-canon-7d.cr2", + "cr2-canon-80d.cr2", + ] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); + assert!(wb[1] == 1.0 && wb[0] > 1.0 && wb[2] > 0.5 && wb[0] < 4.0 && wb[2] < 4.0, "{name}: WB {wb:?}"); + seen += 1; + } + eprintln!("CR2 as-shot WB checked on {seen} files"); +} + /// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must /// match the uncompressed image (up to the small differences between two exposures). #[test] @@ -427,3 +454,80 @@ fn corpus_panasonic_encodings() { } eprintln!("Panasonic encodings checked on {seen} files"); } + +/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`. +/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout. +#[test] +fn corpus_orf_cfa_patterns() { + let dir = corpus_root().join("raw"); + let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")]; + let mut seen = 0; + for (name, cfa) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout"); + assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview")); + let (gw, gh) = (24, 18); + let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh)); + let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern); + let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max); + let own = agree(cfa); + let a = img.active_area; + eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y); + assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})"); + seen += 1; + } + eprintln!("ORF CFA layouts checked on {seen} files"); +} + +/// Reference sensor sums and position-weighted sums from black-box decoding, independent +/// of this implementation. These cover complete images, padding, stripe boundaries and +/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`. +#[test] +fn corpus_fujifilm_compressed_samples() { + use sha2::{Digest, Sha256}; + let checksums = include_str!("../../../docs/raf-corpus.sha256"); + let cases: &[(&str, usize, usize, u64, u64)] = &[ + ("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080), + ("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551), + ("raf-fuji-gfx100rf-8091.raf", 11808, 8754, 509005112862, 6771145666297463071), + ("raf-fuji-gfx100s-4495.raf", 11808, 8754, 941987648006, 4811558780180901389), + ("raf-fuji-gfx100s-4503.raf", 11808, 8754, 939580201129, 4821879272388485929), + ("raf-fuji-gfx50s-1435.raf", 9216, 6210, 79545293042, 2565169091480702981), + ("raf-fuji-xe5-8509.raf", 7872, 5196, 76435253154, 1299804440011842144), + ("raf-fuji-xh2-6001.raf", 7872, 5196, 83480264024, 1799924693528487099), + ("raf-fuji-xh2-6002.raf", 7872, 5196, 72476569659, 1547275125734054244), + ("raf-fuji-xm5-7748.raf", 6336, 4182, 93243605328, 986712869515471030), + ("raf-fuji-xt2-865.raf", 6048, 4038, 44212684244, 541983062567959797), + ("raf-fuji-xt20-compressed.raf", 6048, 4038, 72690241711, 882852288583469317), + ("raf-fuji-xt4-3914.raf", 6384, 4182, 45943146703, 599112804573868025), + ("raf-fuji-xt4-3918.raf", 6384, 4182, 46401490361, 605690093061448074), + ("raf-fuji-xt5-6122.raf", 7872, 5196, 109559716516, 1653376659872066392), + ("raf-fuji-xt5-6123.raf", 7872, 5196, 109489917369, 1673725237502600190), + ("raf-fuji-xt50-7807.raf", 7872, 5196, 76453904143, 1262190863834170778), + ]; + let dir = corpus_root().join("raw"); + let mut seen = 0; + for &(name, width, height, sum, weighted) in cases { + let path = format!("corpus/raw/{name}"); + let checksum = checksums.lines().filter_map(|s| s.split_once(" ")).find(|(_, p)| *p == path).unwrap().0; + assert_eq!(checksum.len(), 64, "{name}: missing published SHA-256"); + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let actual_sha: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual_sha, checksum, "{name}: corpus file differs from its pinned identity"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height), (width, height), "{name}"); + let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") }; + let actual = data + .iter() + .enumerate() + .fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v))))); + assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference"); + seen += 1; + } + eprintln!("verified {seen} Fujifilm compressed sensor arrays"); +} diff --git a/crates/segment/src/fetch/mod.rs b/crates/segment/src/fetch/mod.rs index 55c083cc..35f2d2eb 100644 --- a/crates/segment/src/fetch/mod.rs +++ b/crates/segment/src/fetch/mod.rs @@ -51,6 +51,14 @@ pub fn mirrors(env: Option<&str>, file: Option<&Path>) -> Vec { lightcraft_fetch::mirrors(env, file, DEFAULT_MIRRORS) } +/// Download settings for SAM 3: the defaults, plus [`TOKEN_ENV`] for a gated mirror. +pub fn options() -> Options { + Options { token_env: Some(TOKEN_ENV), ..Options::default() } +} + +/// Environment variable with a bearer token for the SAM 3 mirror's own host (a gated Hugging Face repository). +pub const TOKEN_ENV: &str = "LIGHTCRAFT_SAM3_TOKEN"; + /// What to tell the user when there is no mirror for the SAM 3 model. pub fn no_mirrors_message() -> String { format!( diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json new file mode 100644 index 00000000..7afca56e --- /dev/null +++ b/crates/ui-egui/locales/de-formats.json @@ -0,0 +1,180 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} mit mehreren ähnlichen Dateien: „Fehlende Datei suchen“ verwenden)", + " ({} in Recently Deleted)": " ({} in „Zuletzt gelöscht“)", + " · {:.0}% clipped": " · {:.0}% beschnitten", + " · {failed} couldn't be rendered": " · nicht gerenderte Fotos: {failed}", + " · {skipped} skipped (file exists)": " · {skipped} übersprungen (Datei vorhanden)", + " · {} duplicate{} skipped": " · {} übersprungen (Duplikate){:.0}", + " · {} failed: {}": " · {} fehlgeschlagen: {}", + " · {} moved": " · {} verschoben", + " · {} not readable": " · {} nicht lesbar", + " · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}", + " · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt", + " — not carried over: {}{}": " — nicht übernommen: {}{}", + "+{} more": "+{} weitere", + ", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}", + "; {kept} already had a location": "; bereits verortet: {kept}", + "; {outside} outside its time range": "; {outside} außerhalb des Zeitbereichs", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} Commits, +{} / −{} Zeilen (Δ {}), +{} / −{} Binärdateien, {} – {}", + "Add {local_n} to My Photos": "{local_n} zu „Meine Fotos“ hinzufügen", + "Add {} Photo{}": "{} Foto{} hinzufügen", + "Add {} Photo{}, restore {}": "{} Foto{} hinzufügen, {} wiederherstellen", + "Add “{}” to Favorites": "„{}“ zu Favoriten hinzufügen", + "Added {n} photo{} to My Photos": "{n} Foto{} zu „Meine Fotos“ hinzugefügt", + "Added {n} photo{} to “{}”": "{n} Foto{0} zu „{1}“ hinzugefügt", + "Added {what} to {name}": "{what} zu {name} hinzugefügt", + "Adding photos… {} of {}": "Fotos werden hinzugefügt… {} von {}", + "All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren", + "Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt", + "Auto ({})": "Automatisch ({})", + "Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt", + "Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos", + "Automatic ({} MB)": "Automatisch ({} MB)", + "Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})", + "Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})", + "Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})", + "Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…", + "Click {}:1": "Klick {}:1", + "Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}", + "Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen", + "Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}", + "Decoder: {reason}": "Decoder: {reason}", + "Delete \"{label}\"": "„{label}“ löschen", + "Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)", + "Delete {n} Photos": "{n} Fotos löschen", + "Delete “{current}”": "„{current}“ löschen", + "Discard Smart Previews ({scope})": "Smart-Vorschauen verwerfen ({scope})", + "Done ({what}): {done} photo{plural}": "Fertig ({what}): {done} Foto{plural}", + "Duplicate \"{label}\"": "„{label}“ duplizieren", + "Example: {example}": "Beispiel: {example}", + "Exported {n} photo{}": "{n} Foto{} exportiert", + "Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert", + "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}", + "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}", + "Exporting {} of {total}": "{} von {total} wird exportiert", + "Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}", + "Focus {:.0}": "Schärfe {:.0}", + "Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}", + "Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", + "Import failed: {e}": "Import fehlgeschlagen: {e}", + "Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}", + "Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importierte Profile: {profiles}{:.0} (Profilbrowser → jeweilige Gruppen)", + "Imported {} photo{}": "{} Foto{} importiert", + "Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)", + "Label Set: {name}": "Farbmarkierungssatz: {name}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.", + "Matches: {}": "Treffer: {}", + "Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?", + "Named ({named_n})": "Benannt ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet", + "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt", + "Preset: {name}": "Vorgabe: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})", + "Preview only — {}": "Nur Vorschau — {}", + "Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Softproof-Kopie ({})", + "Proof Preview · {}": "Softproof-Vorschau · {}", + "Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos", + "Quick Develop: {name}": "Schnellentwicklung: {name}", + "Rated {}": "Bewertet mit {}", + "Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}", + "Reading photos… {} of {}": "Fotos werden gelesen… {} von {}", + "Redo {}": "Wiederholen: {}", + "Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?", + "Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen", + "Removed {what} from {name}": "{what} aus {name} entfernt", + "Rename {n} Photos…": "{n} Fotos umbenennen…", + "Rename “{name}”": "„{name}“ umbenennen", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Benennt „{from}“ bei {n} Foto{} um (auch untergeordnete Stichwörter). Verwende | für Ebenen, z. B. Reise|Italien. Bei vorhandenem Namen werden beide zusammengeführt.", + "Rendering on the CPU: {why}": "Rendering auf der CPU: {why}", + "Replace {} with:": "{} ersetzen durch:", + "Reset {name}": "{name} zurücksetzen", + "Reset {} Channel": "Kanal {} zurücksetzen", + "Restore {} Photo{}": "{} Foto{} wiederherstellen", + "Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).", + "Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}", + "Showing {n} of {}": "{n} von {} werden angezeigt", + "Smart album: {}": "Smart-Album: {}", + "Tagged {n} photo{} from the tracklog": "{n} Foto{} anhand des GPS-Tracks verortet", + "The SAM 3 download failed: {e}": "SAM-3-Download fehlgeschlagen: {e}", + "Undo {}": "Rückgängig: {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)", + "Updated “{name}”": "„{name}“ aktualisiert", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", + "Version: {}": "Version: {}", + "Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}", + "Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…", + "none (CPU rendering): {why}": "keine (CPU-Rendering): {why}", + "the {n} selected photos": "die {n} ausgewählten Fotos", + "the {} photos in view": "die {} Fotos in der Ansicht", + "these {count} photos": "diese {count} Fotos", + "{count} smart preview{} · {:.1} MB": "Smart-Vorschauen: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} Ordner (und ihre Unterordner)", + "{label} Grading": "Farbtonung: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} für jedes ausgewählte Foto", + "{label} {index} of {n}": "{label} {index} von {n}", + "{label}, {n} photos": "{label}, Fotos: {n}", + "{matching} of {t} photos": "Fotos: {matching} von {t}", + "{matching} photos": "Fotos: {matching}", + "{mode} spot {} of {}": "{mode}: Stelle {} von {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} fehlgeschlagen)", + "{name} Label": "Farbmarkierung {name}", + "{name} Off": "{name} aus", + "{name} On": "{name} ein", + "{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt", + "{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut", + "{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt", + "{n} photos": "Fotos: {n}", + "{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)", + "{n} photo{}": "{n} Foto{}", + "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{pid} (missing)": "{pid} (fehlt)", + "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb}: {n} Foto{}", + "{weekday}, {day}": "{weekday}, {day}.", + "{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}", + "{what} merge added": "{what}-Zusammenfügung hinzugefügt", + "{what} merge failed: {e}": "{what}-Zusammenfügung fehlgeschlagen: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}}-Formatangaben: {}", + "{} and higher": "{} und höher", + "{} contributors · {} commits": "{} Mitwirkende · {} Commits", + "{} failed": "{} fehlgeschlagen", + "{} files": "{} Dateien", + "{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}", + "{} item{}": "Elemente: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", + "{} photos": "Fotos: {}", + "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "{} Foto{} nicht lesbar", + "{} sample{}": "Farbproben: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto", + "{} — show their photos": "{} — Fotos dieser Person anzeigen", + "· {dups} already in the library": "· {dups} bereits in der Bibliothek", + "· {n} photo{}": "· {n} Foto{}", + "… and {more} more": "… und {more} weitere", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", + "Filtered: {label}": "Gefiltert: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht ▸ Filter löschen zeigt alle Fotos", + "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", + "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", + ", {} star{}": ", Sterne: {}{:.0}", + ", {} label": ", Markierung: {}" +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json new file mode 100644 index 00000000..db33e0de --- /dev/null +++ b/crates/ui-egui/locales/de.json @@ -0,0 +1,1751 @@ +{ + " (library default)": " (Bibliotheksstandard)", + " (inverted)": " (umgekehrt)", + " · best of its burst": " · bestes Foto der Serie", + " · in a burst": " · Teil einer Serie", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„im Arbeitsspeicher gespeichert, aber nicht auf den Datenträger geschrieben“, die obere Leiste zeigt eine Warnung, und LightCraft versucht es erneut,", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "(Issue #106). Ein DNG wird mit den RAW-Daten verglichen, temporär geschrieben, synchronisiert und zurückgelesen, bevor es seinen", + "% of all commits": "% aller Commits", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "mit `gpuReason` und `gpuFallback`, Hilfe → Systeminformationen und Einstellungen → Leistung erklären, warum die GPU", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "etwa D750, D780, D850, D7500 und Z 50, erscheinen nicht mehr fast schwarz oder mit zugelaufenen Tiefen: Ihr Schwarzwert wurde", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(Issue #94). Eine HTTP-Anfrage einer Webseite kann so keinen Befehl mehr im Anfrageinhalt transportieren. Zeilen sind auf", + "+ Group": "+ Gruppe", + "+ Rule": "+ Regel", + "0: nothing is rejected.": "0: Nichts wird abgelehnt.", + "1 day": "1 Tag", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten", + "2026-04 or 2026-04-12": "2026-04 oder 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.", + "4 MiB and connections at 16.": "4 MiB und gleichzeitige Verbindungen auf 16 begrenzt.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "Fenster und GPU-Rendering verwenden unter Windows standardmäßig ausschließlich DirectX 12; der Vulkan-Treiber wird nur auf ausdrückliche Anforderung geladen.", + "= exactly": "= genau", + "A blank template keeps the original names.": "Eine leere Vorlage behält die Originalnamen bei.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Kann eine Änderung wegen eines vollen oder getrennten Laufwerks nicht gespeichert werden, bleibt das nicht mehr unbemerkt: Der Befehl meldet", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ein Filter blendet die Fotos dieser Ansicht aus: Ändere ihn oder lösche ihn (Ansicht → Filter löschen)", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn LightCraft oder `lightcraft-cli` sie bereits geöffnet hat,", + "A one-time download of about": "Ein einmaliger Download von etwa", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Ein teilweise fehlgeschlagener Speichervorgang durch einen vollen Datenträger oder eine getrennte Netzwerkfreigabe hinterlässt keinen scheinbar beschädigten Katalog", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Ein schlafendes NAS, eine getrennte Netzwerkfreigabe oder ein anlaufendes USB-Laufwerk blockieren das Fenster nicht mehr (Issue #104).", + "AI Mask Model Status": "Status des KI-Maskenmodells", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "KI-Masken mit SAM 3 über Objekt und Beschreiben im Maskenbereich: Ein Objekt zum Auswählen anklicken; ⌥-Klick lässt einen Teil", + "About": "Über", + "About LightCraft": "Über LightCraft", + "Active": "Aktiv", + "Activity": "Aktivität", + "Actual size": "Originalgröße", + "Add": "Hinzufügen", + "Add Catchlight": "Lichtreflex hinzufügen", + "Add Folder to Local": "Ordner zu „Lokal“ hinzufügen", + "Add Mask": "Maske hinzufügen", + "Add Object Click": "Objektklick hinzufügen", + "Add Point Color Sample": "Punktfarbenprobe hinzufügen", + "Add Red Eye Correction": "Rote-Augen-Korrektur hinzufügen", + "Add Remove Spot": "Retuschierstelle hinzufügen", + "Add Selected Photos": "Ausgewählte Fotos hinzufügen", + "Add a rule (⌥-click: a group)": "Regel hinzufügen (⌥-Klick: eine Gruppe)", + "Add in place": "Am Speicherort hinzufügen", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.", + "Add keyword": "Stichwort hinzufügen", + "Add number": "Nummer hinzufügen", + "Add to": "Hinzufügen zu", + "Add to Album": "Zu Album hinzufügen", + "Add to Favorites": "Zu Favoriten hinzufügen", + "Add to Local": "Zu „Lokal“ hinzufügen", + "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen", + "Add to Target Album": "Zu Zielalbum hinzufügen", + "Add to this mask": "Zu dieser Maske hinzufügen", + "Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden", + "Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.", + "Adding": "Wird hinzugefügt", + "Advanced search…": "Erweiterte Suche…", + "After": "Nachher", + "After 30 days": "Nach 30 Tagen", + "After 7 days": "Nach 7 Tagen", + "After 90 days": "Nach 90 Tagen", + "After a year": "Nach einem Jahr", + "Album": "Album", + "Albums": "Alben", + "All": "Alle", + "All Metadata": "Alle Metadaten", + "All Metadata…": "Alle Metadaten…", + "All Photos": "Alle Fotos", + "All rights reserved": "Alle Rechte vorbehalten", + "All the photos imported from this disk are included.": "Alle von diesem Datenträger importierten Fotos sind eingeschlossen.", + "All types": "Alle Typen", + "Alt Text": "Alternativtext", + "Always": "Immer", + "Amount": "Stärke", + "Analyzing the photo for AI masks…": "Foto wird für KI-Masken analysiert…", + "Any Searchable Text": "Beliebiger durchsuchbarer Text", + "Aperture": "Blende", + "Application": "Anwendung", + "Apply": "Anwenden", + "Apply Color Label Set": "Farbmarkierungssatz anwenden", + "Apply Filter Preset": "Filtervorgabe anwenden", + "Apply Metadata Preset": "Metadatenvorgabe anwenden", + "Apply Point Curve Preset": "Punktkurvenvorgabe anwenden", + "Apply Preset": "Vorgabe anwenden", + "Apply Settings JSON": "Einstellungen aus JSON anwenden", + "Approved": "Freigegeben", + "Aqua": "Aquamarin", + "Aqua Hue": "Aquamarin – Farbton", + "Aqua Luminance": "Aquamarin – Luminanz", + "Aqua Saturation": "Aquamarin – Sättigung", + "ArtCraft Website": "ArtCraft-Website", + "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", + "As Shot": "Wie Aufnahme", + "As shot": "Wie Aufnahme", + "Ascending": "Aufsteigend", + "Aspect": "Seitenverhältnis", + "Aspect Ratio": "Seitenverhältnis", + "Assisted Culling": "Unterstützte Fotoauswahl", + "Assisted Culling…": "Unterstützte Fotoauswahl…", + "Auto": "Auto", + "Auto Advance": "Automatisch weiterschalten", + "Auto Advance Off": "Automatisches Weiterschalten aus", + "Auto Advance On": "Automatisches Weiterschalten ein", + "Auto Advance after rating or flagging": "Nach Bewertung oder Markierung automatisch weiterschalten", + "Auto Advance: move to the next photo after rating or flagging": "Automatisch weiterschalten: Nach Bewertung oder Markierung zum nächsten Foto wechseln", + "Auto Align": "Automatisch ausrichten", + "Auto B&W Mix": "Automatische Schwarzweißmischung", + "Auto Crop": "Automatisch zuschneiden", + "Auto Import": "Automatischer Import", + "Auto Import Now": "Jetzt automatisch importieren", + "Auto Import Settings": "Einstellungen für automatischen Import", + "Auto Mask": "Automatisch maskieren", + "Auto Select": "Automatisch auswählen", + "Auto Settings": "Automatische Einstellungen", + "Auto Straighten": "Automatisch gerade ausrichten", + "Auto Sync": "Automatisch synchronisieren", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatische Synchronisierung auf alle ausgewählten Fotos, automatische Schwarzweißmischung und automatische Versionen.", + "Auto settings applied": "Automatische Einstellungen angewendet", + "Auto-Stack by Capture Time": "Automatisch nach Aufnahmezeit stapeln", + "Auto-Stack by Capture Time…": "Automatisch nach Aufnahmezeit stapeln…", + "Auto-Tag Photos from Tracklog": "Fotos anhand eines GPS-Tracks automatisch verorten", + "Auto-Tag from Tracklog": "Anhand eines GPS-Tracks automatisch verorten", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.", + "Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…", + "Automatic": "Automatisch", + "Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben", + "Automatically write changes into XMP sidecars": "Änderungen automatisch in XMP-Begleitdateien schreiben", + "Autumn Gold": "Herbstgold", + "B&W": "S/W", + "B&W Mix": "Schwarzweißmischung", + "B&W Mixer": "Schwarzweißmischer", + "Back": "Zurück", + "Back Up Library…": "Bibliothek sichern…", + "Back to Edit": "Zurück zur Bearbeitung", + "Back to Edit (E)": "Zurück zur Bearbeitung (E)", + "Back to Grid": "Zurück zum Raster", + "Background": "Hintergrund", + "Balance": "Balance", + "Basic": "Grundeinstellungen", + "Before": "Vorher", + "Before/After (Y)": "Vorher/Nachher (Y)", + "Before/After Settings": "Vorher/Nachher-Einstellungen", + "Before/After Split": "Vorher/Nachher geteilt", + "Before/After Split Top/Bottom": "Vorher/Nachher geteilt – oben/unten", + "Before/After Top/Bottom": "Vorher/Nachher – oben/unten", + "Begin Interaction": "Interaktion beginnen", + "Best of Similar Shots": "Beste ähnlicher Aufnahmen", + "Binary assets added": "Hinzugefügte Binärdateien", + "Binary assets removed": "Entfernte Binärdateien", + "Bit depth": "Bittiefe", + "Black & White": "Schwarzweiß", + "Blacks": "Schwarz", + "Bleach Bypass": "Bleach-Bypass", + "Bleached": "Ausgebleicht", + "Blending": "Überblendung", + "Blue": "Blau", + "Blue Hour Boost": "Blaue Stunde verstärken", + "Blue Hue": "Blau – Farbton", + "Blue Luminance": "Blau – Luminanz", + "Blue Primary": "Primärfarbe Blau", + "Blue Saturation": "Blau – Sättigung", + "Blue channel": "Blaukanal", + "Blue/Yellow Fringe": "Blaue/gelbe Farbsäume", + "Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.", + "Boundary Warp": "Randverkrümmung", + "Bright & Airy": "Hell und luftig", + "Bring All to Front": "Alle nach vorne bringen", + "Browse Folder": "Ordner durchsuchen", + "Browse Folder…": "Ordner durchsuchen…", + "Browse Profiles": "Profile durchsuchen", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Browserversion (experimentell), Schutz der Bibliothek (Issue #107): Datei → Bibliothek sichern… lädt Katalog", + "Browse…": "Durchsuchen…", + "Brush": "Pinsel", + "Brush Settings": "Pinseleinstellungen", + "Brush Stroke": "Pinselstrich", + "Build Previews": "Vorschauen erstellen", + "Build Smart Previews": "Smart-Vorschauen erstellen", + "By Date": "Nach Datum", + "By day (YYYY/YYYY-MM-DD)": "Nach Tag (JJJJ/JJJJ-MM-TT)", + "By month (YYYY/YYYY-MM)": "Nach Monat (JJJJ/JJJJ-MM)", + "CPU threads": "CPU-Threads", + "Calibration": "Kalibrierung", + "Camera": "Kamera", + "Camera make and model": "Kamerahersteller und -modell", + "Cameras and Cards": "Kameras und Karten", + "Cancel": "Abbrechen", + "Cancel AI Mask Model Download": "Download des KI-Maskenmodells abbrechen", + "Cancel Download": "Download abbrechen", + "Cancel Interaction": "Interaktion abbrechen", + "Cancel Preview Build": "Vorschauerstellung abbrechen", + "Candidate": "Kandidat", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Canon-CR2-Fotos der EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II und 1D Mark IV sowie anderer", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon-CR3-Dateien zeigen ihr eingebettetes JPEG in voller Größe, etwa 6960 × 4640 auf einer EOS R6 Mark III, statt der", + "Caption": "Bildunterschrift", + "Capture Date": "Aufnahmedatum", + "Capture date and time in your own format (directives below)": "Aufnahmedatum und -zeit im eigenen Format (Formatangaben unten)", + "Capture date as YYYYMMDD": "Aufnahmedatum als JJJJMMTT", + "Captured": "Aufgenommen", + "Catalog Statistics": "Katalogstatistik", + "Catchlight": "Lichtreflex", + "Change": "Ändern", + "Change Component Mode": "Komponentenmodus ändern", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….", + "Check All": "Alle auswählen", + "Check Output Path": "Ausgabepfad prüfen", + "Checking names…": "Namen werden geprüft…", + "Choose Another Library…": "Andere Bibliothek auswählen…", + "Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen", + "Choose Edit Settings to Copy…": "Zu kopierende Bearbeitungseinstellungen auswählen…", + "Choose Folder…": "Ordner auswählen…", + "Choose a photo in the grid or filmstrip": "Wähle ein Foto im Raster oder Filmstreifen", + "Choose…": "Auswählen…", + "Cinematic": "Filmisch", + "City": "Stadt", + "Clarity": "Klarheit", + "Clean Studio": "Sauberes Studio", + "Clear": "Löschen", + "Clear Cache": "Cache leeren", + "Clear Filters": "Filter löschen", + "Clear Guides": "Hilfslinien löschen", + "Clear History": "Verlauf löschen", + "Clear Preview Cache": "Vorschau-Cache leeren", + "Clear Quick Collection": "Schnellsammlung leeren", + "Clear Reference": "Referenz entfernen", + "Clear all": "Alles löschen", + "Clear form": "Formular leeren", + "Cleared History": "Verlauf gelöscht", + "Click Zoom Ratio": "Zoomfaktor beim Klicken", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang", + "Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus", + "Click the photo to pick a colour · ⇧-click adds more": "Klicke auf das Foto, um eine Farbe auszuwählen · ⇧-Klick fügt weitere hinzu", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Klicke auf das Foto, um eine Farbe auszuwählen; ⇧-Klick fügt weitere hinzu (bis zu 5)", + "Click to clear these filters": "Klicken, um diese Filter zu löschen", + "Click to restore · double-click to rename": "Zum Wiederherstellen klicken · zum Umbenennen doppelklicken", + "Clone": "Kopierstempel", + "Close": "Schließen", + "Close Panel": "Bedienfeld schließen", + "Close Window": "Fenster schließen", + "Close all": "Alle schließen", + "Close file": "Datei schließen", + "Cloudy": "Bewölkt", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.", + "Collapse": "Einklappen", + "Collapse All Stacks": "Alle Stapel einklappen", + "Collapse Stack": "Stapel einklappen", + "Color": "Farbe", + "Color Grading": "Farbtonung", + "Color Label": "Farbmarkierung", + "Color Label Names": "Namen der Farbmarkierungen", + "Color Label Sets": "Farbmarkierungssätze", + "Color Mixer": "Farbmischer", + "Color Noise Reduction": "Farbrauschreduzierung", + "Color Priority": "Farbpriorität", + "Color Range": "Farbbereich", + "Color label cleared": "Farbmarkierung entfernt", + "Color space": "Farbraum", + "Color space: sRGB (AVIF)": "Farbraum: sRGB (AVIF)", + "Colors": "Farben", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Farbmarkierungsfilter mit mehreren Markierungen gleichzeitig; aufklappbarer lokaler Ordnerbaum.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Farbbereichsmasken durch Anklicken einer Farbprobe; Luminanzbereiche mit Bereichsleiste, Glättung und Luminanzkarte.", + "Combine files…": "Dateien zusammenführen…", + "Command Journal": "Befehlsprotokoll", + "Comments panel": "Kommentarbereich", + "Commits": "Commits", + "Company": "Unternehmen", + "Compare": "Vergleichen", + "Compare (Shift+C)": "Vergleichen (Umschalt+C)", + "Compare Before and After": "Vorher und Nachher vergleichen", + "Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich", + "Component options": "Komponentenoptionen", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,", + "Compression": "Komprimierung", + "Concrete Cool": "Kühler Beton", + "Confirm before deleting": "Vor dem Löschen bestätigen", + "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", + "Constrain Crop": "Zuschnitt beschränken", + "Continue": "Fortfahren", + "Continue Without Saving": "Ohne Speichern fortfahren", + "Contrast": "Kontrast", + "Contributors": "Mitwirkende", + "Convert to DNG": "In DNG konvertieren", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG-Konvertierung, Kopieren als DNG, Fotozusammenfügung und Smart-Vorschauen schreiben nicht mehr direkt in die endgültige Datei", + "Cool Fade": "Kühle Verblassung", + "Cool Morning": "Kühler Morgen", + "Copy": "Kopieren", + "Copy After's Settings to Before": "Nachher-Einstellungen auf Vorher übertragen", + "Copy Before to After": "Vorher auf Nachher übertragen", + "Copy Before's Settings to After": "Vorher-Einstellungen auf Nachher übertragen", + "Copy Edit Settings": "Bearbeitungseinstellungen kopieren", + "Copy History Step Settings to Before": "Einstellungen des Verlaufsschritts auf Vorher übertragen", + "Copy Metadata": "Metadaten kopieren", + "Copy as DNG": "Als DNG kopieren", + "Copy into the library (else use the files where they are)": "In die Bibliothek kopieren (sonst Dateien am Speicherort verwenden)", + "Copy pages": "Seiten kopieren", + "Copy the files (into the library's Originals/, or a folder you choose)": "Dateien kopieren (in Originals/ der Bibliothek oder einen gewählten Ordner)", + "Copy to": "Kopieren nach", + "Copy to Clipboard": "In Zwischenablage kopieren", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopieren: Die Dateien werden in den folgenden Ordner kopiert und die Bibliothek verwendet die Kopien; die Originale bleiben unverändert.", + "Copyright": "Urheberrecht", + "Copyright Info URL": "URL mit Urheberrechtsinformationen", + "Copyright Status": "Urheberrechtsstatus", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Urheberrechtsstatus, Nutzungsbedingungen und URL mit Urheberrechtsinformationen in Infobereich, Metadatenvorgaben und Exporten.", + "Country": "Land", + "Create Album": "Album erstellen", + "Create Album…": "Album erstellen…", + "Create Folder": "Ordner erstellen", + "Create Folder…": "Ordner erstellen…", + "Create New Mask": "Neue Maske erstellen", + "Create PDF from clipboard": "PDF aus Zwischenablage erstellen", + "Create PDF from file…": "PDF aus Datei erstellen…", + "Create PDF from images…": "PDF aus Bildern erstellen…", + "Create Preset": "Vorgabe erstellen", + "Create Preset…": "Vorgabe erstellen…", + "Create Proof Copy": "Softproof-Kopie erstellen", + "Create Smart Album": "Smart-Album erstellen", + "Create Smart Album from Filter…": "Smart-Album aus Filter erstellen…", + "Create Smart Album…": "Smart-Album erstellen…", + "Create Stack": "Stapel erstellen", + "Create Version": "Version erstellen", + "Create Version from Step": "Version aus Schritt erstellen", + "Create Virtual Copy": "Virtuelle Kopie erstellen", + "Creator": "Urheber", + "Creator metadata": "Urheber aus Metadaten", + "Crisp Landscape": "Knackige Landschaft", + "Crisp Vista": "Klare Aussicht", + "Crop": "Zuschneiden", + "Crop & Rotate": "Zuschneiden und drehen", + "Crop & Rotate (C)": "Zuschneiden und drehen (C)", + "Crop Aspect": "Zuschnitt-Seitenverhältnis", + "Crop, Rotate, Geometry": "Zuschnitt, Drehung, Geometrie", + "Cross Process": "Crossentwicklung", + "Culling": "Fotoauswahl", + "Curve": "Kurve", + "Custom": "Benutzerdefiniert", + "Custom template…": "Benutzerdefinierte Vorlage…", + "Custom…": "Benutzerdefiniert…", + "Cut": "Ausschneiden", + "Cut pages": "Seiten ausschneiden", + "Cycle Crop Overlay": "Zuschnittüberlagerung wechseln", + "Cycle Crop Overlay Orientation": "Ausrichtung der Zuschnittüberlagerung wechseln", + "Cycle Info Overlay": "Infoüberlagerung wechseln", + "Cycle Mask Overlay Color": "Farbe der Maskenüberlagerung wechseln", + "Cycle Mask Overlay Mode": "Modus der Maskenüberlagerung wechseln", + "Cylindrical": "Zylindrisch", + "Dark & Moody": "Dunkel und stimmungsvoll", + "Darken": "Abdunkeln", + "Darks": "Dunkle Mitteltöne", + "Date": "Datum", + "Date Groups": "Datumsgruppen", + "Day": "Tag", + "Daylight": "Tageslicht", + "Decrease Brush Feather": "Weiche Pinselkante verringern", + "Decrease Brush Size": "Pinselgröße verringern", + "Deep Sky": "Tiefer Himmel", + "Defringe": "Farbsäume entfernen", + "Deghost Amount": "Geisterbildreduzierung", + "Dehaze": "Dunst entfernen", + "Delete": "Löschen", + "Delete (⌫)": "Löschen (⌫)", + "Delete Album": "Album löschen", + "Delete All Masks": "Alle Masken löschen", + "Delete Color Label Set": "Farbmarkierungssatz löschen", + "Delete Component": "Komponente löschen", + "Delete Export Preset": "Exportvorgabe löschen", + "Delete Filter Preset": "Filtervorgabe löschen", + "Delete Imported Profile": "Importiertes Profil löschen", + "Delete Keyword": "Stichwort löschen", + "Delete Keyword Set": "Stichwortsatz löschen", + "Delete Mask": "Maske löschen", + "Delete Metadata Preset": "Metadatenvorgabe löschen", + "Delete Permanently": "Endgültig löschen", + "Delete Photo": "Foto löschen", + "Delete Photos": "Fotos löschen", + "Delete Point Color": "Punktfarbe löschen", + "Delete Point Color Sample": "Punktfarbenprobe löschen", + "Delete Point Curve Preset": "Punktkurvenvorgabe löschen", + "Delete Preset": "Vorgabe löschen", + "Delete Red Eye": "Rote-Augen-Korrektur löschen", + "Delete Red Eye Correction": "Rote-Augen-Korrektur löschen", + "Delete Spot": "Retuschierstelle löschen", + "Delete Version": "Version löschen", + "Delete all": "Alle löschen", + "Delete all spots": "Alle Retuschierstellen löschen", + "Delete mask": "Maske löschen", + "Delete them": "Löschen", + "Density": "Dichte", + "Depth Range": "Tiefenbereich", + "Descending": "Absteigend", + "Describe": "Beschreiben", + "Describe what to keep": "Beschreibe, was bleiben soll", + "Describe what to leave out": "Beschreibe, was ausgelassen werden soll", + "Describe what to select": "Beschreibe, was ausgewählt werden soll", + "Describe…": "Beschreiben…", + "Deselect": "Auswahl aufheben", + "Deselect All": "Gesamte Auswahl aufheben", + "Desert Heat": "Wüstenhitze", + "Desert Warmth": "Wüstenwärme", + "Desktop": "Schreibtisch", + "Detail": "Detail", + "Detail (D)": "Detail (D)", + "Details": "Details", + "Diagonal": "Diagonal", + "Discard Preview Cache": "Vorschau-Cache verwerfen", + "Display P3": "Display P3", + "Display name": "Anzeigename", + "Display theme": "Oberflächendesign", + "Distortion": "Verzeichnung", + "Document properties…": "Dokumenteigenschaften…", + "Don't enlarge": "Nicht vergrößern", + "Done": "Fertig", + "Download": "Herunterladen", + "Download AI Mask Model": "KI-Maskenmodell herunterladen", + "Download the SAM 3 Model?": "SAM-3-Modell herunterladen?", + "Downloads": "Downloads", + "Drag along the horizon; double-click for Auto": "Entlang des Horizonts ziehen; Doppelklick für Automatik", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Ziehe über ein Auge im Foto; die Pupille darin wird automatisch gefunden.", + "Draw Guides": "Hilfslinien zeichnen", + "Duplicate": "Duplizieren", + "Duplicate Component": "Komponente duplizieren", + "Duplicate Mask": "Maske duplizieren", + "Duplicate and Invert Mask": "Maske duplizieren und umkehren", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Jeder / beginnt eine Ordnerebene; Platzhalter werden pro Foto ausgefüllt (eine Ebene mit fehlenden Metadaten heißt „unknown“).", + "Edge": "Rand", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "besitzen eine Randeinstellung und werden beim Hineinzoomen im Hintergrund genauer berechnet. Das Modell läuft in LightCraft in reinem Rust", + "Edit": "Bearbeiten", + "Edit (E)": "Bearbeiten (E)", + "Edit Capture Time": "Aufnahmezeit bearbeiten", + "Edit Capture Time…": "Aufnahmezeit bearbeiten…", + "Edit Color Label Names": "Namen der Farbmarkierungen bearbeiten", + "Edit Color Label Names…": "Namen der Farbmarkierungen bearbeiten…", + "Edit Copy for External Editor": "Kopie für externen Editor bearbeiten", + "Edit Date": "Bearbeitungsdatum", + "Edit Info": "Informationen bearbeiten", + "Edit Label Names": "Namen der Farbmarkierungen bearbeiten", + "Edit Label Names…": "Markierungsnamen bearbeiten…", + "Edit Mask": "Maske bearbeiten", + "Edit Mask Component": "Maskenkomponente bearbeiten", + "Edit Sections": "Bearbeitungsbereiche", + "Edit Smart Album": "Smart-Album bearbeiten", + "Edit Smart Album…": "Smart-Album bearbeiten…", + "Edit Spot": "Retuschierstelle bearbeiten", + "Edit in External Editor": "In externem Editor bearbeiten", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "In externem Editor bearbeiten mit ⇧⌘E: Eine 16-Bit-TIFF-Kopie wird gestapelt und bei der Rückkehr aktualisiert.", + "Edit settings copied": "Bearbeitungseinstellungen kopiert", + "Edited": "Bearbeitet", + "Edited or not": "Bearbeitet oder nicht", + "Editing": "Bearbeitung", + "Effect": "Effekt", + "Effects": "Effekte", + "Empty Recently Deleted": "„Zuletzt gelöscht“ leeren", + "Enable Lens Corrections": "Objektivkorrekturen aktivieren", + "Enable Profile Corrections": "Profilkorrekturen aktivieren", + "End Interaction": "Interaktion beenden", + "English": "English", + "Enter Full Screen": "Vollbildmodus starten", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Datum als JJJJ-MM-TT HH:MM:SS eingeben", + "Erase": "Radieren", + "Every EXIF, GPS and XMP field in the file": "Alle EXIF-, GPS- und XMP-Felder in der Datei", + "Existing previews": "Vorhandene Vorschauen", + "Exit": "Beenden", + "Expand": "Ausklappen", + "Expand All Stacks": "Alle Stapel ausklappen", + "Expand Stack": "Stapel ausklappen", + "Expand/Collapse Stack": "Stapel aus-/einklappen", + "Export": "Exportieren", + "Export Album with Preset": "Album mit Vorgabe exportieren", + "Export Album…": "Album exportieren…", + "Export As…": "Exportieren als…", + "Export Group…": "Gruppe exportieren…", + "Export Now": "Jetzt exportieren", + "Export Point Curve Presets": "Punktkurvenvorgaben exportieren", + "Export Point Curve Presets…": "Punktkurvenvorgaben exportieren…", + "Export Presets": "Vorgaben exportieren", + "Export Presets…": "Vorgaben exportieren…", + "Export User Presets…": "Benutzervorgaben exportieren…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportdateinamen verwenden die Platzhalter der Fotoumbenennung ({title}, {seq:2}, {date:%Y-%m-%d}…) sowie {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Export unerwartet angehalten", + "Export with Preset": "Mit Vorgabe exportieren", + "Export with Previous": "Mit vorherigen Einstellungen exportieren", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exportdateien werden zunächst temporär geschrieben und anschließend umbenannt. Ein voller Datenträger oder getrenntes Laufwerk", + "Exporting": "Wird exportiert", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Export und Rendering überschreiben niemals das Originalfoto (Issue #93). Ein Export in dessen Ordner mit gleichem Namen", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Exporte werden wegen GPU-Problemen nicht mehr schwarz (Issue #78): Ein GPU-Rendering mit erschöpftem Gerätespeicher,", + "Export…": "Exportieren…", + "Exposure": "Belichtung", + "Exposure (shutter · aperture · ISO)": "Belichtung (Verschlusszeit · Blende · ISO)", + "Extended Description": "Ausführliche Beschreibung", + "External Editor": "Externer Editor", + "Face Boxes": "Gesichtsrahmen", + "Face boxes": "Gesichtsrahmen", + "Face names written to XMP by Lightroom and other apps show up here.": "Gesichtsnamen, die Lightroom und andere Apps in XMP schreiben, erscheinen hier.", + "Faded Matte": "Verblasst und matt", + "Faded Slide": "Verblasstes Dia", + "Faded Urban": "Verblasste Stadt", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Schnellere Exporte und Kartenimporte mit unverändertem Schutz für nicht wiederherstellbare Daten (Issue #134): Exporte,", + "Favorite Preset": "Vorgabe als Favorit markieren", + "Favorite Profile": "Profil als Favorit markieren", + "Favorites": "Favoriten", + "Feather": "Weiche Kante", + "File": "Datei", + "File & date": "Datei und Datum", + "File Format": "Dateiformat", + "File Name": "Dateiname", + "File Path": "Dateipfad", + "File Size": "Dateigröße", + "File Type": "Dateityp", + "File name": "Dateiname", + "Filename": "Dateiname", + "Fill": "Ausfüllen", + "Fill Edges": "Ränder füllen", + "Film": "Film", + "Filmstrip": "Filmstreifen", + "Filmstrip (/)": "Filmstreifen (/)", + "Filter": "Filter", + "Filter Bar": "Filterleiste", + "Filter Presets": "Filtervorgaben", + "Filter bar": "Filterleiste", + "Filter fields": "Filterfelder", + "Find Dust Spots": "Staubflecken finden", + "Find Missing Photos": "Fehlende Fotos suchen", + "Find Missing Photos…": "Fehlende Fotos suchen…", + "Find Similar Photos": "Ähnliche Fotos suchen", + "Find tools and commands…": "Werkzeuge und Befehle suchen…", + "Find…": "Suchen…", + "First": "Erster", + "First commit": "Erster Commit", + "Fit": "Einpassen", + "Fit (F)": "Einpassen (F)", + "Fit on Screen": "An Bildschirm anpassen", + "Fit to width": "An Breite anpassen", + "Fit visible": "Sichtbaren Bereich einpassen", + "Five stars": "Fünf Sterne", + "Flag": "Markierung", + "Flag as Pick": "Als ausgewählt markieren", + "Flag as Reject": "Als abgelehnt markieren", + "Flagged as Pick": "Als ausgewählt markiert", + "Flagged as Reject": "Als abgelehnt markiert", + "Flash": "Blitz", + "Flip H": "H spiegeln", + "Flip Horizontal": "Horizontal spiegeln", + "Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)", + "Flip V": "V spiegeln", + "Flip Vertical": "Vertikal spiegeln", + "Flow": "Fluss", + "Fluorescent": "Leuchtstofflicht", + "Focal Length": "Brennweite", + "Focal length": "Brennweite", + "Focus (assisted culling)": "Schärfe (unterstützte Fotoauswahl)", + "Folder": "Ordner", + "Folder name (renamed on disk; its photos follow)": "Ordnername (wird auf dem Datenträger umbenannt; die Fotos folgen)", + "Folders": "Ordner", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Ordner und {date} verwenden die Aufnahmezeit; bei Fotos ohne Aufnahmezeit wird das heutige Datum verwendet.", + "Food": "Essen", + "Forget Unchanged Local Photos": "Unveränderte lokale Fotos vergessen", + "Forget unchanged photos": "Unveränderte Fotos vergessen", + "Form fields panel": "Formularfeldbereich", + "Format": "Format", + "Forward": "Vorwärts", + "Frame time": "Bilddauer", + "Frame update": "Bildaktualisierung", + "Free": "Frei", + "Fresh & Bright": "Frisch und hell", + "Friday": "Freitag", + "Full": "Vollständig", + "Full Screen Preview": "Vollbildvorschau", + "Full screen mode": "Vollbildmodus", + "Full size": "Volle Größe", + "GPS Track Log": "GPS-Track", + "GPU Rendering": "GPU-Rendering", + "GPU rendering": "GPU-Rendering", + "General": "Allgemein", + "Generated demo photo": "Generiertes Demofoto", + "Geometry": "Geometrie", + "Get Develop Settings": "Entwicklungseinstellungen abrufen", + "Global Hue": "Globaler Farbton", + "Global Luminance": "Globale Luminanz", + "Global Saturation": "Globale Sättigung", + "Glossy": "Glänzend", + "Go to History Step": "Zu Verlaufsschritt wechseln", + "Golden": "Goldfarben", + "Golden Glow": "Goldener Glanz", + "Golden Hour": "Goldene Stunde", + "Grab bag": "Namensliste", + "Grain": "Körnung", + "Graphic": "Grafisch", + "Green": "Grün", + "Green Amount": "Grün – Stärke", + "Green Hue": "Grün – Farbton", + "Green Hue High": "Grün – oberer Farbton", + "Green Hue Low": "Grün – unterer Farbton", + "Green Luminance": "Grün – Luminanz", + "Green Primary": "Primärfarbe Grün", + "Green Saturation": "Grün – Sättigung", + "Green channel": "Grünkanal", + "Grid": "Raster", + "Grid Info": "Rasterinformationen", + "Gritty Street": "Raue Straße", + "Group": "Gruppe", + "Group by Date: Automatic": "Nach Datum gruppieren: automatisch", + "Group by Day": "Nach Tag gruppieren", + "Group by Month": "Nach Monat gruppieren", + "Group by Year": "Nach Jahr gruppieren", + "Group by date": "Nach Datum gruppieren", + "Group into Stack": "Zu Stapel gruppieren", + "Group name": "Gruppenname", + "Guided": "Mit Hilfslinien", + "Guided Upright": "Perspektivkorrektur mit Hilfslinien", + "Guided Upright Guides": "Hilfslinien der Perspektivkorrektur", + "HDR Merge": "Zu HDR zusammenfügen", + "HDR Merge Preview": "Vorschau der HDR-Zusammenfügung", + "HDR Panorama Merge": "Zu HDR-Panorama zusammenfügen", + "HDR Panorama Merge Preview": "Vorschau des HDR-Panoramas", + "HDR Panorama with Last Settings": "HDR-Panorama mit letzten Einstellungen", + "HDR Panoramas": "HDR-Panoramen", + "HDR Panorama…": "HDR-Panorama…", + "HDR panorama": "HDR-Panorama", + "HDR panoramas": "HDR-Panoramen", + "HDR with Last Settings": "HDR mit letzten Einstellungen", + "HDR…": "HDR…", + "Has Edits": "Enthält Bearbeitungen", + "Has GPS": "Enthält GPS", + "Heal": "Reparieren", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Kleine, weiche dunkle Flecken reparieren, die Sensorstaub auf gleichmäßigen Flächen hinterlässt", + "Height": "Höhe", + "Height (px)": "Höhe (px)", + "Help": "Hilfe", + "Hide LightCraft": "LightCraft ausblenden", + "Hide Mask": "Maske ausblenden", + "Hide Others": "Andere ausblenden", + "Hides this shortcut only; the folder and its photos stay as they are": "Blendet nur diese Verknüpfung aus; der Ordner und seine Fotos bleiben unverändert", + "High": "Hoch", + "High Contrast B&W": "Kontrastreiches Schwarzweiß", + "Highlight": "Hervorheben", + "Highlights": "Lichter", + "Highlights Hue": "Lichter – Farbton", + "Highlights Luminance": "Lichter – Luminanz", + "Highlights Saturation": "Lichter – Sättigung", + "Highlights split": "Lichtergrenze", + "Histogram": "Histogramm", + "History": "Verlauf", + "History & Activity (Y)": "Verlauf und Aktivität (Y)", + "Home": "Persönlicher Ordner", + "Horizontal": "Horizontal", + "Horizontal text": "Horizontaler Text", + "Hours": "Stunden", + "Hue": "Farbton", + "Hue Range": "Farbtonbereich", + "ISO Speed": "ISO-Empfindlichkeit", + "ISO speed": "ISO-Empfindlichkeit", + "If file exists": "Bei vorhandener Datei", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Kann deine Bibliothek beim Start nicht geöffnet werden, etwa weil sie anderweitig geöffnet, unlesbar, auf einem getrennten Laufwerk oder von einer", + "Image": "Bild", + "Import": "Importieren", + "Import Date": "Importdatum", + "Import Photos": "Fotos importieren", + "Import Photos…": "Fotos importieren…", + "Import Point Curve Presets": "Punktkurvenvorgaben importieren", + "Import Point Curve Presets…": "Punktkurvenvorgaben importieren…", + "Import Presets": "Vorgaben importieren", + "Import Presets…": "Vorgaben importieren…", + "Import Profiles": "Profile importieren", + "Import Profiles & Presets…": "Profile und Vorgaben importieren…", + "Import as new": "Neu importieren", + "Import from Device": "Von Gerät importieren", + "Import from Folder…": "Aus Ordner importieren…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importiere Fotos über Datei → Fotos importieren… (⌘⇧I) oder lege sie hier ab", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Vorgaben aus anderen Editoren importieren: XMP-Vorgaben, klassische `.lrtemplate`-Dateien, „DNG-Vorgaben“ aus mobilen Apps und `.zip`-Pakete.", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopieren prüft jede Kopie wie beim Verschieben (Issue #96): Jede Datei wird neu geschrieben, auf den Datenträger synchronisiert", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Verschieben: Fotos gehen mit derselben Ordnerstruktur und Umbenennung wie beim Kopieren in den Zielordner, etwa", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: In beliebige Ordner kopieren, nach Tag oder Monat oder in einen Ordner, beim Import umbenennen, Metadatenvorgabe und Kopieren als DNG.", + "Imported": "Importiert", + "Imported Photos": "Importierte Fotos", + "Importing": "Wird importiert", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).", + "In Recently Deleted": "In „Zuletzt gelöscht“", + "In library": "In Bibliothek", + "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", + "In use": "In Verwendung", + "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "Include subfolders": "Unterordner einbeziehen", + "Increase Brush Feather": "Weiche Pinselkante vergrößern", + "Increase Brush Size": "Pinselgröße vergrößern", + "Info": "Informationen", + "Info (I)": "Informationen (I)", + "Info Overlay Off": "Infoüberlagerung aus", + "Info Overlay: Exposure": "Infoüberlagerung: Belichtung", + "Info Overlay: File": "Infoüberlagerung: Datei", + "Info overlay": "Infoüberlagerung", + "Insert at the cursor": "An Cursorposition einfügen", + "Inspect Photo": "Foto untersuchen", + "Instant ’70s": "Sofortbild der 70er", + "Interface": "Oberfläche", + "Interface language": "Sprache der Oberfläche", + "Intersect": "Schnittmenge", + "Intersect with": "Schnittmenge mit", + "Into one folder": "In einen Ordner", + "Invert": "Umkehren", + "Invert Component": "Komponente umkehren", + "Invert Mask": "Maske umkehren", + "JPEG (Large)": "JPEG (groß)", + "JPEG (Small)": "JPEG (klein)", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG mit gleichem Namen (`IMG_0001.CR3` + `IMG_0001.JPG`) keine Begleitdatei mehr: RAW verwendet `IMG_0001.xmp`,", + "Join the ArtCraft Discord": "ArtCraft auf Discord beitreten", + "Join the ArtCraft Discord…": "ArtCraft auf Discord beitreten…", + "Join the ArtCraft community on Discord": "Der ArtCraft-Community auf Discord beitreten", + "Keep in Local": "In „Lokal“ behalten", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto. Der Miniaturbild-Cache bleibt in der Bibliothek.", + "Keyboard Shortcuts": "Tastenkürzel", + "Keyboard shortcuts": "Tastenkürzel", + "Keyword": "Stichwort", + "Keyword Painter": "Stichwortpinsel", + "Keyword Set": "Stichwortsatz", + "Keyword Sets": "Stichwortsätze", + "Keyword Suggestions": "Stichwortvorschläge", + "Keyword to paint": "Stichwort zum Auftragen", + "Keywords": "Stichwörter", + "Keywords (K)": "Stichwörter (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Hinzugefügte Stichwörter erscheinen hier; ⌥1–⌥9 wendet sie an.", + "Label": "Farbmarkierung", + "Landscape": "Landschaft", + "Language": "Sprache", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Längste Kante beim Rendern der Detailansicht; größere Werte sind auf großen Bildschirmen schärfer, aber langsamer.", + "Last": "Letzter", + "Last GPU fallback": "Letzter GPU-Fallback", + "Last commit": "Letzter Commit", + "Last loupe render": "Letztes Rendering der Lupenansicht", + "Last view": "Letzte Ansicht", + "Lateral Chromatic Aberration": "Laterale chromatische Aberration", + "Layer": "Ebene", + "Layers": "Ebenen", + "Leave them": "Beibehalten", + "Lens": "Objektiv", + "Lens Blur": "Objektivunschärfe", + "Lens Corrections": "Objektivkorrekturen", + "Level": "Waagerecht", + "Library": "Bibliothek", + "Library Info": "Bibliotheksinformationen", + "Library Originals": "Originale der Bibliothek", + "Library Preferences": "Bibliothekseinstellungen", + "Library State": "Bibliotheksstatus", + "Library saved": "Bibliothek gespeichert", + "Licence:": "Lizenz:", + "Light": "Licht", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "lehnen LightCraft 0.2.0 und ältere Versionen sie ab („unsupported format … v2“).", + "LightCraft Default": "LightCraft-Standard", + "LightCraft Help": "LightCraft-Hilfe", + "LightCraft Preset": "LightCraft-Vorgabe", + "LightCraft Presets": "LightCraft-Vorgaben", + "LightCraft Website": "LightCraft-Website", + "LightCraft couldn't find where to keep your library.": "LightCraft konnte keinen Speicherort für deine Bibliothek finden.", + "LightCraft couldn't open the library at": "LightCraft konnte die Bibliothek nicht öffnen unter", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", + "LightCraft on GitHub": "LightCraft auf GitHub", + "LightCraft website": "LightCraft-Website", + "LightCraft — Second Window": "LightCraft — zweites Fenster", + "Lights": "Helle Mitteltöne", + "Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)", + "Line delta": "Zeilendifferenz", + "Linear": "Linear", + "Linear Gradient": "Linearer Verlauf", + "Lines +/−": "Zeilen +/−", + "Lines added": "Hinzugefügte Zeilen", + "Lines deleted": "Gelöschte Zeilen", + "List Albums": "Alben auflisten", + "List Develop Controls": "Entwicklungsregler auflisten", + "List History": "Verlauf auflisten", + "List Presets": "Vorgaben auflisten", + "List Profiles": "Profile auflisten", + "List this folder in Local from now on": "Diesen Ordner ab jetzt unter „Lokal“ auflisten", + "Local": "Lokal", + "Local folders": "Lokale Ordner", + "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", + "Locate Missing File…": "Fehlende Datei suchen…", + "Locate Photo": "Foto suchen", + "Location": "Ort", + "Lock": "Sperren", + "Lock Crop Aspect": "Zuschnitt-Seitenverhältnis sperren", + "Locked": "Gesperrt", + "Long Edge": "Lange Kante", + "Looking for photos…": "Fotos werden gesucht…", + "Lossless": "Verlustfrei", + "Lossy DNG files and Smart Previews open as raw photos.": "Verlustbehaftete DNG-Dateien und Smart-Vorschauen werden als RAW-Fotos geöffnet.", + "Low": "Niedrig", + "Luminance": "Luminanz", + "Luminance Range": "Luminanzbereich", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-Looks: `.lmp`-Dateien und `.mplumpack`-Sammlungen werden als Vorgaben importiert und nach Sammlung gruppiert.", + "Lush Greens": "Sattes Grün", + "Magenta": "Magenta", + "Magenta Hue": "Magenta – Farbton", + "Magenta Luminance": "Magenta – Luminanz", + "Magenta Saturation": "Magenta – Sättigung", + "Make Candidate the Select": "Kandidat als Auswahl verwenden", + "Manual": "Manuell", + "Marquee zoom": "Zoom per Auswahlrahmen", + "Mask Adjustment": "Maskenkorrektur", + "Masking": "Maskieren", + "Masking (M)": "Maskieren (M)", + "Masks": "Masken", + "Match": "Erfülle", + "Match Total Exposures": "Gesamtbelichtungen abgleichen", + "Matte": "Matt", + "Matte Soft": "Sanft matt", + "Med": "Mittel", + "Medium Contrast": "Mittlerer Kontrast", + "Megapixels": "Megapixel", + "Memory Budget": "Speicherbudget", + "Memory Usage": "Speichernutzung", + "Memory budget": "Speicherbudget", + "Memory for caches": "Speicher für Caches", + "Menu": "Menü", + "Merge": "Zusammenfügen", + "Merge Keywords": "Stichwörter zusammenführen", + "Merge into…": "Zusammenführen mit…", + "Merged": "Zusammengefügt", + "Merged PRs": "Zusammengeführte PRs", + "Merging…": "Wird zusammengefügt…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Es gelten die Bedingungen von Meta, nicht von LightCraft. Mit dem Download akzeptierst du sie.", + "Metadata": "Metadaten", + "Metadata Preset": "Metadatenvorgabe", + "Metadata Presets": "Metadatenvorgaben", + "Metadata preset": "Metadatenvorgabe", + "Midpoint": "Mittelpunkt", + "Midtones": "Mitteltöne", + "Midtones Hue": "Mitteltöne – Farbton", + "Midtones Luminance": "Mitteltöne – Luminanz", + "Midtones Saturation": "Mitteltöne – Sättigung", + "Midtones split": "Mitteltontrennung", + "Minimize": "Minimieren", + "Missing Photos": "Fehlende Fotos", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Fehlende Metadaten ({camera}, {title}…) hinterlassen eine Lücke; bei einem leeren Ergebnis bleibt der Originalname erhalten.", + "Misty Morning": "Nebliger Morgen", + "Mode": "Modus", + "Model": "Modell", + "Models": "Modelle", + "Modified Date": "Änderungsdatum", + "Monday": "Montag", + "Mono Red Filter": "Monochrom mit Rotfilter", + "Mono Rich": "Kräftig monochrom", + "Mono Soft": "Sanft monochrom", + "Monochrome": "Monochrom", + "Month": "Monat", + "Moody": "Stimmungsvoll", + "Moody Portrait": "Stimmungsvolles Porträt", + "More": "Mehr", + "More preset options": "Weitere Vorgabenoptionen", + "Move": "Verschieben", + "Move Album": "Album verschieben", + "Move Down": "Nach unten verschieben", + "Move Down in Stack": "Im Stapel nach unten verschieben", + "Move Folder": "Ordner verschieben", + "Move Folder To…": "Ordner verschieben nach…", + "Move Mask": "Maske verschieben", + "Move Preset to Group": "Vorgabe in Gruppe verschieben", + "Move Preset to New Group": "Vorgabe in neue Gruppe verschieben", + "Move Up": "Nach oben verschieben", + "Move Up in Stack": "Im Stapel nach oben verschieben", + "Move in Stack": "Im Stapel verschieben", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Dateien verschieben (in Originals/ der Bibliothek oder einen gewählten Ordner) und aus der Quelle entfernen", + "Move them": "Verschieben", + "Move to": "Verschieben nach", + "Move to Group": "In Gruppe verschieben", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Verschieben: Die Dateien werden in den folgenden Ordner verschoben; jedes Original (und seine XMP-Begleitdatei) wird erst nach Überprüfung der Kopie aus der Quelle entfernt. Duplikate und fehlgeschlagene Dateien bleiben an ihrem Speicherort.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Verschiebt alle von diesem Datenträger importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Verschiebt die aus diesem Ordner importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", + "Muted": "Gedämpft", + "Muted Film": "Gedämpfter Film", + "My Photos": "Meine Fotos", + "My Photos Panel": "Bereich „Meine Fotos“", + "Name": "Name", + "Name (A–Z)": "Name (A–Z)", + "Name of the folder the original is in": "Name des Ordners mit dem Original", + "Named": "Benannt", + "Named People": "Benannte Personen", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Die Namen erscheinen im Farbmarkierungsmenü, in der Filterleiste und im Infobereich und werden in XMP geschrieben. Leer = Name der Farbe.", + "Navigator": "Navigator", + "Needs Work": "Überarbeitung nötig", + "Neon Dusk": "Neondämmerung", + "Neon Night": "Neonnacht", + "Nested group": "Verschachtelte Gruppe", + "Neutral": "Neutral", + "Never": "Nie", + "New": "Neu", + "New Album": "Neues Album", + "New Album…": "Neues Album…", + "New Folder…": "Neuer Ordner…", + "New Group…": "Neue Gruppe…", + "New Smart Album": "Neues Smart-Album", + "New Smart Album from Filter…": "Neues Smart-Album aus Filter…", + "New Smart Album…": "Neues Smart-Album…", + "New album": "Neues Album", + "New album…": "Neues Album…", + "New blank PDF": "Neues leeres PDF", + "New name": "Neuer Name", + "New time": "Neue Zeit", + "New…": "Neu…", + "Next Photo": "Nächstes Foto", + "Night Blue": "Nachtblau", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "die Nikon in zwei unterschiedlich komprimierte Hälften teilt, verwenden vorerst weiterhin die Vorschau.", + "No": "Nein", + "No Camera or Card Found": "Keine Kamera oder Karte gefunden", + "No Date Groups": "Keine Datumsgruppen", + "No Stars": "Keine Sterne", + "No Tool": "Kein Werkzeug", + "No automatic versions.": "Keine automatischen Versionen.", + "No camera": "Keine Kamera", + "No contributor data was built into this copy.": "Diese Version enthält keine Daten zu Mitwirkenden.", + "No date": "Kein Datum", + "No dust spots found": "Keine Staubflecken gefunden", + "No edits yet.": "Noch keine Bearbeitungen.", + "No exposure information": "Keine Belichtungsinformationen", + "No filter presets yet": "Noch keine Filtervorgaben", + "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", + "No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.", + "No matching photos": "Keine passenden Fotos", + "No metadata found": "Keine Metadaten gefunden", + "No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.", + "No named people in these photos": "Keine benannten Personen in diesen Fotos", + "No named people yet": "Noch keine benannten Personen", + "No new presets": "Keine neuen Vorgaben", + "No photo selected": "Kein Foto ausgewählt", + "No photos": "Keine Fotos", + "No photos found": "Keine Fotos gefunden", + "No photos in this folder": "Keine Fotos in diesem Ordner", + "No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern", + "No photos match the filters (View → Clear Filters)": "Keine Fotos entsprechen den Filtern (Ansicht → Filter löschen)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Noch keine RAW-Fotos: Kameras erscheinen hier, sobald ihre Fotos in der Bibliothek sind.", + "No reference photo": "Kein Referenzfoto", + "No usable GPU found: rendering on the CPU": "Keine geeignete GPU gefunden: Rendering auf der CPU", + "No versions yet. Create one to keep this look.": "Noch keine Versionen. Erstelle eine, um diesen Look zu bewahren.", + "Noise": "Rauschen", + "Noise Reduction": "Rauschreduzierung", + "Non-raw photos": "Andere Fotos als RAW", + "None": "Keine", + "Not Now": "Nicht jetzt", + "Nothing to compare": "Nichts zum Vergleichen", + "Nothing to survey": "Nichts für die Übersicht", + "Nudge Develop Value": "Entwicklungswert schrittweise ändern", + "OK": "OK", + "Object": "Objekt", + "Object Mask": "Objektmaske", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Objekt- und Beschreibungsmasken verwenden SAM 3, das Segmentierungsmodell von Meta. Es ist kein Bestandteil von LightCraft; alle anderen Funktionen arbeiten auch ohne dieses Modell.", + "October 2026": "Oktober 2026", + "Off": "Aus", + "Offset X": "Versatz X", + "Offset Y": "Versatz Y", + "Opacity": "Deckkraft", + "Opacity (%)": "Deckkraft (%)", + "Open Library": "Bibliothek öffnen", + "Open Library…": "Bibliothek öffnen…", + "Open in": "Öffnen in", + "Open in Detail": "In Detailansicht öffnen", + "Open the place in OpenStreetMap": "Ort in OpenStreetMap öffnen", + "Open…": "Öffnen…", + "Optics": "Optik", + "Optimize Library": "Bibliothek optimieren", + "Optional name": "Optionaler Name", + "Options": "Optionen", + "Orange": "Orange", + "Orange Hue": "Orange – Farbton", + "Orange Luminance": "Orange – Luminanz", + "Orange Saturation": "Orange – Sättigung", + "Orientation": "Ausrichtung", + "Original": "Original", + "Original + Settings": "Original und Einstellungen", + "Original extension without the dot": "Originalerweiterung ohne Punkt", + "Original file name without its extension": "Originaldateiname ohne Erweiterung", + "Original files": "Originaldateien", + "Original offline · editing the smart preview": "Original offline · Smart-Vorschau wird bearbeitet", + "Original offline · no smart preview": "Original offline · keine Smart-Vorschau", + "Originals": "Originale", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Andere Bilder (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Überlagerung", + "Overwrite": "Überschreiben", + "Pages": "Seiten", + "Paint": "Auftragen", + "Paint only areas like the one under the brush": "Nur Bereiche wie den unter dem Pinsel bearbeiten", + "Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.", + "Paint overlay": "Überlagerung auftragen", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in", + "Panorama": "Panorama", + "Panorama Merge": "Zu Panorama zusammenfügen", + "Panorama Merge Preview": "Panoramavorschau", + "Panorama with Last Settings": "Panorama mit letzten Einstellungen", + "Panoramas": "Panoramen", + "Panorama…": "Panorama…", + "Parametric curve": "Parametrische Kurve", + "Paste": "Einfügen", + "Paste Edit Settings": "Bearbeitungseinstellungen einfügen", + "Paste Metadata": "Metadaten einfügen", + "Paste Selected Settings": "Ausgewählte Einstellungen einfügen", + "Paste Selected Settings…": "Ausgewählte Einstellungen einfügen…", + "Paste Settings": "Einstellungen einfügen", + "Paste Settings from Previous": "Einstellungen vom vorherigen Foto einfügen", + "Paste from Previous": "Vom vorherigen Foto einfügen", + "Paste pages": "Seiten einfügen", + "Pastel Haze": "Pastelldunst", + "People": "Personen", + "Percent": "Prozent", + "Percentage": "Prozentsatz", + "Performance": "Leistung", + "Person": "Person", + "Perspective": "Perspektive", + "Pet Eye": "Tieraugen", + "Pet Eye Catchlight": "Lichtreflex für Tieraugen", + "Photo": "Foto", + "Photo Grid": "Fotoraster", + "Photo Grid (G)": "Fotoraster (G)", + "Photo Merge": "Fotos zusammenfügen", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "Fotomenü. Auch der Filmstreifen besitzt das Fotokontextmenü. Wird eine Datei erneut hinzugefügt, die unter „Zuletzt gelöscht“ liegt,", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → In externem Editor bearbeiten (⇧⌘E) rendert eine 16-Bit-TIFF-Kopie, stapelt sie mit dem Original und öffnet sie hier (App-Name unter macOS, sonst Programmpfad).", + "Photos": "Fotos", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Fotos unter „Zuletzt gelöscht“ können in der App wiederhergestellt werden: Rechtsklick → Wiederherstellen oder Endgültig löschen, auch im", + "Photos per bracket (0 = auto)": "Fotos pro Belichtungsreihe (0 = automatisch)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos, die unter „Lokal“ angezeigt, aber nie hinzugefügt oder geändert wurden, verlassen den Katalog, wenn ihr Ordner so lange nicht durchsucht wurde (Prüfung beim Öffnen der Bibliothek). Dateien bleiben auf dem Datenträger; beim Durchsuchen erscheinen sie wieder.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotos, die in diesem Ordner ankommen (Tethering, Scanner, Synchronisierungs-App), werden hinzugefügt, sobald sie vollständig sind.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Auch Import einschließlich Drag-and-drop, Suche fehlender Fotos, Erstellen und Verwerfen von Smart-Vorschauen, Umbenennungsvorschau,", + "Pick": "Auswahl", + "Pick Flag": "Auswahlmarkierung", + "Pick a colour on the photo with the eyedropper.": "Wähle mit der Pipette eine Farbe im Foto.", + "Pick the sharpest photo of each burst": "Schärfstes Foto jeder Serie auswählen", + "Picked": "Ausgewählt", + "Picks": "Ausgewählte Fotos", + "Pictures": "Bilder", + "Pixels": "Pixel", + "Point Color": "Punktfarbe", + "Point Curve": "Punktkurve", + "Point Curve Presets": "Punktkurvenvorgaben", + "Point curve": "Punktkurve", + "Portrait": "Porträt", + "Position": "Position", + "Preferences": "Einstellungen", + "Preferences…": "Einstellungen…", + "Prepare AI Masks": "KI-Masken vorbereiten", + "Preset": "Vorgabe", + "Preset name": "Name der Vorgabe", + "Presets": "Vorgaben", + "Presets & Profiles": "Vorgaben und Profile", + "Presets (Shift+P)": "Vorgaben (Umschalt+P)", + "Presets and profiles": "Vorgaben und Profile", + "Presets ▾": "Vorgaben ⌄", + "Preview": "Vorschau", + "Preview Build Progress": "Fortschritt der Vorschauerstellung", + "Preview only": "Nur Vorschau", + "Preview size": "Vorschaugröße", + "Previews": "Vorschauen", + "Previous Photo": "Vorheriges Foto", + "Print": "Drucken", + "Print…": "Drucken…", + "Profile": "Profil", + "Profile Browser": "Profilbrowser", + "Profile Distortion": "Profil-Verzeichnung", + "Profile Menu": "Profilmenü", + "Profile Vignetting": "Profil-Vignettierung", + "Profile:": "Profil:", + "Profiles": "Profile", + "Projection": "Projektion", + "Proof copy created": "Softproof-Kopie erstellt", + "Properties": "Eigenschaften", + "Public domain": "Gemeinfrei", + "Pupil Size": "Pupillengröße", + "Purple": "Violett", + "Purple Amount": "Violett – Stärke", + "Purple Hue": "Violett – Farbton", + "Purple Hue High": "Violett – oberer Farbton", + "Purple Hue Low": "Violett – unterer Farbton", + "Purple Luminance": "Violett – Luminanz", + "Purple Saturation": "Violett – Sättigung", + "Put the locations you removed from Local back (no files change)": "Aus „Lokal“ entfernte Orte wiederherstellen (keine Dateien werden geändert)", + "Quality": "Qualität", + "Query Photos": "Fotos abfragen", + "Quick Collection": "Schnellsammlung", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Schnellsammlung und Zielalbum mit B im Raster, Stichwortsätze mit ⌥1–⌥9 und Farbmarkierungssätze.", + "Quick Develop": "Schnellentwicklung", + "Quiet Green": "Ruhiges Grün", + "Quit": "Beenden", + "Quit Anyway": "Trotzdem beenden", + "Quit LightCraft": "LightCraft beenden", + "Quit with unsaved changes?": "Mit ungespeicherten Änderungen beenden?", + "RAW decoding": "RAW-Decodierung", + "Radial": "Radial", + "Radial Gradient": "Radialer Verlauf", + "Radius": "Radius", + "Random": "Zufällig", + "Range": "Bereich", + "Rating": "Bewertung", + "Rating cleared": "Bewertung entfernt", + "Ratings & flags": "Bewertungen und Markierungen", + "Raw": "RAW", + "Raw default": "RAW-Standard", + "Raw defaults": "RAW-Standardeinstellungen", + "Raw files": "RAW-Dateien", + "Raw photos": "RAW-Fotos", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "RAW-Fotos als DNG mit eingebetteten Bearbeitungen. Größen-, Farb- und Ausgabeoptionen gelten nicht.", + "Read Metadata from File": "Metadaten aus Datei lesen", + "Read mode": "Lesemodus", + "Read the SAM License": "SAM-Lizenz lesen", + "Reading": "Wird gelesen", + "Reading folder…": "Ordner wird gelesen…", + "Real name": "Echter Name", + "Recent": "Zuletzt verwendet", + "Recent Keywords": "Letzte Stichwörter", + "Recently Added": "Zuletzt hinzugefügt", + "Recently Deleted": "Zuletzt gelöscht", + "Red": "Rot", + "Red Eye": "Rote Augen", + "Red Hue": "Rot – Farbton", + "Red Luminance": "Rot – Luminanz", + "Red Primary": "Primärfarbe Rot", + "Red Saturation": "Rot – Sättigung", + "Red channel": "Rotkanal", + "Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume", + "Redo": "Wiederholen", + "Reference": "Referenz", + "Reference View": "Referenzansicht", + "Reference the files where they are": "Auf Dateien am aktuellen Speicherort verweisen", + "Refine": "Verfeinern", + "Refine AI Mask Detail": "KI-Maskendetails verfeinern", + "Refine Edges": "Ränder verfeinern", + "Refine Mask Edges": "Maskenränder verfeinern", + "Refine Saturation": "Sättigung verfeinern", + "Refining the mask's detail…": "Maskendetails werden verfeinert…", + "Refresh Source": "Quelle aktualisieren", + "Refresh Source (/)": "Quelle aktualisieren (/)", + "Reject": "Ablehnen", + "Reject below focus": "Unter Schärfegrenzwert ablehnen", + "Rejected": "Abgelehnt", + "Rejects": "Abgelehnte Fotos", + "Reliability": "Zuverlässigkeit", + "Relink Photo": "Foto neu verknüpfen", + "Reload Changed Files": "Geänderte Dateien neu laden", + "Reload from Disk": "Vom Datenträger neu laden", + "Remove": "Entfernen", + "Remove (H)": "Entfernen (H)", + "Remove CA": "Chromatische Aberration entfernen", + "Remove Chromatic Aberration": "Chromatische Aberration entfernen", + "Remove Disk from Library": "Datenträger aus Bibliothek entfernen", + "Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…", + "Remove Face Box": "Gesichtsrahmen entfernen", + "Remove Folder from Library": "Ordner aus Bibliothek entfernen", + "Remove a filter above, or choose Clear all": "Entferne oben einen Filter oder wähle „Alles löschen“", + "Remove from Album": "Aus Album entfernen", + "Remove from Favorites": "Aus Favoriten entfernen", + "Remove from Library…": "Aus Bibliothek entfernen…", + "Remove from Local": "Aus „Lokal“ entfernen", + "Remove from Photo": "Von Foto entfernen", + "Remove from Selected Photos": "Von ausgewählten Fotos entfernen", + "Remove from Stack": "Aus Stapel entfernen", + "Remove from Target Album": "Aus Zielalbum entfernen", + "Remove from survey": "Aus Übersicht entfernen", + "Remove location info": "Ortsinformationen entfernen", + "Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)", + "Remove this filter": "Diesen Filter entfernen", + "Remove this group": "Diese Gruppe entfernen", + "Remove this rule": "Diese Regel entfernen", + "Rename": "Umbenennen", + "Rename Album": "Album umbenennen", + "Rename Component": "Komponente umbenennen", + "Rename Folder": "Ordner umbenennen", + "Rename Folder…": "Ordner umbenennen…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Ordner umbenennen… und Ordner verschieben nach… unter „Lokal“ lassen sich rückgängig machen und wiederholen (Issue #97). Der Ordner wird auf dem Datenträger", + "Rename Keyword": "Stichwort umbenennen", + "Rename Keyword…": "Stichwort umbenennen…", + "Rename Mask": "Maske umbenennen", + "Rename Photos": "Fotos umbenennen", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Beim Umbenennen von Fotos wird kein anderes Foto überschrieben, wenn sich nur die Groß-/Kleinschreibung ändert (Issue #95). Auf Laufwerken,", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Beim Umbenennen meldet LightCraft Dateien, die nach einem Fehler nicht zurückverschoben werden konnten (Issue #105), etwa bei einer getrennten Netzwerkfreigabe", + "Rename Photos…": "Fotos umbenennen…", + "Rename Preset": "Vorgabe umbenennen", + "Rename Preview": "Umbenennungsvorschau", + "Rename Template Tags": "Platzhalter der Umbenennungsvorlage", + "Rename Version": "Version umbenennen", + "Rename…": "Umbenennen…", + "Rendering": "Wird gerendert", + "Rendering…": "Wird gerendert…", + "Reorder Masks": "Masken umordnen", + "Reset": "Zurücksetzen", + "Reset All Curves": "Alle Kurven zurücksetzen", + "Reset Before to Import State": "Vorher auf Importzustand zurücksetzen", + "Reset Control": "Regler zurücksetzen", + "Reset Crop": "Zuschnitt zurücksetzen", + "Reset Edits": "Bearbeitungen zurücksetzen", + "Reset Parametric Curve": "Parametrische Kurve zurücksetzen", + "Reset Section": "Bereich zurücksetzen", + "Reset Tone Curve": "Gradationskurve zurücksetzen", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Alle Kurven zurücksetzen: Punktkurven (alle Kanäle) und parametrische Kurve (Doppelklick auf einen Kanal setzt nur diesen zurück)", + "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", + "Reshuffle": "Neu mischen", + "Resize Face Box": "Gesichtsrahmengröße ändern", + "Resize to": "Größe ändern auf", + "Resolution (ppi)": "Auflösung (ppi)", + "Restore": "Wiederherstellen", + "Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…", + "Restore Version": "Version wiederherstellen", + "Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.", + "Restore them": "Wiederherstellen", + "Retouch": "Retuschieren", + "Reverse the order": "Reihenfolge umkehren", + "Revert": "Zurück zum letzten Stand", + "Review": "Überprüfung", + "Review Import": "Import überprüfen", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Rechtsklick auf ein Foto → Als Referenzfoto festlegen, dann Ansicht → Referenzansicht (Umschalt+R)", + "Right-click a preset to delete it": "Rechtsklick auf eine Vorgabe, um sie zu löschen", + "Rights Usage Terms": "Nutzungsbedingungen", + "Rotate": "Drehen", + "Rotate Crop": "Zuschnitt drehen", + "Rotate Crop Aspect": "Zuschnitt-Seitenverhältnis drehen", + "Rotate Left": "Nach links drehen", + "Rotate Right": "Nach rechts drehen", + "Roughness": "Unregelmäßigkeit", + "Roundness": "Rundheit", + "Rules": "Regeln", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM-3-Download angehalten: Beim nächsten Mal wird er an derselben Stelle fortgesetzt.", + "Same as raw default": "Wie RAW-Standard", + "Sample Mask Color": "Maskenfarbe aufnehmen", + "Sample a colour on the photo": "Eine Farbe im Foto aufnehmen", + "Saturation": "Sättigung", + "Saturation Range": "Sättigungsbereich", + "Saturday": "Samstag", + "Save": "Speichern", + "Save As…": "Speichern unter…", + "Save Color Label Set": "Farbmarkierungssatz speichern", + "Save Current Filter as Preset…": "Aktuellen Filter als Vorgabe speichern…", + "Save Current Keywords as Set…": "Aktuelle Stichwörter als Satz speichern…", + "Save Export Preset": "Exportvorgabe speichern", + "Save Filter Preset": "Filtervorgabe speichern", + "Save Keyword Set": "Stichwortsatz speichern", + "Save Metadata Preset": "Metadatenvorgabe speichern", + "Save Metadata Preset…": "Metadatenvorgabe speichern…", + "Save Metadata to File": "Metadaten in Datei speichern", + "Save Point Curve Preset": "Punktkurvenvorgabe speichern", + "Save Point Curve…": "Punktkurve speichern…", + "Save as Preset": "Als Vorgabe speichern", + "Save as Set…": "Als Satz speichern…", + "Save as Smart Album…": "Als Smart-Album speichern…", + "Save as set": "Als Satz speichern", + "Save as…": "Speichern unter…", + "Save preset": "Vorgabe speichern", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", + "Scale": "Skalierung", + "Scan failed": "Durchsuchen fehlgeschlagen", + "Scanning": "Wird durchsucht", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.", + "Screen": "Bildschirm", + "Search": "Suche", + "Search Photos": "Fotos suchen", + "Search…": "Suchen…", + "Seasons": "Jahreszeiten", + "Second Window": "Zweites Fenster", + "Select": "Auswählen", + "Select All": "Alles auswählen", + "Select Luminance Range": "Luminanzbereich auswählen", + "Select Mask": "Maske auswählen", + "Select Photos": "Fotos auswählen", + "Select Spot": "Retuschierstelle auswählen", + "Select a photo to edit": "Wähle ein Foto zum Bearbeiten", + "Select by": "Auswählen nach", + "Select by Flag, Rating or Label": "Nach Markierung, Bewertung oder Farbmarkierung auswählen", + "Select photos, then choose View → Survey (N)": "Wähle Fotos aus, dann Ansicht → Übersicht (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Wähle zwei Fotos aus, dann Ansicht → Vergleichen (Umschalt+C)", + "Selected": "Ausgewählt", + "Selecting…": "Wird ausgewählt…", + "Selenium Tone": "Selentonung", + "Send Feedback…": "Feedback senden…", + "Sepia Tone": "Sepiatonung", + "Sequence number zero-padded to N digits (1–9)": "Laufende Nummer, mit Nullen auf N Stellen aufgefüllt (1–9)", + "Sequence number, counting from the start number": "Laufende Nummer, beginnend mit der Startnummer", + "Services": "Dienste", + "Set": "Festlegen", + "Set Album Cover": "Albumcover festlegen", + "Set Before": "Vorher festlegen", + "Set Color Label": "Farbmarkierung festlegen", + "Set Crop": "Zuschnitt festlegen", + "Set Develop Values": "Entwicklungswerte festlegen", + "Set Flag": "Markierung festlegen", + "Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen", + "Set Mask Adjustments": "Maskenkorrekturen festlegen", + "Set Point Curve": "Punktkurve festlegen", + "Set Profile": "Profil festlegen", + "Set Rating": "Bewertung festlegen", + "Set Top of Stack": "Oberstes Foto im Stapel festlegen", + "Set as Album Cover": "Als Albumcover festlegen", + "Set as Before": "Als Vorher festlegen", + "Set as Reference Photo": "Als Referenzfoto festlegen", + "Set as Target Album": "Als Zielalbum festlegen", + "Set as Target Album (B adds to it)": "Als Zielalbum festlegen (B fügt Fotos hinzu)", + "Set as Top of Stack": "Als oberstes Foto im Stapel festlegen", + "Set date & time": "Datum und Uhrzeit festlegen", + "Set name": "Satzname", + "Set the mix from the photo's colours": "Mischung anhand der Farben des Fotos festlegen", + "Settings": "Einstellungen", + "Settings file problem": "Problem mit Einstellungsdatei", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Einstellungsdateien werden nie still zurückgesetzt (Issue #103): Beschädigte `prefs.json`, `presets.json` oder `view.json` bleiben", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Einstellungen für neue RAW-Fotos. Änderungen wirken sich nicht auf Fotos aus, die bereits in der Bibliothek sind.", + "Settings pasted": "Einstellungen eingefügt", + "Settings to include": "Einzubeziehende Einstellungen", + "Settings…": "Einstellungen…", + "Shade": "Schatten", + "Shadow": "Schatten", + "Shadows": "Tiefen", + "Shadows Hue": "Tiefen – Farbton", + "Shadows Luminance": "Tiefen – Luminanz", + "Shadows Saturation": "Tiefen – Sättigung", + "Shadows Tint": "Tiefen – Tonung", + "Shadows split": "Tiefengrenze", + "Sharpen": "Schärfen", + "Sharpening": "Schärfen", + "Sharpness": "Schärfe", + "Shift": "Verschieben", + "Shift by": "Verschieben um", + "Short Edge": "Kurze Kante", + "Show": "Anzeigen", + "Show All": "Alle einblenden", + "Show Clipping": "Beschnitt anzeigen", + "Show Deghost Overlay": "Geisterbildüberlagerung anzeigen", + "Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen", + "Show Luminance Map": "Luminanzkarte anzeigen", + "Show Mask": "Maske anzeigen", + "Show Mask Overlay": "Maskenüberlagerung anzeigen", + "Show Mask Pins": "Maskenpins anzeigen", + "Show Original": "Original anzeigen", + "Show Photo Counts": "Fotoanzahl anzeigen", + "Show Photos with Keyword": "Fotos mit Stichwort anzeigen", + "Show Pins": "Pins anzeigen", + "Show Source": "Quelle anzeigen", + "Show Thumbnails": "Miniaturbilder anzeigen", + "Show destination gamut warning": "Zielfarbraumwarnung anzeigen", + "Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen", + "Show file names": "Dateinamen anzeigen", + "Show in Explorer": "Im Explorer anzeigen", + "Show in File Manager": "Im Dateimanager anzeigen", + "Show in Finder": "Im Finder anzeigen", + "Show on Map": "Auf Karte anzeigen", + "Show overlay (O)": "Überlagerung anzeigen (O)", + "Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen", + "Show the Navigator while zoomed in": "Navigator beim Hineinzoomen anzeigen", + "Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken", + "Show/Hide Mask": "Maske ein-/ausblenden", + "Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden", + "Shutter speed": "Verschlusszeit", + "Side panels": "Seitliche Bedienfelder", + "Sidecar names": "Namen der Begleitdateien", + "Size": "Größe", + "Size (% of short edge)": "Größe (% der kurzen Kante)", + "Size limit": "Größenbegrenzung", + "Skip": "Überspringen", + "Sky": "Himmel", + "Slideshow": "Diashow", + "Slideshow paused": "Diashow pausiert", + "Slideshow resumed": "Diashow fortgesetzt", + "Slideshow · Space pauses · Esc ends": "Diashow · Leertaste pausiert · Esc beendet", + "Slideshow, second window, All Metadata, System Info.": "Diashow, zweites Fenster, alle Metadaten und Systeminformationen.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Kleinere und schnellere Kataloge: Fotos, die nur unter „Lokal“ angesehen, aber nie hinzugefügt, bewertet oder bearbeitet wurden, werden vergessen,", + "Smart Album": "Smart-Album", + "Smart Album Rule Fields": "Regelfelder für Smart-Alben", + "Smart Preview Status": "Status der Smart-Vorschauen", + "Smart Previews Location": "Speicherort der Smart-Vorschauen", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Smart-Alben mit Regel-Editor: alle, beliebige oder keine Regeln, verschachtelte Gruppen und 26 Felder.", + "Smart preview available": "Smart-Vorschau verfügbar", + "Smart previews": "Smart-Vorschauen", + "Smart previews folder changed": "Ordner für Smart-Vorschauen geändert", + "Smoothness": "Glättung", + "Soft B&W": "Sanftes Schwarzweiß", + "Soft Pastel": "Sanftes Pastell", + "Soft Proofing": "Softproof", + "Soft Skin": "Weiche Haut", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony-ARWs von vor etwa 2017, RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7, A7 II, A7R II und verwandte", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Verkleinerte verlustfreie ARW-Dateien der Sony ILCE-7M4 werden jetzt aus unterabgetasteten YCbCr-Kacheln in lineares RGB decodiert.", + "Sort": "Sortieren", + "Sort by": "Sortieren nach", + "Source": "Quelle", + "Source code on GitHub": "Quellcode auf GitHub", + "Spherical": "Sphärisch", + "Spiral": "Spirale", + "Split Stack": "Stapel teilen", + "Spring Fresh": "Frühlingsfrische", + "Square Grid": "Quadratisches Raster", + "Square Grid (G toggles)": "Quadratisches Raster (G schaltet um)", + "Square Grid: show file names and formats": "Quadratisches Raster: Dateinamen und Formate anzeigen", + "Stack": "Stapel", + "Stack photos taken within this time of each other:": "Fotos innerhalb dieses Zeitabstands stapeln:", + "Stacks": "Stapel", + "Standard": "Standard", + "Star rating (0–5)": "Sternebewertung (0–5)", + "Start at": "Beginnen bei", + "Start number": "Startnummer", + "Starting": "Wird gestartet", + "Startup": "Beim Start", + "State / Province": "Bundesland / Provinz", + "Stop": "Anhalten", + "Stop Building Previews": "Vorschauerstellung anhalten", + "Stop Using as Target Album": "Nicht mehr als Zielalbum verwenden", + "Stopping…": "Wird angehalten…", + "Straighten": "Gerade ausrichten", + "Straighten Tool": "Gerade-ausrichten-Werkzeug", + "Strong Contrast": "Starker Kontrast", + "Style": "Stil", + "Subfolder": "Unterordner", + "Subfolder name": "Unterordnername", + "Subject": "Motiv", + "Subtract": "Subtrahieren", + "Subtract from this mask": "Von dieser Maske subtrahieren", + "Suggestions": "Vorschläge", + "Summer Haze": "Sommerdunst", + "Sunday": "Sonntag", + "Survey": "Übersicht", + "Survey (N)": "Übersicht (N)", + "Swap": "Tauschen", + "Swap Aspect (X)": "Seitenverhältnis tauschen (X)", + "Swap Before and After": "Vorher und Nachher tauschen", + "Swap Before and After Settings": "Vorher- und Nachher-Einstellungen tauschen", + "Swap Compare Photos": "Vergleichsfotos tauschen", + "Switch light / dark theme": "Helles/dunkles Design wechseln", + "Sync Settings": "Einstellungen synchronisieren", + "System": "System", + "System Info": "Systeminformationen", + "System Info…": "Systeminformationen…", + "System default": "Systemstandard", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "und TZ/ZS-Serien, Leica D-Lux, V-Lux und C-Lux sowie GH6, GH7, G9 II, S5 II und S9. Ihr Farb- und Tonwertbild wird", + "Table": "Tabelle", + "Tags": "Platzhalter", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Platzhalter: Siehe „Platzhalter“ neben der Vorlage. Dateien werden auf dem Datenträger umbenannt (mit ihren XMP-Begleitdateien); vorhandene Namen erhalten -1, -2…", + "Take a snapshot": "Schnappschuss erstellen", + "Targeted Adjustment": "Gezielte Anpassung", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die dortigen Farben anzupassen", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die Kurve dort anzupassen", + "Teal & Amber": "Petrol und Bernstein", + "Teal & Orange": "Petrol und Orange", + "Temp": "Temp.", + "Temperature": "Temperatur", + "Template": "Vorlage", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung — deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", + "Text": "Text", + "Text direction": "Textrichtung", + "Texture": "Struktur", + "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", + "The download didn't work:": "Der Download ist fehlgeschlagen:", + "The number at the end of the original name (IMG_0042 → 0042)": "Nummer am Ende des Originalnamens (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Die Originalerweiterung wird immer angehängt; {ext} fügt sie zusätzlich innerhalb des Namens ein.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Unveränderte Originaldateien, jeweils mit einer XMP-Begleitdatei für die Bearbeitungen.", + "The photo has these settings": "Das Foto enthält diese Einstellungen", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Der Miniaturbild-Cache zählt und löscht ausschließlich eigene Dateien (Issue #98). Wird eine Bibliothek in einem Ordner mit", + "Theme": "Design", + "They can be restored from Recently Deleted until it is emptied.": "Sie können aus „Zuletzt gelöscht“ wiederhergestellt werden, bis dieser Bereich geleert wird.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", + "Thirds": "Drittel", + "This Computer": "Dieser Computer", + "This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Diese Version enthält noch keine Downloadquelle für das Modell: Lege die Dateien selbst im obigen Ordner ab (siehe docs/ai-masks.md).", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.", + "This photo can't be opened": "Dieses Foto kann nicht geöffnet werden", + "Threshold": "Schwellenwert", + "Thumbnail cache": "Miniaturbild-Cache", + "Thursday": "Donnerstag", + "Time zone": "Zeitzone", + "Tint": "Tonung", + "Title": "Titel", + "Title metadata": "Titel aus Metadaten", + "Toggle Keyword from Set": "Stichwort aus Satz umschalten", + "Toggle Mask": "Maske umschalten", + "Toggle Section": "Bereich umschalten", + "Toggle Zoom": "Zoom umschalten", + "Tone Curve": "Gradationskurve", + "Tools": "Werkzeuge", + "Top Level": "Oberste Ebene", + "Transfer": "Übertragen", + "Treatment": "Behandlung", + "Triangle": "Dreieck", + "Try Again": "Erneut versuchen", + "Try Saving Again": "Speichern erneut versuchen", + "Tuesday": "Dienstag", + "Tungsten": "Glühlampenlicht", + "Turn Off": "Ausschalten", + "Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner", + "Type": "Typ", + "Uncheck All": "Alle abwählen", + "Undo": "Rückgängig", + "Unedited": "Unbearbeitet", + "Unedited raws": "Unbearbeitete RAW-Fotos", + "Unflag": "Markierung entfernen", + "Unflagged": "Unmarkiert", + "Ungroup Stack": "Stapel auflösen", + "Unknown Date": "Unbekanntes Datum", + "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert — prüfe die Vorschau auf einen {typo}.", + "Unrated": "Unbewertet", + "Unreadable": "Nicht lesbar", + "Update": "Aktualisieren", + "Update Mask Shape": "Maskenform aktualisieren", + "Update Preset with Current Settings": "Vorgabe mit aktuellen Einstellungen aktualisieren", + "Update Rules from Current Filter": "Regeln aus aktuellem Filter aktualisieren", + "Update Version": "Version aktualisieren", + "Update Version with Current Settings": "Version mit aktuellen Einstellungen aktualisieren", + "Update with Current Settings": "Mit aktuellen Einstellungen aktualisieren", + "Updated the edits saved in the external editor": "Im externen Editor gespeicherte Bearbeitungen aktualisiert", + "Upright": "Perspektivkorrektur", + "Urban": "Städtisch", + "Use Keyword Set": "Stichwortsatz verwenden", + "Use Library Folder": "Bibliotheksordner verwenden", + "Use camera-specific defaults": "Kameraspezifische Standardeinstellungen verwenden", + "Use the GPU for rendering": "GPU zum Rendern verwenden", + "User Presets": "Benutzervorgaben", + "Username": "Benutzername", + "Value": "Wert", + "Variance": "Varianz", + "Version": "Version", + "Version name": "Versionsname", + "Versions": "Versionen", + "Versions (Shift+V)": "Versionen (Umschalt+V)", + "Vertical": "Vertikal", + "Vertical text": "Vertikaler Text", + "Vibrance": "Dynamik", + "Videos": "Videos", + "View": "Ansicht", + "Viewing and sharing": "Ansicht und Weitergabe", + "Vignette": "Vignette", + "Vignetting": "Vignettierung", + "Vintage": "Vintage", + "Virtual Copy": "Virtuelle Kopie", + "Visible": "Sichtbar", + "Visualize Spots": "Flecken sichtbar machen", + "Visualize Spots (A)": "Flecken sichtbar machen (A)", + "Visualize range": "Bereich sichtbar machen", + "Vivid": "Lebendig", + "Vivid Pop": "Lebendige Farben", + "Warm Film": "Warmer Film", + "Warm Glow": "Warmer Glanz", + "Warm Print": "Warmer Abzug", + "Warm Table": "Warmer Tisch", + "Watched folder": "Überwachter Ordner", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatischer Import aus überwachten Ordnern, DNG-Konvertierung, Duplizieren und Erstellen von Standard-, 1:1- und Smart-Vorschauen.", + "Watermark": "Wasserzeichen", + "Wednesday": "Mittwoch", + "What's New": "Neuigkeiten", + "When rated or hovered": "Bei Bewertung oder Mauszeigerkontakt", + "White Balance": "Weißabgleich", + "White Balance Selector": "Weißabgleich-Pipette", + "White Balance Selector (W)": "Weißabgleich-Pipette (W)", + "Whites": "Weiß", + "Width": "Breite", + "Width (% of photo)": "Breite (% des Fotos)", + "Width (px)": "Breite (px)", + "Width × Height": "Breite × Höhe", + "Will be replaced": "Wird ersetzt", + "Will be restored": "Wird wiederhergestellt", + "Window": "Fenster", + "Winter Blue": "Winterblau", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Ohne Speichern fortfahren und Beenden (Issue #100). Sie startet keine unbemerkte Demo-Sitzung mehr, die wie eine zurückgesetzte Bibliothek wirkte und", + "XMP Sidecar Preferences": "Einstellungen für XMP-Begleitdateien", + "XMP sidecars": "XMP-Begleitdateien", + "Year": "Jahr", + "Yellow": "Gelb", + "Yellow Hue": "Gelb – Farbton", + "Yellow Luminance": "Gelb – Luminanz", + "Yellow Saturation": "Gelb – Sättigung", + "Yes": "Ja", + "You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.", + "Your Name": "Dein Name", + "Your library couldn't be opened": "Deine Bibliothek konnte nicht geöffnet werden", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100 %", + "Zoom In": "Vergrößern", + "Zoom Out": "Verkleinern", + "Zoom to Fit": "Zum Einpassen zoomen", + "Zoom to page level": "Auf Seitenebene zoomen", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`, zusammen mit ihren XMP-Begleitdateien. Jedes Original verlässt die Karte erst nach", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`. Auf Laufwerken ohne diese Unterscheidung wird die Schreibweise weiterhin geändert.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.", + "a literal %": "ein wörtliches %", + "a removed photo": "einem entfernten Foto", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", + "again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "gegen Originale durchsucht nicht mehr die gesamte Bibliothek, wenn an diesem Pfad nichts liegt.", + "all": "alle", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "bereits vorhandenen fremden Bildern unter `thumbs/` geöffnet, bleiben diese beim Leeren oder Verkleinern des Caches erhalten.", + "and": "und", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "Vor Verwendung werden die Dateien geprüft. Masken behalten ihre Auswahl und lassen sich ohne das Modell rendern und exportieren.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "und alle importierten Fotos als ZIP herunter. Datei → Bibliothek aus Sicherung wiederherstellen… stellt sie wieder her; die aktuelle Bibliothek", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "und blockiert das Fenster nie. Es ist optional und kein Bestandteil von LightCraft, unter Metas SAM-Lizenz mit etwa 3,4 GB.", + "any": "mindestens eine", + "are empty": "sind leer", + "aren't empty": "sind nicht leer", + "as `.corrupt-