Merge remote-tracking branch 'origin/main' into claude/fix-316-section-eye

# Conflicts:
#	crates/ui-egui/src/headless.rs
This commit is contained in:
Claude 2026-10-08 19:18:55 +00:00
commit 57dc811a6a
No known key found for this signature in database
150 changed files with 19402 additions and 976 deletions

2
Cargo.lock generated
View File

@ -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",

View File

@ -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).

View File

@ -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

View File

@ -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 <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/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<String> = 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<std::path::PathBuf>, 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> {

View File

@ -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<Value> = 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}");
}

View File

@ -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()

View File

@ -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<Backend>,
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<Backend>, index: ThumbIndex, cache: &Arc<PreviewCache>, ctx: egui::Context) -> Workers {
pub fn start(
n: usize,
store: &str,
backend: Option<Backend>,
index: ThumbIndex,
cache: &Arc<PreviewCache>,
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 {

View File

@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
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
}

View File

@ -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:
//! `<settings folder>/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.<KEEP>.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<LevelFilter> {
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
pending: Vec<String>,
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(&note);
}
}
/// 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<Sink>,
}
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<PathBuf, String> {
// 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(&timestamp(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(&timestamp(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<AppLogger> = 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);
}
}

View File

@ -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<std::path::PathBuf> {
/// 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<String> {
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<String> {
gpu_crash_check_at(gpu_marker_path(), in_memory)
}
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
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<std::path::PathBuf> {
lightcraft_engine::camera_profiles::config_dir()
}
/// `<config>/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<std::path::PathBuf> {
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 (`<config>/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-<unix time>` 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<UiState>, Option<String>, 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<UiState>, Option<String>, 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<UiState>, Option<String>, bool) {
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, 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<UiState>, Option<String>, bo
};
match serde_json::from_slice::<UiState>(&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<std::path::PathBuf>,
written: Vec<u8>,
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 <url>` 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<std::path::PathBuf>, 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<std::path::PathBuf>, 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<std::path::PathBuf>, 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: <settings folder>/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=<env_logger directives>. Records go to stderr and to
<settings folder>/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<std::path::PathBuf> {
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<StderrLog> = 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<String>) -> Option<u16> {
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<u16> = 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);
}

View File

@ -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::<u8>().is_ok()))
}
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
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::<HashSet<_>>();
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<String>) {
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"));
}
}

View File

@ -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 `<image>`, 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 |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<FolderNode>,
}
/// A folder while the tree is being built (children by identity key).
struct Level {
name: String,
path: String,
own: usize,
volume: bool,
children: Vec<String>,
}
/// Where a stored folder path lies: its volume and the folder names below it.
struct Placed {
mount: String,
volume: String,
names: Vec<String>,
}
/// 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<Placed> {
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/<host>`, macOS `/Network/Servers/<host>`
["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<String> {
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::<Vec<_>>().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<FolderNode> {
// 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<String, Level> = HashMap::new();
let mut roots: Vec<String> = 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<FolderNode> = 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<String> = 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<String, Level>) -> Option<FolderNode> {
let l = levels.get(key)?;
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
sort(&mut children);
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
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)));
}

View File

@ -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<PhotoId> = ids.iter().copied().collect();
let mut ops: Vec<Op> = 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;

View File

@ -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());

View File

@ -37,6 +37,10 @@ pub struct Filter {
pub deleted: bool,
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
pub date: Option<String>,
/// 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<String>,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option<String>,
/// 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<String> {
// 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<PhotoId> {
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<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = 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<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
@ -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<String>,
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::<Vec<_>>().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<String> = 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`,

View File

@ -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/<host>, macOS /Network/Servers/<host>
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::<usize>(), 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<String> = 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::<usize>(), 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:?}");
}
}
}

View File

@ -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.

View File

@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
};
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<u8>) -> Vec<u8> {
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());

View File

@ -105,6 +105,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
decode_with_fallback(bytes, opts, None)
}
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
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<Decoded> {
} 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<u8> = (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));

View File

@ -156,6 +156,14 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
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<Decoded> {
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<Decoded> {

View File

@ -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<Decoded> {
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<Decoded> {
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<Decoded> {
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<Decoded> {
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<Vec<u8>> {
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<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
if v.len() < n * s {

View File

@ -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<u8> {
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);

View File

@ -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]

View File

@ -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)]

View File

@ -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]

View File

@ -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"))));

View File

@ -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<CameraLook> {
@ -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<Vec<([f64; 3
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
let to_camera = transform.matrix.inverse()?;
let (pairs, _) = collect_pairs(&sensor, &reference)?;
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
}
@ -150,10 +149,31 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
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<f32>) -> 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<Mat3> {
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<HsvTable>)>) -> Option<CameraLook> {
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<HsvTable>)>) -> Option<CameraLook> {
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<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
let 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::<f64>();
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);

View File

@ -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<Option<String>, 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);

View File

@ -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<String>, 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.

View File

@ -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<Vec<f64>> {
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<CommandSpec> {
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<Point> {
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<CommandSpec> {
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| {

View File

@ -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<Value> {
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, &params)?;
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,

View File

@ -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<ImportRequest> {
{
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<ImportRequest> {
/// `album`).
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
let mut report = serde_json::to_value(report).unwrap_or_default();
let imported: Vec<u64> = 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<u64> =
["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<CommandSpec> {
"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<CommandSpec> {
}
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<CommandSpec> {
"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<CommandSpec> {
}
),
// ---- 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<CommandSpec> {
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<PhotoId> = 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::<Vec<_>>();
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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
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<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
let ids_of = |k: &str| -> Vec<u64> { 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
}
}

View File

@ -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<PhotoId> {
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<CommandSpec> {
@ -81,10 +86,14 @@ pub fn specs() -> Vec<CommandSpec> {
}))
}),
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,

View File

@ -258,6 +258,35 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
.collect()
}
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
fn vertical_watermark_glyph(
font: &ab_glyph::FontRef<'_>,
data: &harfrust::ShaperData,
ch: char,
px: f32,
left: f32,
top: f32,
) -> Option<ab_glyph::Glyph> {
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<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let mut prev: Vec<usize> = (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<i64> {
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> {
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<i64> {
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<f64> {
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<bool> { 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::<Resize>(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::<DngCompression>(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::<Anchor>(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::<Watermark>(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);
}
}

View File

@ -43,8 +43,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
(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<lightcraft_develop::EmbeddedLens> {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_geom::Point;
@ -87,10 +88,6 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
}
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> 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<lightcraft_codecs::Decoded> {
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<lightcraft_raster::Rgba8> {
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<f32> {
let l: Vec<f32> = (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::<f32>();
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<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
let na = pa.iter().map(|v| v * v).sum::<f32>().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<u8> {
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();

View File

@ -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<Self> {
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<lightcraft_develop::Preset>,
@ -147,12 +172,18 @@ pub struct Duplicate {
pub existing: Option<u64>,
/// `"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<u64>,
pub duplicates: Vec<Duplicate>,
/// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub restored: Vec<u64>,
/// Move: the originals that were moved (and removed from the source).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub moved: Vec<Moved>,
@ -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<crate::import_move::Placed> = 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<String, PhotoId> = 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<String, PhotoId>, HashMap<String, PhotoId>) = 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<PhotoId> = Vec::new();
let mut purged: std::collections::HashSet<PhotoId> = 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);

View File

@ -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<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
pub browse: Option<Browse>,
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
/// from it and from the folders inside it.
pub library_folder: Option<String>,
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
pub meta_clipboard: Option<Value>,
/// 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<usize> {
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;

View File

@ -102,6 +102,8 @@ struct PresetsFile {
struct ViewFile {
source: LibrarySource,
browse: Option<crate::Browse>,
/// The folder a `libraryFolder` source shows.
library_folder: Option<String>,
// 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::<PresetsFile>(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::<PrefsFile>(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::<PresetsFile>(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::<ViewFile>(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<u8> {
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()
}

View File

@ -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 `<prefix>: 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<LevelFilter> {
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<LevelFilter> = 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<StderrLog> = 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")));
}
}

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 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];

View File

@ -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);

View File

@ -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"))]
{

View File

@ -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());
}

View File

@ -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", &params).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<String> = 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<String> = 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");
}

View File

@ -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)
}

View File

@ -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() {

View File

@ -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<PhotoId>,
}
/// 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<FilterChip> {
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<PhotoId> = (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);
}
}

7
crates/fetch/README.md Normal file
View File

@ -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.

View File

@ -60,7 +60,10 @@ pub struct Url {
impl Url {
pub fn parse(s: &str) -> Result<Url, HttpError> {
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<Res
mod tests {
use super::*;
#[test]
fn rejects_oversized_and_control_character_urls() {
assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err());
assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err());
}
#[test]
fn parses_urls() {
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();

View File

@ -33,12 +33,12 @@ use http::{HttpError, Limits, Url};
/// One file of the model.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileSpec {
pub name: &'static str,
pub struct FileSpec<'a> {
pub name: &'a str,
/// Exact size in bytes, when pinned (then the download resumes and is checked against it).
pub size: Option<u64>,
/// 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<u64, DownloadError> {
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<bool, DownloadError> {
fn verified(f: &FileSpec<'_>, path: &Path) -> Result<bool, DownloadError> {
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 `<name>.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 {

View File

@ -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<Progress>) {
fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
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());
}
}

View File

@ -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<CropHandle> {
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<CropHandle> {
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<f64>) -> 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<dyn Fn(f64, f64) -> 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<Rect> {
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<f64>) -> 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<Value = CropHandle> {
(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());
}
}
}
}

View File

@ -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};

View File

@ -155,8 +155,16 @@ pub(crate) fn with_init_marker<T>(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<String> {
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<String> {
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);
}
}

View File

@ -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)

View File

@ -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));
}
}
}

View File

@ -61,7 +61,7 @@ impl Headless {
fn export(&mut self, p: &Value) -> Result<Value, String> {
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<Value> = 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))
}

View File

@ -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));
}

View File

@ -102,6 +102,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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<Value> {
"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();
}

View File

@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, 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()))?;

View File

@ -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:?}");
}
}
}

View File

@ -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}");
}
}

View File

@ -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;

View File

@ -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:?}");
}
}
}

View File

@ -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"] }

View File

@ -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<RawImage> {
/// 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<RawInfo> {
decode_with(bytes, Mode::Header).map(RawImage::into_info)
}
@ -185,7 +185,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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"))),
}
}

View File

@ -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.

View File

@ -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<PreviewColorSpace> {
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 {

View File

@ -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<u64>) -> Option<Cfa> {
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<u64> {
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<RawImage> {
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<u8>, Vec<u16>) {
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<u8>>) -> (Vec<u8>, Vec<u16>) {
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
let widths: Vec<usize> = 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::<Vec<_>>()).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<u8> {
let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32;
let mut note = Vec::new();
note.extend_from_slice(&1u16.to_le_bytes());
note.extend_from_slice(&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:?}");
}
}
}

View File

@ -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};

View File

@ -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<RawImage> {
};
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<RawImage> {
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<u8> = (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);

View File

@ -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<Decod
"Nikon \"lossy after split\" compressed NEF (split at row {split}) is not decoded yet"
)));
}
// type 2: `n` points, one every 2^bits / (n - 1) codes
let range = 1usize << bits;
// type 2: `n` points covering `range` codes, one every `range / (n - 1)`; the curve's first interval
// rises by exactly that step (identity in the dark part) and the table version sets the span:
// `0x20` covers all 2^bits codes, `0x40` the first quarter (see the module docs)
let range = if v1 == 0x40 { 1usize << (bits - 2) } else { 1usize << bits };
if n - 1 > 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 <v1>`, 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<u8> {
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];

View File

@ -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<MakerNote> {
@ -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<Cfa> {
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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
(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<RawImage> {
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<RawImage> {
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<u8>) -> Vec<u8> {
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<u8>, cfa_pattern: Option<&[u8]>) -> Vec<u8> {
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<u8> {
(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<u8> = (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);

View File

@ -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<RawImage> {
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<RawImage> {
}
#[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(_))));
}
}

505
crates/raw/src/vendor/rafc.rs vendored Normal file
View File

@ -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<usize> {
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<Vec<u16>> {
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<Vec<u16>> = stripes.par_iter().map(|s| decode_stripe(s, height, bits, xtrans)).collect::<Result<_>>()?;
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<u32> {
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<i32> {
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<i32>], y: usize, x: usize) -> Result<i32> {
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<Vec<u16>> {
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<u8> {
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<u8> {
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<u8> = 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::<u8>(), 0..2048), quantizer in any::<u8>(), bits in prop::sample::select(vec![12,14,16]), xtrans in any::<bool>()) {
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);
}
}
}

View File

@ -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<RawImage> {
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<Opcode> {
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::<f64>();
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<Value>) -> Vec<Opcode> {
let (w, h) = (90usize, 60usize);
let px: Vec<u16> = (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<bool> = 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<u8> = 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));

View File

@ -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");
}

View File

@ -51,6 +51,14 @@ pub fn mirrors(env: Option<&str>, file: Option<&Path>) -> Vec<String> {
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!(

View File

@ -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: {}"
}

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,11 @@
" · {} failed: {}": "・{}件失敗:{}",
" · {} moved": "・{}件移動",
" · {} not readable": "・{}件は読み込み不可",
" · {} restored from Recently Deleted": "・最近削除した写真から{}枚を復元",
", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}",
"Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加",
"Add {} Photo{}": "{}枚の写真を追加{:.0}",
"Add {} Photo{}, restore {}": "{}枚の写真を追加{:.0}、{}枚を復元",
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
@ -53,6 +55,7 @@
"Rename “{name}”": "「{name}」の名前を変更",
"Rendering on the CPU: {why}": "CPUで描画:{why}",
"Reset {} Channel": "{}チャンネルをリセット",
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
"Showing {n} of {}": "{}枚中{n}枚を表示",
@ -118,5 +121,60 @@
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年"
"{year}": "{year}年",
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(うち{unsure}枚は似たファイルが複数あります。「場所を指定」を使ってください)",
" · {:.0}% clipped": "・{:.0}%が白とび/黒つぶれ",
" · {failed} couldn't be rendered": "・{failed}枚は描画できませんでした",
" · {} original{} left at the source": "・元のファイル{}枚を読み込み元に残しました{:.0}",
" — not carried over: {}{}": " — 引き継がなかった設定:{}{}",
"+{} more": "ほか{}件",
"; {kept} already had a location": "。{kept}枚はすでに位置情報あり",
"; {outside} outside its time range": "。{outside}枚は記録時間の範囲外",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:PR {}件、コミット {}件、+{} / −{} 行(Δ {})、バイナリアセット +{} / −{}、{} – {}",
"Added {what} to {name}": "{what}を{name}に追加しました",
"Auto Import: added {} photo{}": "自動読み込み:{}枚の写真を追加しました{:.0}",
"Building previews for {} photos…": "{}枚のプレビューを作成中…",
"Couldn't open the editor: {e}": "エディターを開けませんでした:{e}",
"Creates {} stacks from {} of {scope}": "{scope}の{1}枚から{0}個のスタックを作成します",
"Done ({what}): {done} photo{plural}": "完了({what}):{done}枚{plural:.0}",
"Focus {:.0}": "ピント {:.0}",
"Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)",
"Found {n} missing photo{}; {left} still missing{unsure}": "見つからない写真を{n}枚見つけました{:.0}。{left}枚はまだ見つかりません{unsure}",
"Import failed: {e}": "読み込みに失敗しました:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles}個のプロファイルを読み込みました{:.0}(プロファイルブラウザー ▸ 各グループ)",
"Insert at the cursor (also written {})": "カーソル位置に挿入({}とも表記可)",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。フォント:{}(OFL)。アイコン:オリジナル",
"Painting “{k}”: click photos to add or remove it · Esc stops": "「{k}」を適用中:写真をクリックして付け外し・Escで終了",
"Previews ready for {done} photo{plural}": "{done}枚のプレビューが完成しました{plural:.0}",
"Removed {what} from {name}": "{what}を{name}から除去しました",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "{n}枚の写真の「{from}」の名前を変更します{:.0}(下位のキーワードも含む)。階層は | で区切ります(例:旅行|イタリア)。既存の名前にすると2つを統合します",
"Replace {} with:": "{}を次に置換:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "{scope}のピントと露出を評価し、数秒以内に撮影した似た写真(連写)を検出します",
"Tagged {n} photo{} from the tracklog": "GPS軌跡から{n}枚の写真に位置情報を付けました{:.0}",
"The SAM 3 download failed: {e}": "SAM 3のダウンロードに失敗しました:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "不明なタグ{s:.0} {} は入力したまま残ります{:.0}(「タグ」を参照)",
"Virtual copy “{name}” of {of}": "{of}の仮想コピー「{name}」",
"Working on {what} for {} photos…": "{}枚の写真の{what}を処理中…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "{name}を編集用に開きました。元の写真とスタックにしてあります",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n}件の変更{0:.0}をディスクに書き込めませんでした:{e}\n今終了すると、これらの変更は失われます{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n}件の変更{:.0}はメモリに保存しましたが、ディスクには書き込めていません:{e} — LightCraftが再試行します",
"{pid} (missing)": "{pid}(見つかりません)",
"{{date:…}} directives: {}": "{{date:…}} の指定子:{}",
"{} contributors · {} commits": "コントリビューター {}人・コミット {}件",
"{} failed": "{}に失敗しました",
"{} item{}": "{}項目{:.0}",
"{} photos · exposures {} EV": "{}枚・露出 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{}枚・{}・{:.0}° × {:.0}°",
"{} sample{}": "サンプル{}個{:.0}",
"{} sec": "{}秒",
"{} — show their photos": "{} — この人物の写真を表示",
"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はこのRAWの形式({})をまだデコードできません。カメラの埋め込みJPEGプレビューを編集しています。このプレビューには、カメラのピクチャースタイル(モノクロなど)とホワイトバランスがすでに反映されています",
"Filtered: {label}": "フィルター適用中:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "似た写真{n}枚{:.0}・すべて表示するには「表示 ▸ フィルターを解除」",
"Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました",
"{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)",
", {} star{}": "、星{}つ{:.0}",
", {} label": "、{}ラベル"
}

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,11 @@
" · {} failed: {}": " · {} falhou: {}",
" · {} moved": " · {} movida(s)",
" · {} not readable": " · {} não legível",
" · {} restored from Recently Deleted": " · {} restaurada(s) de Excluídos recentemente",
", {failed} file{} not readable": ", {failed} arquivo{:.0} não legível",
"Add {local_n} to My Photos": "Adicionar {local_n} a Minhas fotos",
"Add {} Photo{}": "Adicionar {} foto{:.0}",
"Add {} Photo{}, restore {}": "Adicionar {} foto{:.0}, restaurar {}",
"Add “{}” to Favorites": "Adicionar “{}” aos favoritos",
"Added {n} photo{} to My Photos": "Adicionada {n} foto{:.0} a Minhas fotos",
"Added {n} photo{} to “{}”": "Adicionada {n} foto{:.0} a “{}”",
@ -61,12 +63,14 @@
"Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}",
"Reading photos… {} of {}": "Lendo fotos… {} de {}",
"Redo {}": "Refazer {}",
"Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?",
"Remove “{}” from Favorites": "Remover “{}” dos favoritos",
"Rename {n} Photos…": "Renomear {n} fotos…",
"Rename “{name}”": "Renomear “{name}”",
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
"Reset {name}": "Redefinir {name}",
"Reset {} Channel": "Redefinir canal {}",
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
"Showing {n} of {}": "Mostrando {n} de {}",
@ -118,5 +122,59 @@
"{} spot(s) on this photo": "{} ponto(s) nesta foto",
"· {dups} already in the library": "· {dups} já na biblioteca",
"· {n} photo{}": "· {n} foto{:.0}",
"… and {more} more": "… e mais {more}"
"… and {more} more": "… e mais {more}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} com vários arquivos parecidos: use Localizar)",
" · {:.0}% clipped": " · {:.0}% recortado",
" · {failed} couldn't be rendered": " · {failed} não puderam ser renderizadas",
" · {} original{} left at the source": " · originais mantidos na origem: {}{:.0}",
" — not carried over: {}{}": " — não transferido: {}{}",
"+{} more": "+{} a mais",
"; {kept} already had a location": "; {kept} já tinham localização",
"; {outside} outside its time range": "; {outside} fora do intervalo de tempo",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} linhas (Δ {}), +{} / −{} arquivos binários, {} – {}",
"Added {what} to {name}": "{what} adicionado a {name}",
"Auto Import: added {} photo{}": "Importação automática: {} foto{1} adicionada{1}",
"Building previews for {} photos…": "Gerando prévias de {} fotos…",
"Couldn't open the editor: {e}": "Não foi possível abrir o editor: {e}",
"Creates {} stacks from {} of {scope}": "Cria {} pilhas a partir de {} de {scope}",
"Done ({what}): {done} photo{plural}": "Concluído ({what}): {done} foto{plural}",
"Focus {:.0}": "Foco {:.0}",
"Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)",
"Found {n} missing photo{}; {left} still missing{unsure}": "{n} foto{0} ausente{0} encontrada{0}; {left} ainda ausente(s){unsure}",
"Import failed: {e}": "Falha na importação: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} perfil(is) importado(s){:.0} (Navegador de perfis ▸ seus grupos)",
"Insert at the cursor (also written {})": "Inserir no cursor (também escrito {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontes: {} (OFL). Ícones: originais.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Aplicando “{k}”: clique nas fotos para adicionar ou remover · Esc para",
"Previews ready for {done} photo{plural}": "Prévias prontas para {done} foto{plural}",
"Removed {what} from {name}": "{what} removido de {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renomeia “{from}” em {n} foto{} (incluindo as palavras-chave abaixo dela). Use | para níveis, p. ex. Viagem|Itália. Um nome já existente mescla os dois.",
"Replace {} with:": "Substituir {} por:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Avalia o foco e a exposição de {scope} e encontra fotos semelhantes tiradas com segundos de diferença (sequências).",
"Tagged {n} photo{} from the tracklog": "{n} foto{0} marcada{0} pelo tracklog",
"The SAM 3 download failed: {e}": "Falha no download do SAM 3: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Marcador(es) desconhecido(s){s:.0} {} permanece(m) como digitado(s){:.0} (veja Marcadores)",
"Virtual copy “{name}” of {of}": "Cópia virtual “{name}” de {of}",
"Working on {what} for {} photos…": "Processando {what} em {} fotos…",
"{label} {index} of {n}": "{label} {index} de {n}",
"{name} opened for editing; it is stacked with the original": "{name} aberta para edição; está empilhada com o original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} alteração(ões){0:.0} não puderam ser gravadas no disco: {e}\nSe você sair agora, elas serão perdidas.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} alteração(ões){:.0} salva(s) na memória, mas não gravada(s) no disco: {e} — o LightCraft tentará de novo",
"{pid} (missing)": "{pid} (ausente)",
"{{date:…}} directives: {}": "Diretivas de {{date:…}}: {}",
"{} contributors · {} commits": "{} colaboradores · {} commits",
"{} failed": "Falha: {}",
"{} item{}": "{} item(ns){:.0}",
"{} photos · exposures {} EV": "{} fotos · exposições {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} amostra{}",
"{} sec": "{} s",
"{} — show their photos": "{} — mostrar as fotos desta pessoa",
"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.": "O LightCraft ainda não consegue decodificar esta variante RAW ({}). Você está editando a prévia JPEG incorporada da câmera, que já inclui o estilo de imagem da câmera (p. ex. Monocromático) e o balanço de branco.",
"Filtered: {label}": "Filtrado: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto{0} semelhante{0} · Exibir ▸ Limpar filtros para ver todas",
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) novamente",
"{} label (click more labels to show any of them)": "Rótulo {} (clique em mais rótulos para mostrar qualquer um deles)",
", {} star{}": ", {} estrela{}",
", {} label": ", rótulo {}"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
{
" ({unsure} with several look-alike files: use Locate)": " (из них с несколькими похожими файлами: {unsure} — используйте «Найти фото»)",
" ({} in Recently Deleted)": " ({} в «Недавно удалённых»)",
" · {:.0}% clipped": " · обрезано: {:.0}%",
" · {failed} couldn't be rendered": " · не удалось отрисовать: {failed}",
" · {skipped} skipped (file exists)": " · пропущено: {skipped} (файл существует)",
" · {} duplicate{} skipped": " · пропущено дубликатов: {}{:.0}",
" · {} failed: {}": " · не удалось: {}; ошибка: {}",
" · {} moved": " · перемещено: {}",
" · {} not readable": " · не читается: {}",
" · {} original{} left at the source": " · оригиналов оставлено в источнике: {}{:.0}",
" · {} restored from Recently Deleted": " · восстановлено из «Недавно удалённых»: {}",
" — not carried over: {}{}": " — не перенесено: {}{}",
"+{} more": "+{} ещё",
", {failed} file{} not readable": ", не читается файлов: {failed}{:.0}",
", {} label": ", метка: {}",
", {} star{}": ", звёзд: {}{:.0}",
"; {kept} already had a location": "; уже имели местоположение: {kept}",
"; {outside} outside its time range": "; вне временного диапазона трека: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, коммитов: {}, строк: +{} / −{} (Δ {}), бинарных файлов: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Добавить в «Мои фото»: {local_n}",
"Add {} Photo{}": "Добавить фото: {}{:.0}",
"Add {} Photo{}, restore {}": "Добавить фото: {}{:.0}, восстановить: {}",
"Add “{}” to Favorites": "Добавить «{}» в избранное",
"Added {n} photo{} to My Photos": "В «Мои фото» добавлено фото: {n}{:.0}",
"Added {n} photo{} to “{}”": "В «{1}» добавлено фото: {n}{0:.0}",
"Added {what} to {name}": "{what}: добавлено в {name}",
"Adding photos… {} of {}": "Добавление фото… {} из {}",
"All {n} selected photos move by the same amount.": "Все выбранные фото ({n}) сдвигаются на одно и то же значение.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в библиотеке, в «Недавно удалённых»: выбрано фото: {}{:.0} · щёлкните правой кнопкой → Восстановить или Удалить навсегда, чтобы импортировать заново",
"Already in the library: {} photo{} selected in All Photos": "Уже в библиотеке: выбрано фото в разделе «Все фото»: {}{:.0}",
"Auto ({})": "Авто ({})",
"Auto Import: added {} photo{}": "Автоимпорт: добавлено фото: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Автосинхронизация: правки применяются к фото: {n}",
"Automatic ({} MB)": "Автоматически ({} МБ)",
"Build 1:1 Previews ({scope})": "Создать просмотры 1:1 ({scope})",
"Build Smart Previews ({scope})": "Создать смарт-просмотры ({scope})",
"Build Standard-Sized Previews ({scope})": "Создать просмотры стандартного размера ({scope})",
"Building previews for {} photos…": "Создание просмотров для фото: {}…",
"Click {}:1": "Щелчок {}:1",
"Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}",
"Create {n} Virtual Copies": "Создать виртуальные копии: {n}",
"Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})",
"Decoder: {reason}": "Декодер: {reason}",
"Delete \"{label}\"": "Удалить «{label}»",
"Delete \"{label}\" (and the mask)": "Удалить «{label}» (и маску)",
"Delete {n} Photos": "Удалить фото: {n}",
"Delete “{current}”": "Удалить «{current}»",
"Discard Smart Previews ({scope})": "Удалить смарт-просмотры ({scope})",
"Done ({what}): {done} photo{plural}": "Готово ({what}): фото: {done}{plural:.0}",
"Duplicate \"{label}\"": "Дублировать «{label}»",
"Example: {example}": "Пример: {example}",
"Exported {n} photo{}": "Экспортировано фото: {n}{:.0}",
"Exported {ok} of {total} photo{}": "Экспортировано фото: {ok} из {total}{:.0}",
"Exported {} point curve preset{}": "Экспортировано пресетов точечной кривой: {}{:.0}",
"Exported {} preset{}": "Экспортировано пресетов: {}{:.0}",
"Exporting {} of {total}": "Экспорт: {} из {total}",
"Filter bar — {active} active filter{}": "Панель фильтров — активных фильтров: {active}{:.0}",
"Filtered: {label}": "Отфильтровано: {label}",
"Focus {:.0}": "Резкость {:.0}",
"Folder “{}” (and its subfolders)": "Папка «{}» (и вложенные папки)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Найдено отсутствующих фото: {n}{:.0}; всё ещё отсутствует: {left}{unsure}",
"Healed {n} dust spot{}": "Убрано пылинок: {n}{:.0}",
"Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}",
"Import failed: {e}": "Ошибка импорта: {e}",
"Imported {n} point curve preset{}": "Импортировано пресетов точечной кривой: {n}{:.0}",
"Imported {n} preset{}": "Импортировано пресетов: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Импортировано пресетов: {n}{:.0}, не читается файлов: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Импортировано профилей: {profiles}{:.0} (Обозреватель профилей → их группы)",
"Imported {} photo{}": "Импортировано фото: {}{:.0}",
"Insert at the cursor (also written {})": "Вставить в позицию курсора (также пишется как {})",
"Label Set: {name}": "Набор меток: {name}",
"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 пока не умеет декодировать этот вариант RAW ({}). Вы редактируете встроенный в файл JPEG-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
"Matches: {}": "Совпадения: {}",
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "Переместить {what} в «Недавно удалённые»?",
"Moved; {} photo(s) relinked": "Перемещено; перепривязано фото: {}",
"Named ({named_n})": "Именованные ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Назначение «{k}»: щёлкайте по фото, чтобы добавить или снять его · Esc — завершить",
"Paste into {n} photo{} — only settings that were copied are pasted": "Вставка в фото: {n}{:.0} — вставляются только скопированные настройки",
"Preset: {name}": "Пресет: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Только просмотр — правка встроенного в камеру JPEG ({})",
"Preview only — {}": "Только просмотр — {}",
"Previews ready for {done} photo{plural}": "Просмотры готовы, фото: {done}{plural:.0}",
"Profile: {}": "Профиль: {}",
"Proof Copy ({})": "Пробная копия ({})",
"Proof Preview · {}": "Пробный просмотр · {}",
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
"Quick Develop: {name}": "Быстрая коррекция: {name}",
"Rated {}": "Оценка: {}",
"Reading photos… {done} of {total}": "Чтение фото… {done} из {total}",
"Reading photos… {} of {}": "Чтение фото… {} из {}",
"Redo {}": "Повторить {}",
"Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?",
"Remove “{}” from Favorites": "Убрать «{}» из избранного",
"Removed {what} from {name}": "{what}: удалено из {name}",
"Rename {n} Photos…": "Переименовать фото: {n}…",
"Rename “{name}”": "Переименовать «{name}»",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Переименовывает «{from}» на фото ({n}){:.0} (включая вложенные ключевые слова). Уровни разделяются символом |, например Путешествия|Италия. Если такое имя уже есть, слова объединяются.",
"Rendering on the CPU: {why}": "Рендеринг на CPU: {why}",
"Replace {} with:": "Заменить {} на:",
"Reset {name}": "Сбросить: {name}",
"Reset {} Channel": "Сбросить канал: {}",
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
"Saved export preset “{}”": "Пресет экспорта «{}» сохранён",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
"Show {hidden} hidden location{}": "Показать скрытые места: {hidden}{:.0}",
"Showing {n} of {}": "Показано {n} из {}",
"Smart album: {}": "Смарт-альбом: {}",
"Tagged {n} photo{} from the tracklog": "Привязано по треку фото: {n}{:.0}",
"The SAM 3 download failed: {e}": "Не удалось загрузить SAM 3: {e}",
"Undo {}": "Отменить {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Неизвестные теги{s:.0} {} остаются как введены{:.0} (см. «Теги»)",
"Updated “{name}”": "«{name}» обновлено",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Версия {} — независимо разработанная библиотека фото и RAW-конвертер на чистом Rust.",
"Version: {}": "Версия: {}",
"Virtual copy “{name}” of {of}": "Виртуальная копия «{name}» от {of}",
"Working on {what} for {} photos…": "{what}: обработка фото ({})…",
"none (CPU rendering): {why}": "нет (рендеринг на CPU): {why}",
"the {n} selected photos": "выбранные {n} фото",
"the {} photos in view": "все {} фото в представлении",
"these {count} photos": "эти {count} фото",
"{count} smart preview{} · {:.1} MB": "Смарт-просмотров: {count}{:.0} · {:.1} МБ",
"{f} folders (and their subfolders)": "Папок: {f} (и их подпапок)",
"{label} Grading": "Цветокоррекция: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} для каждого выбранного фото",
"{label} {index} of {n}": "{label} {index} из {n}",
"{label}, {n} photos": "{label}, фото: {n}",
"{matching} of {t} photos": "Фото: {matching} из {t}",
"{matching} photos": "Фото: {matching}",
"{mode} spot {} of {}": "{mode}: пятно {} из {}",
"{month}": "{month}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (ошибок: {failed})",
"{name} Label": "Метка: {name}",
"{name} Off": "{name}: выкл.",
"{name} On": "{name}: вкл.",
"{name} opened for editing; it is stacked with the original": "{name} открыт для редактирования и объединён в стопку с оригиналом",
"{name} — double-click to reset it": "{name} — двойной щелчок для сброса",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Изменений не удалось записать на диск: {n}{0:.0}: {e}\nЕсли выйти сейчас, они будут потеряны.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Не записано на диск, хранится только в памяти (изменений: {n}): {e}\nLightCraft повторяет попытку автоматически; при выходе сейчас они будут потеряны.{:.0}{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Изменений сохранено в памяти, но не записано на диск: {n}{:.0}: {e} — LightCraft повторит попытку",
"{n} found · {sel} selected": "Найдено: {n} · выбрано: {sel}",
"{n} photos": "Фото: {n}",
"{n} photos in {} are hidden by the filters above": "Фильтры выше скрывают фото в разделе «{}»: {n}",
"{n} photo{}": "Фото: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Подходящих фото: {n}{:.0} · обновляется автоматически при изменении фото",
"{n} photo{} now · updates automatically as photos change": "Сейчас фото: {n}{:.0} · обновляется автоматически при изменении фото",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все",
"{pid} (missing)": "{pid} (отсутствует)",
"{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (фото: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}",
"{what} merge added": "{what}: слияние добавлено",
"{what} merge failed: {e}": "{what}: ошибка слияния: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Директивы {{date:…}}: {}",
"{} and higher": "{} и выше",
"{} contributors · {} commits": "Участников: {} · коммитов: {}",
"{} failed": "Ошибка: {}",
"{} files": "Файлов: {}",
"{} files and {f} folder{}": "Файлов: {}, папок: {f}{:.0}",
"{} item{}": "Элементов: {}{:.0}",
"{} label (click more labels to show any of them)": "Метка «{}» (щёлкните другие метки, чтобы показать фото с любой из них)",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "Направляющих: {} из 4 — проведите вдоль линий, которые должны быть вертикальными или горизонтальными.",
"{} photos": "Фото: {}",
"{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Не читается фото: {}{:.0}",
"{} sample{}": "Образцов: {}{:.0}",
"{} sec": "{} с",
"{} spot(s) on this photo": "Пятен на этом фото: {}",
"{} — show their photos": "{} — показать фото этого человека",
"· {dups} already in the library": "· уже в библиотеке: {dups}",
"· {n} photo{}": "· фото: {n}{:.0}",
"… and {more} more": "… и ещё {more}"
}

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,11 @@
" · {} failed: {}": " · {0} 个失败:{1}",
" · {} moved": " · 已移动 {0} 个",
" · {} not readable": " · {0} 个无法读取",
" · {} restored from Recently Deleted": " · 已从“最近删除”恢复 {} 张",
", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}",
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
"Add {} Photo{}": "添加 {0} 张照片{1:.0}",
"Add {} Photo{}, restore {}": "添加 {0} 张照片{1:.0},恢复 {2} 张",
"Add “{}” to Favorites": "将“{0}”添加到收藏夹",
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
@ -53,6 +55,7 @@
"Rename “{name}”": "重命名“{name}”",
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
"Reset {} Channel": "重置 {0} 通道",
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
"Saved export preset “{}”": "已保存导出预设“{0}”",
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
@ -118,5 +121,60 @@
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(其中 {unsure} 张有多个相似文件:请使用“定位”)",
" · {:.0}% clipped": " · {0:.0}% 剪切",
" · {failed} couldn't be rendered": " · {failed} 张无法渲染",
" · {} original{} left at the source": " · {0} 个原始文件留在了来源位置{1:.0}",
" — not carried over: {}{}": " — 未沿用:{0}{1}",
"+{} more": "+{0} 个",
"; {kept} already had a location": ";{kept} 张已有位置信息",
"; {outside} outside its time range": ";{outside} 张不在其时间范围内",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 个 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 个二进制资源,{} – {}",
"Added {what} to {name}": "已将{what}添加到 {name}",
"Auto Import: added {} photo{}": "自动导入:已添加 {0} 张照片{1:.0}",
"Building previews for {} photos…": "正在为 {0} 张照片生成预览…",
"Couldn't open the editor: {e}": "无法打开编辑器:{e}",
"Creates {} stacks from {} of {scope}": "将从{scope}中的 {1} 张照片创建 {0} 个堆叠",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 张照片{plural:.0}",
"Focus {:.0}": "对焦 {0:.0}",
"Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 张缺失的照片{0:.0};仍有 {left} 张缺失{unsure}",
"Import failed: {e}": "导入失败:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已导入 {profiles} 个配置文件{0:.0}(配置文件浏览器 ▸ 各自的分组)",
"Insert at the cursor (also written {})": "在光标处插入(也可写作 {0})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字体:{0}(OFL)。图标:原创。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在应用“{k}”:单击照片以添加或移除 · 按 Esc 停止",
"Previews ready for {done} photo{plural}": "已为 {done} 张照片生成预览{plural:.0}",
"Removed {what} from {name}": "已从 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 张照片上重命名“{from}”{0:.0}(其下级关键字也一并处理)。用 | 分隔层级,例如 Travel|Italy。改为已有名称时,两者将合并。",
"Replace {} with:": "将 {0} 替换为:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "为{scope}评估对焦和曝光,并找出相隔几秒内拍摄的相似照片(连拍)。",
"Tagged {n} photo{} from the tracklog": "已根据轨迹日志标记 {n} 张照片{0:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下载失败:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知标签{s:.0} {0} 将按原样保留{1:.0}(参见“标签”)",
"Virtual copy “{name}” of {of}": "{of} 的虚拟副本“{name}”",
"Working on {what} for {} photos…": "正在为 {0} 张照片处理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已打开 {name} 进行编辑;它已与原始照片堆叠在一起",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} 项更改{0:.0}无法写入磁盘:{e}\n如果现在退出,这些更改将会丢失。{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} 项更改{0:.0}已保存在内存中,但未写入磁盘:{e} — LightCraft 会重试",
"{pid} (missing)": "{pid}(缺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式说明:{0}",
"{} contributors · {} commits": "{0} 位贡献者 · {1} 次提交",
"{} failed": "{0}失败",
"{} item{}": "{0} 项{1:.0}",
"{} photos · exposures {} EV": "{0} 张照片 · 曝光 {1} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{0} 张照片 · {1} · {2:.0}° × {3:.0}°",
"{} sample{}": "{0} 个样本{1:.0}",
"{} sec": "{0} 秒",
"{} — show their photos": "{0} — 显示其照片",
"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 暂时无法解码这种 RAW 变体({0})。你正在编辑相机内嵌的 JPEG 预览,其中已包含相机的照片风格(如单色)和白平衡。",
"Filtered: {label}": "已筛选:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 张相似照片{0:.0} · 选择“视图 ▸ 清除过滤器”可查看全部",
"Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片",
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
", {} label": ",{0}标签"
}

View File

@ -1,14 +1,31 @@
{
" (library default)": " (图库默认值)",
" (inverted)": " (已反相)",
" · best of its burst": " · 连拍中的最佳照片",
" · in a burst": " · 属于一组连拍",
"% of all commits": "占全部提交的百分比",
"+ Group": "+ 组",
"+ Rule": "+ 条件",
", picked": ",留用",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等于",
"A blank template keeps the original names.": "模板留空则保留原始名称。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)",
"A one-time download of about": "一次性下载,大小约",
"AI Mask Model Status": "AI 蒙版模型状态",
"About": "关于",
"About LightCraft": "关于 LightCraft",
"Active": "当前照片",
"Activity": "活动",
"Actual size": "实际大小",
"Add": "添加",
"Add Catchlight": "添加眼神光",
"Add Folder to Local": "将文件夹添加到本地",
"Add Mask": "添加蒙版",
"Add Object Click": "添加对象点击",
"Add Point Color Sample": "添加点颜色样本",
"Add Red Eye Correction": "添加红眼校正",
"Add Remove Spot": "添加污点去除",
@ -21,9 +38,11 @@
"Add to": "添加到",
"Add to Album": "添加到相册",
"Add to Favorites": "添加到收藏夹",
"Add to Local": "添加到本地",
"Add to My Photos": "添加到我的照片",
"Add to Selected Photos": "添加到所选照片",
"Add to Target Album": "添加到目标相册",
"Add to this mask": "添加到此蒙版",
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
"Adding": "正在添加",
@ -44,8 +63,14 @@
"Alt Text": "替代文本",
"Always": "始终",
"Amount": "数量",
"Analyzing the photo for AI masks…": "正在分析照片以生成 AI 蒙版…",
"Any Searchable Text": "任意可搜索文本",
"Any camera": "任意相机",
"Any keyword": "任意关键字",
"Any lens": "任意镜头",
"Aperture": "光圈",
"Application": "应用程序",
"Apply": "应用",
"Apply Color Label Set": "应用颜色标签集",
"Apply Filter Preset": "应用筛选器预设",
"Apply Metadata Preset": "应用元数据预设",
@ -79,9 +104,11 @@
"Auto Import Now": "立即自动导入",
"Auto Import Settings": "自动导入设置",
"Auto Mask": "自动蒙版",
"Auto Select": "自动选择",
"Auto Settings": "自动设置",
"Auto Straighten": "自动拉直",
"Auto Sync": "自动同步",
"Auto settings applied": "已应用自动设置",
"Auto-Stack by Capture Time": "按拍摄时间自动堆叠",
"Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…",
"Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片",
@ -109,6 +136,9 @@
"Before/After Split Top/Bottom": "修改前/修改后上下分屏",
"Before/After Top/Bottom": "修改前/修改后上下排列",
"Begin Interaction": "开始交互",
"Best of Similar Shots": "相似照片中的最佳",
"Binary assets added": "新增的二进制资源",
"Binary assets removed": "删除的二进制资源",
"Bit depth": "位深度",
"Black & White": "黑白",
"Blacks": "黑色色阶",
@ -139,19 +169,32 @@
"By Date": "按日期",
"By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)",
"CPU threads": "CPU 线程数",
"Calibration": "校准",
"Camera": "相机",
"Camera make and model": "相机品牌和型号",
"Cameras and Cards": "相机和存储卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下载 AI 蒙版模型",
"Cancel Download": "取消下载",
"Cancel Interaction": "取消交互",
"Cancel Preview Build": "取消生成预览",
"Candidate": "候选",
"Caption": "题注",
"Capture Date": "拍摄日期",
"Capture date and time in your own format (directives below)": "自定义格式的拍摄日期和时间(格式说明见下方)",
"Capture date as YYYYMMDD": "拍摄日期(YYYYMMDD)",
"Captured": "拍摄时间",
"Captured from": "拍摄时间起于",
"Captured until": "拍摄时间止于",
"Catalog Statistics": "目录统计",
"Catchlight": "眼神光",
"Change": "修改方式",
"Change Component Mode": "更改组件模式",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。",
"Check All": "全部选中",
"Check Output Path": "检查输出路径",
"Checking names…": "正在检查名称…",
"Choose Another Library…": "选择其他图库…",
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
"Choose Edit Settings to Copy…": "选择要复制的编辑设置…",
@ -176,8 +219,11 @@
"Click Zoom Ratio": "单击缩放比例",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。",
"Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束",
"Click the object to select it · ⌥-click leaves a part out": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分",
"Click the photo to pick a colour · ⇧-click adds more": "单击照片选择颜色 · 按 ⇧ 单击可添加更多",
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "单击照片选择颜色;按 ⇧ 单击可添加更多(最多 5 个)",
"Click to clear these filters": "单击以清除这些筛选条件",
"Click to restore · double-click to rename": "单击以恢复 · 双击以重命名",
"Clone": "仿制",
"Close": "关闭",
"Close Panel": "关闭面板",
@ -186,7 +232,10 @@
"Close file": "关闭文件",
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Collapse Stack": "折叠堆叠",
"Color": "颜色",
"Color Label": "颜色标签",
"Color label cleared": "已清除颜色标签",
"Color Grading": "颜色分级",
"Color Label Names": "颜色标签名称",
"Color Label Sets": "颜色标签集",
@ -196,9 +245,12 @@
"Color Range": "颜色范围",
"Color space": "色彩空间",
"Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)",
"Colors": "颜色",
"Combine files…": "合并文件…",
"Command Journal": "命令日志",
"Comments panel": "注释面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比较",
"Compare (Shift+C)": "比较(Shift+C)",
"Compare Before and After": "比较修改前和修改后",
@ -209,13 +261,16 @@
"Confirm before deleting": "删除前确认",
"Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认",
"Constrain Crop": "限制裁剪",
"Continue": "继续",
"Continue Without Saving": "不保存继续",
"Contrast": "对比度",
"Contributors": "贡献者",
"Convert to DNG": "转换为 DNG",
"Cool Fade": "冷调褪色",
"Cool Morning": "清冷晨光",
"Copy": "复制",
"Copy After's Settings to Before": "将修改后的设置复制到修改前",
"Copy Before to After": "将修改前复制到修改后",
"Copy Before's Settings to After": "将修改前的设置复制到修改后",
"Copy Edit Settings": "复制编辑设置",
"Copy History Step Settings to Before": "将历史记录步骤的设置复制到修改前",
@ -250,16 +305,19 @@
"Create Version from Step": "从该步骤创建版本",
"Create Virtual Copy": "创建虚拟副本",
"Creator": "创建者",
"Creator metadata": "元数据中的创建者",
"Crisp Landscape": "清晰风光",
"Crisp Vista": "通透远景",
"Crop": "裁剪",
"Crop & Rotate": "裁剪和旋转",
"Crop & Rotate (C)": "裁剪和旋转(C)",
"Crop Aspect": "裁切长宽比",
"Crop, Rotate, Geometry": "裁剪、旋转、几何",
"Cross Process": "交叉冲洗",
"Culling": "挑片",
"Curve": "曲线",
"Custom": "自定义",
"Custom template…": "自定义模板…",
"Custom…": "自定义…",
"Cut": "剪切",
"Cut pages": "剪切页面",
@ -272,6 +330,7 @@
"Dark & Moody": "暗调氛围",
"Darken": "压暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期分组",
"Day": "日",
"Daylight": "日光",
@ -286,6 +345,7 @@
"Delete Album": "删除相册",
"Delete All Masks": "删除所有蒙版",
"Delete Color Label Set": "删除颜色标签集",
"Delete Component": "删除组件",
"Delete Export Preset": "删除导出预设",
"Delete Filter Preset": "删除筛选器预设",
"Delete Imported Profile": "删除导入的配置文件",
@ -296,9 +356,11 @@
"Delete Permanently": "永久删除",
"Delete Photo": "删除照片",
"Delete Photos": "删除照片",
"Delete Point Color": "删除点颜色",
"Delete Point Color Sample": "删除点颜色样本",
"Delete Point Curve Preset": "删除点曲线预设",
"Delete Preset": "删除预设",
"Delete Red Eye": "删除红眼校正",
"Delete Red Eye Correction": "删除红眼校正",
"Delete Spot": "删除污点",
"Delete Version": "删除版本",
@ -309,27 +371,41 @@
"Density": "密度",
"Depth Range": "深度范围",
"Descending": "降序",
"Describe": "描述",
"Describe what to keep": "描述要保留的内容",
"Describe what to leave out": "描述要排除的内容",
"Describe what to select": "描述要选择的内容",
"Describe…": "描述…",
"Deselect": "取消选择",
"Deselect All": "取消全选",
"Desert Heat": "沙漠热浪",
"Desert Warmth": "沙漠暖意",
"Desktop": "桌面",
"Detail": "细节",
"Detail (D)": "细节 (D)",
"Details": "详细信息",
"Diagonal": "对角线",
"Discard Preview Cache": "丢弃预览缓存",
"Display P3": "Display P3",
"Display name": "显示名称",
"Display theme": "显示主题",
"Distortion": "扭曲",
"Document properties…": "文档属性…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下载",
"Download AI Mask Model": "下载 AI 蒙版模型",
"Download the SAM 3 Model?": "要下载 SAM 3 模型吗?",
"Downloads": "下载",
"Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。",
"Draw Guides": "绘制参考线",
"Duplicate": "创建副本",
"Duplicate Component": "复制组件",
"Duplicate Mask": "复制蒙版",
"Duplicate and Invert Mask": "复制并反转蒙版",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。",
"Edge": "边缘",
"Edit": "编辑",
"Edit (E)": "编辑 (E)",
"Edit Capture Time": "编辑拍摄时间",
@ -337,26 +413,36 @@
"Edit Color Label Names": "编辑颜色标签名称",
"Edit Color Label Names…": "编辑颜色标签名称…",
"Edit Copy for External Editor": "编辑外部编辑器副本",
"Edit Date": "编辑日期",
"Edit Info": "编辑信息",
"Edit Label Names": "编辑标签名称",
"Edit Label Names…": "编辑标签名称…",
"Edit Mask": "编辑蒙版",
"Edit Mask Component": "编辑蒙版组件",
"Edit Sections": "编辑板块",
"Edit Smart Album": "编辑智能相册",
"Edit Smart Album…": "编辑智能相册…",
"Edit Spot": "编辑污点",
"Edit in External Editor": "在外部编辑器中编辑",
"Edit settings copied": "已复制编辑设置",
"Edited": "已编辑",
"Edited or not": "不限是否编辑",
"Editing": "编辑",
"Effect": "效果",
"Effects": "效果",
"Empty Recently Deleted": "清空“最近删除”",
"Enable Lens Corrections": "启用镜头校正",
"Enable Profile Corrections": "启用配置文件校正",
"End Interaction": "结束交互",
"English": "English",
"Enter Full Screen": "进入全屏",
"Enter a date as YYYY-MM-DD HH:MM:SS": "请按 YYYY-MM-DD HH:MM:SS 格式输入日期",
"Erase": "擦除",
"Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段",
"Existing previews": "现有预览",
"Exit": "退出",
"Expand All Stacks": "展开所有堆叠",
"Expand Stack": "展开堆叠",
"Expand/Collapse Stack": "展开/折叠堆叠",
"Export": "导出",
"Export Album with Preset": "使用预设导出相册",
@ -369,6 +455,7 @@
"Export Presets": "导出预设",
"Export Presets…": "导出预设…",
"Export User Presets…": "导出用户预设…",
"Export stopped unexpectedly": "导出意外停止",
"Export with Preset": "使用预设导出",
"Export with Previous": "使用上次设置导出",
"Exporting": "正在导出",
@ -377,6 +464,9 @@
"Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)",
"Extended Description": "扩展描述",
"External Editor": "外部编辑器",
"Face Boxes": "人脸框",
"Face boxes": "人脸框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他应用写入 XMP 的人脸名称会显示在这里。",
"Faded Matte": "褪色哑光",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色都市",
@ -386,10 +476,128 @@
"Feather": "羽化",
"File": "文件",
"File & date": "文件 & 日期",
"File Format": "文件格式",
"File Name": "文件名",
"File Path": "文件路径",
"File Size": "文件大小",
"File Type": "文件类型",
"Filename": "文件名",
"Fill": "填满",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五星",
"Flagged as Pick": "已标记为留用",
"Flagged as Reject": "已标记为排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "对焦(辅助筛选)",
"Frame time": "帧耗时",
"Frame update": "帧更新",
"GPU rendering": "GPU 渲染",
"Generated demo photo": "生成的演示照片",
"Grab bag": "名单",
"Group name": "分组名称",
"HDR Merge Preview": "HDR 合并预览",
"HDR Panorama Merge Preview": "HDR 全景合并预览",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有编辑",
"Has GPS": "有 GPS",
"Home": "主文件夹",
"Hours": "小时",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "作为新照片导入",
"Imported": "已导入",
"Info Overlay Off": "关闭信息叠加",
"Info Overlay: Exposure": "信息叠加:曝光",
"Info Overlay: File": "信息叠加:文件",
"Insert at the cursor": "在光标处插入",
"Invert Component": "反相组件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
"Label": "标签",
"Label Set": "标签集",
"Last GPU fallback": "最近一次 GPU 回退",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大视图渲染",
"Lens Blur": "镜头模糊",
"Library saved": "图库已保存",
"Licence:": "许可证:",
"Line delta": "行数差",
"Linear": "线性",
"Lines +/−": "行数 +/−",
"Lines added": "新增行数",
"Lines deleted": "删除行数",
"Lock": "锁定",
"Lock Crop Aspect": "锁定裁剪长宽比",
"Locked": "已锁定",
"Mask Adjustment": "蒙版调整",
"Memory budget": "内存预算",
"Merged": "已合并",
"Merged PRs": "已合并的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "适用的是 Meta 的条款,而非 LightCraft 的条款。下载即表示接受这些条款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的元数据({camera}、{title}…)会留空;如果生成的名称为空,则保留原始名称。",
"Model": "型号",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "将预设移动到新分组",
"Move Up": "上移",
"Move in Stack": "在堆叠中移动",
"Name (A–Z)": "名称(A–Z)",
"Name of the folder the original is in": "原始文件所在文件夹的名称",
"Named People": "已命名的人物",
"Nested group": "嵌套组",
"New time": "新时间",
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
"No named people in these photos": "这些照片中没有已命名的人物",
"No named people yet": "还没有已命名的人物",
"Not Now": "以后再说",
"Object Mask": "对象蒙版",
"Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "对象蒙版和描述蒙版使用 Meta 的分割模型 SAM 3。它不属于 LightCraft,其他所有功能无需它也能使用。",
"October 2026": "2026年10月",
"Only": "仅限",
"Original extension without the dot": "不含点的原始扩展名",
"Original file name without its extension": "不含扩展名的原始文件名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合并预览",
"Panoramas": "全景",
"Paste Settings": "粘贴设置",
"Paste from Previous": "粘贴上一张照片的设置",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)",
"Pick Flag": "留用旗标",
"Pictures": "图片",
"Prepare AI Masks": "准备 AI 蒙版",
"Presets and profiles": "预设和配置文件",
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
"Proof copy created": "已创建校样副本",
"Quit Anyway": "仍然退出",
"Quit with unsaved changes?": "有未保存的更改,确定退出吗?",
"RAW decoding": "RAW 解码",
"Radial": "径向",
"Random": "随机",
"Rating cleared": "已清除星级",
"Raw": "RAW",
"Read the SAM License": "阅读 SAM 许可证",
"Real name": "真实姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的关键字",
"Refine": "细化",
"Refine AI Mask Detail": "细化 AI 蒙版细节",
"Refine Edges": "细化边缘",
"Refine Saturation": "细化饱和度",
"Refining the mask's detail…": "正在细化蒙版细节…",
"Reliability": "可靠性",
"Relink Photo": "重新链接照片",
"Reload Changed Files": "重新载入已更改的文件",
"Remove Face Box": "移除人脸框",
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
"Remove from Target Album": "从目标相册中移除",
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
"Rename Component": "重命名组件",
"Reorder Masks": "重新排序蒙版",
"Reshuffle": "重新随机排序",
"File name": "文件名",
"Fill Edges": "填充边缘",
@ -931,23 +1139,31 @@
"Reset Tone Curve": "重置色调曲线",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)",
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
"Resize Face Box": "调整人脸框大小",
"Resize to": "调整大小为",
"Resolution (ppi)": "分辨率 (ppi)",
"Restore": "恢复",
"Restore Library from Backup…": "从备份恢复图库…",
"Restore Version": "恢复版本",
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
"Restore them": "恢复",
"Retouch": "润饰",
"Reverse the order": "倒序排列",
"Revert": "恢复",
"Review": "审阅",
"Review Import": "查看导入内容",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)",
"Right-click a preset to delete it": "右键单击预设即可将其删除",
"Rights Usage Terms": "使用条款",
"Rotate": "旋转",
"Rotate Crop": "旋转裁剪",
"Rotate Crop Aspect": "旋转裁切长宽比",
"Rotate Left": "向左旋转",
"Rotate Right": "向右旋转",
"Roughness": "粗糙度",
"Roundness": "圆度",
"Rules": "规则",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下载已停止:下次将从中断处继续。",
"Same as raw default": "与 RAW 默认设置相同",
"Sample Mask Color": "取样蒙版颜色",
"Sample a colour on the photo": "在照片上取样颜色",
@ -968,14 +1184,17 @@
"Save Point Curve Preset": "保存点曲线预设",
"Save Point Curve…": "保存点曲线…",
"Save as Preset": "保存为预设",
"Save as Set…": "存储为标签集…",
"Save as Smart Album…": "保存为智能相册…",
"Save as set": "保存为集",
"Save as…": "另存为…",
"Save preset": "保存预设",
"Scale": "缩放",
"Scan failed": "扫描失败",
"Scanning": "正在扫描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
"Screen": "屏幕",
"Search": "搜索",
"Search Photos": "搜索照片",
"Search…": "搜索…",
"Seasons": "四季",
@ -992,28 +1211,39 @@
"Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)",
"Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)",
"Selected": "已选择",
"Selecting…": "正在选择…",
"Selenium Tone": "硒调色",
"Send Feedback…": "发送反馈…",
"Sepia Tone": "棕褐色调",
"Sequence number zero-padded to N digits (1–9)": "补零至 N 位的序号(1–9)",
"Sequence number, counting from the start number": "从起始编号开始计数的序号",
"Services": "服务",
"Set": "标签集",
"Set Album Cover": "设置相册封面",
"Set Before": "设为修改前",
"Set Color Label": "设置颜色标签",
"Set Crop": "设置裁切",
"Set Develop Values": "设置编辑参数",
"Set Flag": "设置旗标",
"Set Image Zoom and Pan": "设置图像缩放和平移",
"Set Mask Adjustments": "设置蒙版调整",
"Set Point Curve": "设置点曲线",
"Set Profile": "设置配置文件",
"Set Rating": "设置星级",
"Set Top of Stack": "设为堆叠顶部",
"Set as Album Cover": "设为相册封面",
"Set as Before": "设为修改前",
"Set as Reference Photo": "设为参考照片",
"Set as Target Album": "设为目标相册",
"Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)",
"Set as Top of Stack": "设为堆叠顶部",
"Set date & time": "设置日期和时间",
"Set name": "标签集名称",
"Set the mix from the photo's colours": "根据照片颜色设置混合",
"Settings": "设置",
"Settings file problem": "设置文件有问题",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。",
"Settings pasted": "已粘贴设置",
"Settings to include": "要包含的设置",
"Settings…": "设置…",
"Shade": "阴影",
@ -1027,7 +1257,10 @@
"Sharpen": "锐化",
"Sharpening": "锐化",
"Sharpness": "锐度",
"Shift": "偏移量",
"Shift by": "偏移",
"Short Edge": "短边",
"Show": "显示",
"Show All": "显示全部",
"Show Clipping": "显示溢出警告",
"Show Deghost Overlay": "显示去重影叠加",
@ -1045,6 +1278,8 @@
"Show destination gamut warning": "显示目标色域警告",
"Show display gamut warning": "显示显示器色域警告",
"Show file names": "显示文件名",
"Show in Explorer": "在文件资源管理器中显示",
"Show in File Manager": "在文件管理器中显示",
"Show in Finder": "在 Finder 中显示",
"Show on Map": "在地图上显示",
"Show overlay (O)": "显示叠加 (O)",
@ -1062,11 +1297,16 @@
"Skip": "跳过",
"Sky": "天空",
"Slideshow": "幻灯片播放",
"Slideshow paused": "幻灯片播放已暂停",
"Slideshow resumed": "幻灯片播放已继续",
"Slideshow · Space pauses · Esc ends": "幻灯片播放 · 空格键暂停 · Esc 结束",
"Smart Album": "智能相册",
"Smart Album Rule Fields": "智能相册规则字段",
"Smart Preview Status": "智能预览状态",
"Smart Previews Location": "智能预览位置",
"Smart preview available": "有智能预览",
"Smart previews": "智能预览",
"Smart previews folder changed": "智能预览文件夹已更改",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@ -1087,8 +1327,10 @@
"Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:",
"Stacks": "堆叠",
"Standard": "标准",
"Star rating (0–5)": "星级(0–5)",
"Start at": "起始编号",
"Start number": "起始编号",
"Starting": "正在开始",
"Startup": "启动",
"State / Province": "省 / 州",
"Stop": "停止",
@ -1103,6 +1345,7 @@
"Subfolder name": "子文件夹名称",
"Subject": "主题",
"Subtract": "减去",
"Subtract from this mask": "从此蒙版中减去",
"Suggestions": "建议",
"Summer Haze": "夏日薄雾",
"Sunday": "星期日",
@ -1110,13 +1353,16 @@
"Survey (N)": "筛选视图(N)",
"Swap": "交换",
"Swap Aspect (X)": "交换长宽比(X)",
"Swap Before and After": "交换修改前和修改后",
"Swap Before and After Settings": "交换修改前和修改后的设置",
"Swap Compare Photos": "交换对比照片",
"Switch light / dark theme": "切换浅色/深色主题",
"Sync Settings": "同步设置",
"System": "系统",
"System Info": "系统信息",
"System Info…": "系统信息…",
"System default": "系统默认",
"Table": "表格",
"Tags": "标签",
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…",
"Take a snapshot": "创建快照",
@ -1132,19 +1378,27 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "纹理",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安装:对象蒙版和描述蒙版可以使用了。",
"The download didn't work:": "下载失败:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)",
"The original extension is always added; {ext} only puts it inside the name as well.": "始终会添加原始扩展名;{ext} 只是额外把它放进名称中。",
"The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。",
"The photo has these settings": "此照片具有这些设置",
"Theme": "主题",
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 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.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
"This photo can't be opened": "无法打开此照片",
"Threshold": "阈值",
"Thumbnail cache": "缩略图缓存",
"Thursday": "星期四",
"Time zone": "时区",
"Tint": "色调",
"Title": "标题",
"Title metadata": "元数据中的标题",
"Toggle Keyword from Set": "切换关键字集中的关键字",
"Toggle Mask": "切换蒙版",
"Toggle Section": "切换板块",
"Toggle Zoom": "切换缩放",
"Tone Curve": "色调曲线",
@ -1154,6 +1408,7 @@
"Treatment": "处理方式",
"Triangle": "三角形",
"Try Again": "重试",
"Try Saving Again": "再次尝试保存",
"Tuesday": "星期二",
"Tungsten": "钨丝灯",
"Turn Off": "关闭",
@ -1162,10 +1417,12 @@
"Uncheck All": "全部取消选中",
"Undo": "撤销",
"Unedited": "未编辑",
"Unedited raws": "未编辑的 RAW",
"Unflag": "取消旗标",
"Unflagged": "无旗标",
"Ungroup Stack": "取消堆叠",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知标签会按原样保留 — 请在预览中检查是否有{typo}。",
"Unrated": "未评级",
"Unreadable": "无法读取",
"Update": "更新",
@ -1175,6 +1432,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "使用当前设置更新版本",
"Update with Current Settings": "使用当前设置更新",
"Updated the edits saved in the external editor": "已更新在外部编辑器中保存的编辑",
"Upright": "垂直校正",
"Urban": "都市",
"Use Keyword Set": "使用关键字集",
@ -1182,6 +1440,7 @@
"Use camera-specific defaults": "使用相机专属默认值",
"Use the GPU for rendering": "使用 GPU 渲染",
"User Presets": "用户预设",
"Username": "用户名",
"Value": "值",
"Variance": "变化幅度",
"Version": "版本",
@ -1193,6 +1452,7 @@
"Vibrance": "鲜艳度",
"Videos": "视频",
"View": "视图",
"Viewing and sharing": "查看与分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "复古",
@ -1220,6 +1480,8 @@
"Width (% of photo)": "宽度(照片百分比)",
"Width (px)": "宽度 (px)",
"Width × Height": "宽度 × 高度",
"Will be replaced": "将替换",
"Will be restored": "将恢复",
"Window": "窗口",
"Winter Blue": "冬日蓝调",
"XMP Sidecar Preferences": "XMP 附属文件偏好设置",
@ -1230,6 +1492,8 @@
"Yellow Luminance": "黄色明度",
"Yellow Saturation": "黄色饱和度",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
"Your Name": "你的名字",
"Your library couldn't be opened": "无法打开图库",
"Zoom": "缩放",
"Zoom 100%": "缩放 100%",
@ -1237,14 +1501,96 @@
"Zoom Out": "缩小",
"Zoom to Fit": "适合窗口",
"Zoom to page level": "缩放至页面级别",
"a literal %": "字面上的 %",
"a removed photo": "一张已移除的照片",
"all": "全部",
"and": "和",
"any": "任一",
"are empty": "为空",
"aren't empty": "不为空",
"blue": "蓝色",
"comma, separated": "逗号分隔",
"contains": "包含",
"copyrighted": "受版权保护",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在丢弃智能预览",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "结尾为",
"false": "否",
"green": "绿色",
"hour 00–23": "时 00–23",
"hours": "小时",
"image": "图像",
"is": "是",
"is <": "小于",
"is >": "大于",
"is after": "晚于",
"is before": "早于",
"is between": "介于",
"is empty": "为空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小于等于",
"is ≥": "大于等于",
"isn't": "不是",
"isn't empty": "不为空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名称 — 或如 {date}_{seq:3}",
"latitude, longitude": "纬度, 经度",
"memory only": "仅内存",
"minute": "分",
"month 01–12": "月 01–12",
"months": "个月",
"none": "无",
"none (CPU rendering)": "无(CPU 渲染)",
"none of": "均不符合",
"not available here": "此处不可用",
"of the following rules:": "以下规则:",
"or": "或",
"pick": "留用",
"previews": "预览",
"publicDomain": "公共领域",
"purple": "紫色",
"raw": "RAW",
"red": "红色",
"reject": "排除",
"saved in": "保存在",
"second": "秒",
"smart previews": "智能预览",
"starts with": "开头为",
"the photos in view": "视图中的照片",
"the selected photos": "所选照片",
"this photo": "这张照片",
"true": "是",
"unknown": "未知",
"video": "视频",
"weeks": "周",
"year, 2 digits": "年,2 位",
"year, 4 digits": "年,4 位",
"years": "年",
"yellow": "黄色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍摄时间;没有拍摄时间的照片使用其导入时间。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 边缘更硬 · + 边缘更柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在“最近删除”中"
"In Recently Deleted": "在“最近删除”中",
"Remove Folder from Library": "从图库中移除文件夹",
"Remove from Library…": "从图库中移除…",
"This Computer": "此电脑",
"includes the folders inside it": "包含其中的文件夹",
"They move to Recently Deleted and can be restored; no file on disk is touched.": "它们会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "将从此文件夹导入的照片移到“最近删除”;不会改动任何文件",
"Remove Disk from Library…": "从图库中移除磁盘…",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "将从此磁盘导入的所有照片移到“最近删除”;不会改动任何文件",
"Collapse": "折叠",
"Expand": "展开",
"Remove Disk from Library": "从图库中移除磁盘",
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
}

View File

@ -4,9 +4,11 @@
" · {} failed: {}": " · {} 個失敗:{}",
" · {} moved": " · 已移動 {} 個",
" · {} not readable": " · {} 個無法讀取",
" · {} restored from Recently Deleted": " · 已從「最近刪除」還原 {} 張",
", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
"Add {local_n} to My Photos": "將 {local_n} 張加入我的照片",
"Add {} Photo{}": "新增 {} 張照片{:.0}",
"Add {} Photo{}, restore {}": "新增 {} 張照片{:.0},還原 {} 張",
"Add “{}” to Favorites": "將「{}」加入喜好項目",
"Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}",
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
@ -64,6 +66,7 @@
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
"Reset {name}": "重設{name}",
"Reset {} Channel": "重設 {} 通道",
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
"Showing {n} of {}": "顯示 {n}/{} 張",
@ -118,5 +121,60 @@
"… and {more} more": "…以及另外 {more} 個",
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(其中 {unsure} 張有多個相似檔案:請使用「尋找」)",
" · {:.0}% clipped": " · {:.0}% 剪裁",
" · {failed} couldn't be rendered": " · {failed} 張無法算繪",
" · {} original{} left at the source": " · {} 個原始檔留在來源位置{:.0}",
" — not carried over: {}{}": " — 未沿用:{}{}",
"+{} more": "+{} 個",
"; {kept} already had a location": ";{kept} 張已有位置資訊",
"; {outside} outside its time range": ";{outside} 張不在其時間範圍內",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 個 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 個二進位資產,{} – {}",
"Added {what} to {name}": "已將{what}加入 {name}",
"Auto Import: added {} photo{}": "自動匯入:已新增 {} 張照片{:.0}",
"Building previews for {} photos…": "正在為 {} 張照片建立預覽…",
"Couldn't open the editor: {e}": "無法開啟編輯器:{e}",
"Creates {} stacks from {} of {scope}": "將從{scope}中的 {1} 張照片建立 {0} 個堆疊",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 張照片{plural:.0}",
"Focus {:.0}": "焦點 {:.0}",
"Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 張遺失的照片{:.0};仍有 {left} 張遺失{unsure}",
"Import failed: {e}": "匯入失敗:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已匯入 {profiles} 個描述檔{:.0}(描述檔瀏覽器 ▸ 各自的群組)",
"Insert at the cursor (also written {})": "在游標處插入(也可寫成 {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字型:{}(OFL)。圖示:原創。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在套用「{k}」:點擊照片即可加入或移除 · 按 Esc 停止",
"Previews ready for {done} photo{plural}": "已為 {done} 張照片建立預覽{plural:.0}",
"Removed {what} from {name}": "已從 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 張照片上重新命名「{from}」{:.0}(其下層關鍵字一併處理)。以 | 區隔層級,例如 Travel|Italy。改為現有名稱時,兩者會合併。",
"Replace {} with:": "將 {} 取代為:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "為{scope}評估焦點與曝光,並找出相隔數秒內拍攝的相似照片(連拍)。",
"Tagged {n} photo{} from the tracklog": "已依軌跡記錄標記 {n} 張照片的位置{:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下載失敗:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知的標籤{s:.0} {} 會保持原樣{:.0}(請參閱「標籤」)",
"Virtual copy “{name}” of {of}": "{of} 的虛擬副本「{name}」",
"Working on {what} for {} photos…": "正在為 {} 張照片處理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已開啟 {name} 進行編輯;它已與原始照片堆疊在一起",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} 項變更{0:.0}無法寫入磁碟:{e}\n如果現在結束,這些變更將會遺失。{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} 項變更{:.0}已儲存在記憶體中,但未寫入磁碟:{e} — LightCraft 會重試",
"{pid} (missing)": "{pid}(遺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式說明:{}",
"{} contributors · {} commits": "{} 位貢獻者 · {} 次提交",
"{} failed": "{}失敗",
"{} item{}": "{} 個項目{:.0}",
"{} photos · exposures {} EV": "{} 張照片 · 曝光 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} 張照片 · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} 個樣本{:.0}",
"{} sec": "{} 秒",
"{} — show their photos": "{} — 顯示其照片",
"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 目前還無法解碼這種 RAW 變體({})。你正在編輯相機內嵌的 JPEG 預覽,其中已包含相機的相片風格(例如單色)與白平衡。",
"Filtered: {label}": "已篩選:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 張相似照片{:.0} · 選擇「檢視 ▸ 清除篩選條件」可查看全部",
"Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片",
"{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)",
", {} star{}": ",{} 顆星{:.0}",
", {} label": ",{}標籤"
}

View File

@ -1,14 +1,31 @@
{
" (library default)": " (照片庫預設值)",
" (inverted)": " (已反轉)",
" · best of its burst": " · 連拍中的最佳照片",
" · in a burst": " · 屬於一組連拍",
"% of all commits": "佔所有提交的百分比",
"+ Group": "+ 群組",
"+ Rule": "+ 規則",
", picked": ",精選",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等於",
"A blank template keeps the original names.": "範本留空時保留原始名稱。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」",
"A one-time download of about": "一次性下載,大小約",
"AI Mask Model Status": "AI 遮色片模型狀態",
"About": "關於",
"About LightCraft": "關於 LightCraft",
"Active": "目前照片",
"Activity": "處理狀態",
"Actual size": "實際大小",
"Add": "新增",
"Add Catchlight": "新增眼神光",
"Add Folder to Local": "將資料夾加入本機",
"Add Mask": "新增遮色片",
"Add Object Click": "新增物件點擊",
"Add Point Color Sample": "新增點色彩樣本",
"Add Red Eye Correction": "新增紅眼校正",
"Add Remove Spot": "新增移除斑點",
@ -21,9 +38,11 @@
"Add to": "加入至",
"Add to Album": "加入相簿",
"Add to Favorites": "加入喜好項目",
"Add to Local": "加入本機",
"Add to My Photos": "加入我的照片",
"Add to Selected Photos": "加入選取的照片",
"Add to Target Album": "加入目標相簿",
"Add to this mask": "加入此遮色片",
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
"Adding": "正在新增",
@ -44,8 +63,14 @@
"Alt Text": "替代文字",
"Always": "一律",
"Amount": "強度",
"Analyzing the photo for AI masks…": "正在分析照片以建立 AI 遮色片…",
"Any Searchable Text": "任何可搜尋文字",
"Any camera": "任何相機",
"Any keyword": "任何關鍵字",
"Any lens": "任何鏡頭",
"Aperture": "光圈",
"Application": "應用程式",
"Apply": "套用",
"Apply Color Label Set": "套用色彩標籤集",
"Apply Filter Preset": "套用篩選預設集",
"Apply Metadata Preset": "套用中繼資料預設集",
@ -79,9 +104,11 @@
"Auto Import Now": "立即自動匯入",
"Auto Import Settings": "自動匯入設定",
"Auto Mask": "自動遮色片",
"Auto Select": "自動選取",
"Auto Settings": "自動設定",
"Auto Straighten": "自動拉直",
"Auto Sync": "自動同步",
"Auto settings applied": "已套用自動設定",
"Auto-Stack by Capture Time": "依拍攝時間自動堆疊",
"Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…",
"Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
@ -109,6 +136,9 @@
"Before/After Split Top/Bottom": "編輯前/後上下分割檢視",
"Before/After Top/Bottom": "編輯前/後上下檢視",
"Begin Interaction": "開始互動",
"Best of Similar Shots": "相似照片中的最佳照片",
"Binary assets added": "新增的二進位資產",
"Binary assets removed": "移除的二進位資產",
"Bit depth": "位元深度",
"Black & White": "黑白",
"Blacks": "黑色色階",
@ -139,19 +169,32 @@
"By Date": "依日期",
"By day (YYYY/YYYY-MM-DD)": "依日期(YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "依月份(YYYY/YYYY-MM)",
"CPU threads": "CPU 執行緒",
"Calibration": "校正",
"Camera": "相機",
"Camera make and model": "相機品牌與型號",
"Cameras and Cards": "相機與記憶卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下載 AI 遮色片模型",
"Cancel Download": "取消下載",
"Cancel Interaction": "取消互動",
"Cancel Preview Build": "取消建立預覽",
"Candidate": "候選照片",
"Caption": "圖說",
"Capture Date": "拍攝日期",
"Capture date and time in your own format (directives below)": "自訂格式的拍攝日期與時間(格式說明見下方)",
"Capture date as YYYYMMDD": "拍攝日期(YYYYMMDD)",
"Captured": "拍攝時間",
"Captured from": "拍攝時間起於",
"Captured until": "拍攝時間止於",
"Catalog Statistics": "目錄統計",
"Catchlight": "眼神光",
"Change": "變更方式",
"Change Component Mode": "變更元件模式",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。",
"Check All": "全部勾選",
"Check Output Path": "檢驗輸出路徑",
"Checking names…": "正在檢查名稱…",
"Choose Another Library…": "選擇其他照片庫…",
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
"Choose Edit Settings to Copy…": "選擇要複製的編輯設定…",
@ -176,8 +219,11 @@
"Click Zoom Ratio": "點擊縮放比例",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。",
"Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束",
"Click the object to select it · ⌥-click leaves a part out": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分",
"Click the photo to pick a colour · ⇧-click adds more": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩",
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩(最多 5 個)",
"Click to clear these filters": "點擊以清除這些篩選條件",
"Click to restore · double-click to rename": "點擊以還原 · 按兩下以重新命名",
"Clone": "仿製",
"Close": "關閉",
"Close Panel": "關閉面板",
@ -186,7 +232,10 @@
"Close file": "關閉檔案",
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Collapse Stack": "收合堆疊",
"Color": "色彩",
"Color Label": "色彩標籤",
"Color label cleared": "已清除色彩標籤",
"Color Grading": "色彩分級",
"Color Label Names": "色彩標籤名稱",
"Color Label Sets": "色彩標籤集",
@ -196,9 +245,12 @@
"Color Range": "色彩範圍",
"Color space": "色彩空間",
"Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)",
"Colors": "色彩",
"Combine files…": "合併檔案…",
"Command Journal": "指令記錄",
"Comments panel": "註解面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比較",
"Compare (Shift+C)": "比較(Shift+C)",
"Compare Before and After": "比較編輯前與編輯後",
@ -209,13 +261,16 @@
"Confirm before deleting": "刪除前確認",
"Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
"Constrain Crop": "限制裁切範圍",
"Continue": "繼續",
"Continue Without Saving": "繼續使用但不儲存",
"Contrast": "對比",
"Contributors": "貢獻者",
"Convert to DNG": "轉換為 DNG",
"Cool Fade": "冷調褪色",
"Cool Morning": "清冷晨光",
"Copy": "複製",
"Copy After's Settings to Before": "將編輯後設定複製至編輯前",
"Copy Before to After": "將編輯前複製到編輯後",
"Copy Before's Settings to After": "將編輯前設定複製至編輯後",
"Copy Edit Settings": "複製編輯設定",
"Copy History Step Settings to Before": "將歷史步驟設定複製至編輯前",
@ -250,16 +305,19 @@
"Create Version from Step": "依此步驟建立版本",
"Create Virtual Copy": "建立虛擬副本",
"Creator": "創作者",
"Creator metadata": "中繼資料中的創作者",
"Crisp Landscape": "清晰風景",
"Crisp Vista": "清晰遠景",
"Crop": "裁切",
"Crop & Rotate": "裁切與旋轉",
"Crop & Rotate (C)": "裁切與旋轉(C)",
"Crop Aspect": "裁切長寬比",
"Crop, Rotate, Geometry": "裁切、旋轉、幾何",
"Cross Process": "交叉沖洗",
"Culling": "挑片",
"Curve": "曲線",
"Custom": "自訂",
"Custom template…": "自訂範本…",
"Custom…": "自訂…",
"Cut": "剪下",
"Cut pages": "剪下頁面",
@ -272,6 +330,7 @@
"Dark & Moody": "暗調氛圍",
"Darken": "變暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期群組",
"Day": "日",
"Daylight": "日光",
@ -286,6 +345,7 @@
"Delete Album": "刪除相簿",
"Delete All Masks": "刪除所有遮色片",
"Delete Color Label Set": "刪除色彩標籤集",
"Delete Component": "刪除元件",
"Delete Export Preset": "刪除匯出預設集",
"Delete Filter Preset": "刪除篩選預設集",
"Delete Imported Profile": "刪除匯入的描述檔",
@ -296,9 +356,11 @@
"Delete Permanently": "永久刪除",
"Delete Photo": "刪除照片",
"Delete Photos": "刪除照片",
"Delete Point Color": "刪除點色彩",
"Delete Point Color Sample": "刪除點色彩樣本",
"Delete Point Curve Preset": "刪除點曲線預設集",
"Delete Preset": "刪除預設集",
"Delete Red Eye": "刪除紅眼校正",
"Delete Red Eye Correction": "刪除紅眼校正",
"Delete Spot": "刪除斑點",
"Delete Version": "刪除版本",
@ -309,27 +371,41 @@
"Density": "濃度",
"Depth Range": "深度範圍",
"Descending": "遞減",
"Describe": "描述",
"Describe what to keep": "描述要保留的內容",
"Describe what to leave out": "描述要排除的內容",
"Describe what to select": "描述要選取的內容",
"Describe…": "描述…",
"Deselect": "取消選取",
"Deselect All": "取消全選",
"Desert Heat": "沙漠熱浪",
"Desert Warmth": "沙漠暖色",
"Desktop": "桌面",
"Detail": "詳細檢視",
"Detail (D)": "詳細檢視(D)",
"Details": "詳細資訊",
"Diagonal": "對角線",
"Discard Preview Cache": "捨棄預覽快取",
"Display P3": "Display P3",
"Display name": "顯示名稱",
"Display theme": "顯示主題",
"Distortion": "變形",
"Document properties…": "文件內容…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下載",
"Download AI Mask Model": "下載 AI 遮色片模型",
"Download the SAM 3 Model?": "要下載 SAM 3 模型嗎?",
"Downloads": "下載項目",
"Drag along the horizon; double-click for Auto": "沿水平線拖曳;按兩下可自動校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片的眼睛上拖曳,會自動尋找範圍內的瞳孔。",
"Draw Guides": "繪製參考線",
"Duplicate": "複製",
"Duplicate Component": "複製元件",
"Duplicate Mask": "複製遮色片",
"Duplicate and Invert Mask": "複製並反轉遮色片",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每個 / 代表一層資料夾;標籤會依照片填入(缺少中繼資料的層級使用「unknown」)。",
"Edge": "邊緣",
"Edit": "編輯",
"Edit (E)": "編輯(E)",
"Edit Capture Time": "編輯拍攝時間",
@ -337,26 +413,36 @@
"Edit Color Label Names": "編輯色彩標籤名稱",
"Edit Color Label Names…": "編輯色彩標籤名稱…",
"Edit Copy for External Editor": "編輯外部編輯器副本",
"Edit Date": "編輯日期",
"Edit Info": "編輯資訊",
"Edit Label Names": "編輯標籤名稱",
"Edit Label Names…": "編輯標籤名稱…",
"Edit Mask": "編輯遮色片",
"Edit Mask Component": "編輯遮色片元件",
"Edit Sections": "編輯區段",
"Edit Smart Album": "編輯智慧型相簿",
"Edit Smart Album…": "編輯智慧型相簿…",
"Edit Spot": "編輯斑點",
"Edit in External Editor": "在外部編輯器中編輯",
"Edit settings copied": "已複製編輯設定",
"Edited": "已編輯",
"Edited or not": "不限是否編輯",
"Editing": "編輯",
"Effect": "效果",
"Effects": "效果",
"Empty Recently Deleted": "清空「最近刪除」",
"Enable Lens Corrections": "啟用鏡頭校正",
"Enable Profile Corrections": "啟用描述檔校正",
"End Interaction": "結束互動",
"English": "English",
"Enter Full Screen": "進入全螢幕",
"Enter a date as YYYY-MM-DD HH:MM:SS": "請以 YYYY-MM-DD HH:MM:SS 格式輸入日期",
"Erase": "擦除",
"Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位",
"Existing previews": "現有預覽",
"Exit": "結束",
"Expand All Stacks": "展開所有堆疊",
"Expand Stack": "展開堆疊",
"Expand/Collapse Stack": "展開/收合堆疊",
"Export": "匯出",
"Export Album with Preset": "使用預設集匯出相簿",
@ -369,6 +455,7 @@
"Export Presets": "匯出預設集",
"Export Presets…": "匯出預設集…",
"Export User Presets…": "匯出使用者預設集…",
"Export stopped unexpectedly": "匯出意外停止",
"Export with Preset": "使用預設集匯出",
"Export with Previous": "使用上次設定匯出",
"Exporting": "正在匯出",
@ -377,6 +464,9 @@
"Exposure (shutter · aperture · ISO)": "曝光(快門 · 光圈 · ISO)",
"Extended Description": "延伸說明",
"External Editor": "外部編輯器",
"Face Boxes": "臉部框",
"Face boxes": "臉部框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他應用程式寫入 XMP 的臉部名稱會顯示在這裡。",
"Faded Matte": "褪色霧面",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色城市",
@ -386,10 +476,128 @@
"Feather": "羽化",
"File": "檔案",
"File & date": "檔案與日期",
"File Format": "檔案格式",
"File Name": "檔案名稱",
"File Path": "檔案路徑",
"File Size": "檔案大小",
"File Type": "檔案類型",
"Filename": "檔名",
"Fill": "填滿",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五顆星",
"Flagged as Pick": "已標記為精選",
"Flagged as Reject": "已標記為排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "焦點(輔助挑選)",
"Frame time": "影格時間",
"Frame update": "影格更新",
"GPU rendering": "GPU 算繪",
"Generated demo photo": "產生的示範照片",
"Grab bag": "名單",
"Group name": "群組名稱",
"HDR Merge Preview": "HDR 合併預覽",
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有編輯",
"Has GPS": "有 GPS",
"Home": "個人資料夾",
"Hours": "小時",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "以新照片匯入",
"Imported": "已匯入",
"Info Overlay Off": "關閉資訊疊加",
"Info Overlay: Exposure": "資訊疊加:曝光",
"Info Overlay: File": "資訊疊加:檔案",
"Insert at the cursor": "在游標處插入",
"Invert Component": "反轉元件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
"Label": "標籤",
"Label Set": "標籤集",
"Last GPU fallback": "最近一次 GPU 備援",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大檢視算繪",
"Lens Blur": "鏡頭模糊",
"Library saved": "照片庫已儲存",
"Licence:": "授權:",
"Line delta": "行數差",
"Linear": "線性",
"Lines +/−": "行數 +/−",
"Lines added": "新增行數",
"Lines deleted": "刪除行數",
"Lock": "鎖定",
"Lock Crop Aspect": "鎖定裁切長寬比",
"Locked": "已鎖定",
"Mask Adjustment": "遮色片調整",
"Memory budget": "記憶體預算",
"Merged": "已合併",
"Merged PRs": "已合併的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "適用的是 Meta 的條款,而非 LightCraft 的條款。下載即表示接受這些條款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的中繼資料({camera}、{title}…)會留空;產生的名稱若為空白,則保留原始名稱。",
"Model": "型號",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "將預設集移至新群組",
"Move Up": "上移",
"Move in Stack": "在堆疊中移動",
"Name (A–Z)": "名稱(A–Z)",
"Name of the folder the original is in": "原始檔所在資料夾的名稱",
"Named People": "已命名的人物",
"Nested group": "巢狀群組",
"New time": "新時間",
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
"No named people in these photos": "這些照片中沒有已命名的人物",
"No named people yet": "尚無已命名的人物",
"Not Now": "以後再說",
"Object Mask": "物件遮色片",
"Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "物件遮色片與描述遮色片使用 Meta 的分割模型 SAM 3。它不屬於 LightCraft,其他所有功能不需要它也能使用。",
"October 2026": "2026年10月",
"Only": "僅限",
"Original extension without the dot": "不含點的原始副檔名",
"Original file name without its extension": "不含副檔名的原始檔名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合併預覽",
"Panoramas": "全景",
"Paste Settings": "貼上設定",
"Paste from Previous": "貼上前一張照片的設定",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)",
"Pick Flag": "精選旗標",
"Pictures": "圖片",
"Prepare AI Masks": "準備 AI 遮色片",
"Presets and profiles": "預設集與描述檔",
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
"Proof copy created": "已建立校樣副本",
"Quit Anyway": "仍要結束",
"Quit with unsaved changes?": "有未儲存的變更,確定要結束嗎?",
"RAW decoding": "RAW 解碼",
"Radial": "放射狀",
"Random": "隨機",
"Rating cleared": "已清除評分",
"Raw": "RAW",
"Read the SAM License": "閱讀 SAM 授權",
"Real name": "真實姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的關鍵字",
"Refine": "調整",
"Refine AI Mask Detail": "調整 AI 遮色片細節",
"Refine Edges": "調整邊緣",
"Refine Saturation": "調整飽和度",
"Refining the mask's detail…": "正在調整遮色片細節…",
"Reliability": "可靠度",
"Relink Photo": "重新連結照片",
"Reload Changed Files": "重新載入已變更的檔案",
"Remove Face Box": "移除臉部框",
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
"Remove from Target Album": "從目標相簿移除",
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
"Rename Component": "重新命名元件",
"Reorder Masks": "重新排序遮色片",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
@ -931,23 +1139,31 @@
"Reset Tone Curve": "重設色調曲線",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)",
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
"Resize Face Box": "調整臉部框大小",
"Resize to": "調整尺寸為",
"Resolution (ppi)": "解析度(ppi)",
"Restore": "還原",
"Restore Library from Backup…": "從備份還原照片庫…",
"Restore Version": "還原版本",
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
"Restore them": "還原",
"Retouch": "修飾",
"Reverse the order": "反轉順序",
"Revert": "回復至上次儲存",
"Review": "審閱",
"Review Import": "檢視匯入",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)",
"Right-click a preset to delete it": "在預設集上按右鍵即可刪除",
"Rights Usage Terms": "使用權利條款",
"Rotate": "旋轉",
"Rotate Crop": "旋轉裁切",
"Rotate Crop Aspect": "旋轉裁切長寬比",
"Rotate Left": "向左旋轉",
"Rotate Right": "向右旋轉",
"Roughness": "粗糙度",
"Roundness": "圓度",
"Rules": "規則",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下載已停止:下次會從中斷處繼續。",
"Same as raw default": "與 RAW 預設值相同",
"Sample Mask Color": "取樣遮色片色彩",
"Sample a colour on the photo": "在照片上取樣色彩",
@ -968,14 +1184,17 @@
"Save Point Curve Preset": "儲存點曲線預設集",
"Save Point Curve…": "儲存點曲線…",
"Save as Preset": "儲存為預設集",
"Save as Set…": "儲存為標籤集…",
"Save as Smart Album…": "儲存為智慧型相簿…",
"Save as set": "儲存為關鍵字集",
"Save as…": "另存新檔…",
"Save preset": "儲存預設集",
"Scale": "縮放",
"Scan failed": "掃描失敗",
"Scanning": "正在掃描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
"Screen": "螢幕",
"Search": "搜尋",
"Search Photos": "搜尋照片",
"Search…": "搜尋…",
"Seasons": "四季",
@ -992,28 +1211,39 @@
"Select photos, then choose View → Survey (N)": "選取照片後,選擇「檢視 → 總覽」(N)",
"Select two photos, then choose View → Compare (Shift+C)": "選取兩張照片後,選擇「檢視 → 比較」(Shift+C)",
"Selected": "已選取",
"Selecting…": "正在選取…",
"Selenium Tone": "硒調色",
"Send Feedback…": "傳送意見回饋…",
"Sepia Tone": "深褐色調",
"Sequence number zero-padded to N digits (1–9)": "補零至 N 位數的序號(1–9)",
"Sequence number, counting from the start number": "從起始編號開始計數的序號",
"Services": "服務",
"Set": "標籤集",
"Set Album Cover": "設定相簿封面",
"Set Before": "設定編輯前",
"Set Color Label": "設定色彩標籤",
"Set Crop": "設定裁切",
"Set Develop Values": "設定顯像參數",
"Set Flag": "設定旗標",
"Set Image Zoom and Pan": "設定影像縮放與平移",
"Set Mask Adjustments": "設定遮色片調整",
"Set Point Curve": "設定點曲線",
"Set Profile": "設定描述檔",
"Set Rating": "設定評分",
"Set Top of Stack": "設為堆疊頂端",
"Set as Album Cover": "設為相簿封面",
"Set as Before": "設為編輯前",
"Set as Reference Photo": "設為參考照片",
"Set as Target Album": "設為目標相簿",
"Set as Target Album (B adds to it)": "設為目標相簿(按 B 加入)",
"Set as Top of Stack": "設為堆疊頂端",
"Set date & time": "設定日期與時間",
"Set name": "標籤集名稱",
"Set the mix from the photo's colours": "依照片色彩設定混色",
"Settings": "設定",
"Settings file problem": "設定檔有問題",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片使用的初始設定。變更不會影響照片庫中已有的照片。",
"Settings pasted": "已貼上設定",
"Settings to include": "要包含的設定",
"Settings…": "設定…",
"Shade": "陰影處",
@ -1027,7 +1257,10 @@
"Sharpen": "銳利化",
"Sharpening": "銳利化",
"Sharpness": "銳利度",
"Shift": "位移量",
"Shift by": "位移",
"Short Edge": "短邊",
"Show": "顯示",
"Show All": "全部顯示",
"Show Clipping": "顯示溢色",
"Show Deghost Overlay": "顯示去除鬼影疊加",
@ -1045,6 +1278,8 @@
"Show destination gamut warning": "顯示目標色域警告",
"Show display gamut warning": "顯示螢幕色域警告",
"Show file names": "顯示檔案名稱",
"Show in Explorer": "在檔案總管中顯示",
"Show in File Manager": "在檔案管理員中顯示",
"Show in Finder": "在 Finder 中顯示",
"Show on Map": "在地圖上顯示",
"Show overlay (O)": "顯示疊加(O)",
@ -1062,11 +1297,16 @@
"Skip": "略過",
"Sky": "天空",
"Slideshow": "幻燈片播放",
"Slideshow paused": "幻燈片播放已暫停",
"Slideshow resumed": "幻燈片播放已繼續",
"Slideshow · Space pauses · Esc ends": "幻燈片播放 · 空白鍵暫停 · Esc 結束",
"Smart Album": "智慧型相簿",
"Smart Album Rule Fields": "智慧型相簿規則欄位",
"Smart Preview Status": "智慧型預覽狀態",
"Smart Previews Location": "智慧型預覽位置",
"Smart preview available": "有智慧型預覽",
"Smart previews": "智慧型預覽",
"Smart previews folder changed": "智慧型預覽資料夾已變更",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@ -1087,8 +1327,10 @@
"Stack photos taken within this time of each other:": "將拍攝時間間隔在此範圍內的照片堆疊:",
"Stacks": "堆疊",
"Standard": "標準",
"Star rating (0–5)": "星級評分(0–5)",
"Start at": "起始值",
"Start number": "起始編號",
"Starting": "正在開始",
"Startup": "啟動",
"State / Province": "州/省",
"Stop": "停止",
@ -1103,6 +1345,7 @@
"Subfolder name": "子資料夾名稱",
"Subject": "主體",
"Subtract": "減去",
"Subtract from this mask": "從此遮色片減去",
"Suggestions": "建議",
"Summer Haze": "夏日薄霧",
"Sunday": "星期日",
@ -1110,13 +1353,16 @@
"Survey (N)": "總覽(N)",
"Swap": "交換",
"Swap Aspect (X)": "交換長寬比(X)",
"Swap Before and After": "交換編輯前與編輯後",
"Swap Before and After Settings": "交換編輯前與編輯後設定",
"Swap Compare Photos": "交換比較照片",
"Switch light / dark theme": "切換淺色/深色主題",
"Sync Settings": "同步設定",
"System": "系統",
"System Info": "系統資訊",
"System Info…": "系統資訊…",
"System default": "系統預設值",
"Table": "表格",
"Tags": "標籤",
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "標籤:請參閱範本旁的「標籤」。檔案及其 XMP 附屬檔案會在磁碟上重新命名;遇到同名檔案會加上 -1、-2…",
"Take a snapshot": "擷取快照",
@ -1132,19 +1378,27 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "紋理",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安裝:物件遮色片與描述遮色片已可使用。",
"The download didn't work:": "下載失敗:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)",
"The original extension is always added; {ext} only puts it inside the name as well.": "一律會加上原始副檔名;{ext} 只是另外將它放進名稱中。",
"The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。",
"The photo has these settings": "此照片具有這些設定",
"Theme": "主題",
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 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.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
"This photo can't be opened": "無法開啟此照片",
"Threshold": "臨界值",
"Thumbnail cache": "縮圖快取",
"Thursday": "星期四",
"Time zone": "時區",
"Tint": "色調",
"Title": "標題",
"Title metadata": "中繼資料中的標題",
"Toggle Keyword from Set": "切換關鍵字集中的關鍵字",
"Toggle Mask": "切換遮色片",
"Toggle Section": "切換區段",
"Toggle Zoom": "切換縮放",
"Tone Curve": "色調曲線",
@ -1154,6 +1408,7 @@
"Treatment": "處理方式",
"Triangle": "三角形",
"Try Again": "再試一次",
"Try Saving Again": "再試一次儲存",
"Tuesday": "星期二",
"Tungsten": "鎢絲燈",
"Turn Off": "關閉",
@ -1162,10 +1417,12 @@
"Uncheck All": "全部取消勾選",
"Undo": "復原",
"Unedited": "未編輯",
"Unedited raws": "未編輯的 RAW",
"Unflag": "移除旗標",
"Unflagged": "無旗標",
"Ungroup Stack": "取消堆疊",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知的標籤會保持原樣 — 請在預覽中檢查是否有{typo}。",
"Unrated": "未評分",
"Unreadable": "無法讀取",
"Update": "更新",
@ -1175,6 +1432,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "以目前設定更新版本",
"Update with Current Settings": "以目前設定更新",
"Updated the edits saved in the external editor": "已更新在外部編輯器中儲存的編輯內容",
"Upright": "透視校正",
"Urban": "城市",
"Use Keyword Set": "使用關鍵字集",
@ -1182,6 +1440,7 @@
"Use camera-specific defaults": "使用個別相機的預設值",
"Use the GPU for rendering": "使用 GPU 算繪",
"User Presets": "使用者預設集",
"Username": "使用者名稱",
"Value": "數值",
"Variance": "變異數",
"Version": "版本",
@ -1193,6 +1452,7 @@
"Vibrance": "自然飽和度",
"Videos": "影片",
"View": "檢視",
"Viewing and sharing": "檢視與分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "復古",
@ -1220,6 +1480,8 @@
"Width (% of photo)": "寬度(照片百分比)",
"Width (px)": "寬度(像素)",
"Width × Height": "寬度 × 高度",
"Will be replaced": "將取代",
"Will be restored": "將還原",
"Window": "視窗",
"Winter Blue": "冬日藍調",
"XMP Sidecar Preferences": "XMP 附屬檔案偏好設定",
@ -1230,6 +1492,8 @@
"Yellow Luminance": "黃色明度",
"Yellow Saturation": "黃色飽和度",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
"Your Name": "你的名字",
"Your library couldn't be opened": "無法開啟你的照片庫",
"Zoom": "縮放",
"Zoom 100%": "縮放至 100%",
@ -1237,14 +1501,96 @@
"Zoom Out": "縮小",
"Zoom to Fit": "縮放至符合視窗",
"Zoom to page level": "符合頁面大小",
"a literal %": "字面上的 %",
"a removed photo": "一張已移除的照片",
"all": "全部",
"and": "且",
"any": "任一",
"are empty": "為空",
"aren't empty": "不為空",
"blue": "藍色",
"comma, separated": "以逗號分隔",
"contains": "包含",
"copyrighted": "受著作權保護",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在捨棄智慧型預覽",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "結尾為",
"false": "否",
"green": "綠色",
"hour 00–23": "時 00–23",
"hours": "小時",
"image": "影像",
"is": "是",
"is <": "小於",
"is >": "大於",
"is after": "晚於",
"is before": "早於",
"is between": "介於",
"is empty": "為空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小於或等於",
"is ≥": "大於或等於",
"isn't": "不是",
"isn't empty": "不為空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名稱 — 或例如 {date}_{seq:3}",
"latitude, longitude": "緯度, 經度",
"memory only": "僅記憶體",
"minute": "分",
"month 01–12": "月 01–12",
"months": "個月",
"none": "無",
"none (CPU rendering)": "無(CPU 算繪)",
"none of": "皆不符合",
"not available here": "此處無法使用",
"of the following rules:": "下列規則:",
"or": "或",
"pick": "精選",
"previews": "預覽",
"publicDomain": "公有領域",
"purple": "紫色",
"raw": "RAW",
"red": "紅色",
"reject": "排除",
"saved in": "儲存於",
"second": "秒",
"smart previews": "智慧型預覽",
"starts with": "開頭為",
"the photos in view": "檢視中的照片",
"the selected photos": "選取的照片",
"this photo": "這張照片",
"true": "是",
"unknown": "未知",
"video": "影片",
"weeks": "週",
"year, 2 digits": "年,2 位數",
"year, 4 digits": "年,4 位數",
"years": "年",
"yellow": "黃色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍攝時間;沒有拍攝時間的照片會使用匯入時間。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 邊緣較硬 · + 邊緣較柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在「最近刪除」中"
"In Recently Deleted": "在「最近刪除」中",
"Remove Folder from Library": "從照片庫移除資料夾",
"Remove from Library…": "從照片庫移除…",
"This Computer": "這部電腦",
"includes the folders inside it": "包含其中的資料夾",
"They move to Recently Deleted and can be restored; no file on disk is touched.": "它們會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "將從此資料夾匯入的照片移至「最近刪除」;不會更動任何檔案",
"Remove Disk from Library…": "從照片庫移除磁碟…",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "將從此磁碟匯入的所有照片移至「最近刪除」;不會更動任何檔案",
"Collapse": "收合",
"Expand": "展開",
"Remove Disk from Library": "從照片庫移除磁碟",
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
}

View File

@ -258,6 +258,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
ctx.request_repaint();
ok(Value::Null)
}
"ui.zoom" => {
let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else {
return err("ui.zoom: factor must be a finite positive number (1 = unchanged)");
};
app.synthetic.push(egui::Event::Zoom(factor));
ctx.request_repaint();
ok(Value::Null)
}
"ui.set" => {
let mut v = serde_json::to_value(&app.ui).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, p);
@ -352,7 +360,7 @@ pub fn default_export_dir() -> String {
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
let p = &app.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 let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);

View File

@ -125,7 +125,7 @@ impl Contributor {
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
crate::i18n::tr_format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
@ -136,7 +136,7 @@ impl Contributor {
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
day(self.last_commit)
)
}
}
@ -232,43 +232,43 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
ui.label(crate::i18n::tr("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
if ui.selectable_label(v.names == m, crate::i18n::tr(m.label())).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
ui.label(crate::i18n::tr("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
let r = ui.selectable_label(!v.table, "Grab bag");
let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag"));
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
if r.clicked() {
v.table = false;
}
let r = ui.selectable_label(v.table, "Table");
let r = ui.selectable_label(v.table, crate::i18n::tr("Table"));
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
if r.clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
ui.label(crate::i18n::tr("No contributor data was built into this copy."));
} else if v.table {
table(app, ui, &list, &mut v);
} else {
@ -289,7 +289,11 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if ui
.button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong())
.on_hover_text(crate::i18n::tr(k.label().0))
.clicked()
{
if v.key == k {
v.ascending = !v.ascending;
} else {
@ -318,14 +322,14 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
ui.label(crate::i18n::tr("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
ui.label(RichText::new(crate::i18n::tr(h)).strong());
}
ui.end_row();
for m in MODELS {

View File

@ -122,7 +122,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.export = None;
app.toast(ctx, "Export stopped unexpectedly");
app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));
}
}
}

View File

@ -1053,6 +1053,37 @@ mod tests {
assert_ne!(h.app.session.active(), Some(first), "advanced");
}
/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc
/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star).
#[test]
fn slider_values_can_be_typed() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
h.settle(SETTLE);
let active = h.app.session.active().unwrap();
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating;
let (undo0, rating0) = (h.app.session.undo.len(), rating(&h));
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "1,5"}), t);
h.request("ui.key", json!({"key": "Enter"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
assert_eq!(rating(&h), rating0, "typing 1 set no rating");
// Esc keeps the value
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "-2"}), t);
h.request("ui.key", json!({"key": "Escape"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
// something that isn't a number changes nothing
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "bright"}), t);
h.request("ui.key", json!({"key": "Enter"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
assert_eq!(h.app.session.undo.len(), undo0 + 1);
}
/// The eye on a section header switches the section off and on again, one undo step each
/// (issue #316).
#[test]
@ -1125,6 +1156,26 @@ mod tests {
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// The source facts were cached by a worker with the source-language default. They must
// still be rendered in German after switching, with the folder name kept verbatim.
assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true);
h.settle(SETTLE);
fn texts(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()),
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)),
_ => {}
}
}
let mut painted = Vec::new();
for shape in &h.view.shapes {
texts(&shape.shape, &mut painted);
}
assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}");
for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] {
assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}");
}
assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true);
// a camera / card folder: copied into the library by default
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
@ -1376,6 +1427,122 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// The import review offers what to do with files that are in Recently Deleted (issue #298):
/// they are unchecked until Restore or Import as new is chosen; then confirming restores the
/// photo (with its edits) or imports the file afresh.
#[test]
fn import_review_offers_recently_deleted_files() {
for (choice, tag) in [("restore", "r"), ("fresh", "f")] {
let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..2u8 {
let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data };
let png =
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join(format!("img{i}.png")), png).unwrap();
}
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let r =
h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t);
let id = r["result"]["imported"][0].as_u64().unwrap();
h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t);
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t);
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.selected_paths().len(), 1, "only the new file: the trashed one waits for a choice");
opts.set_on_deleted(choice);
assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked once a choice is made");
// a per-cell choice survives switching between the two options, and "Leave them" unchecks
let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap();
opts.checked[trashed] = false;
opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" });
assert_eq!(opts.selected_paths().len(), 1, "still unchecked");
opts.set_on_deleted("");
assert_eq!(opts.selected_paths().len(), 1);
opts.set_on_deleted(choice);
assert_eq!(opts.selected_paths().len(), 2);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
// frames until the import has finished, so its (timed) toast is read before it expires
let t0 = std::time::Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) {
h.step();
}
assert!(h.app.import.is_none(), "finished");
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
let photos = &h.app.session.catalog;
assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}");
let old = photos.photo(lightcraft_catalog::PhotoId(id));
if choice == "restore" {
assert!(toast.contains("1 restored"), "{toast}");
assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits");
assert_eq!(photos.len(), 2);
} else {
assert!(old.is_none(), "the trashed record is gone");
assert_eq!(photos.len(), 2);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
/// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its
/// corner: the photo grid takes the new height, and the window then keeps its size (issue #337).
#[test]
fn import_review_dialog_resizes() {
let mut h = demo([1400.0, 1000.0]);
let t = Duration::from_secs(10);
let candidates = (0..60)
.map(|i| lightcraft_engine::import::ImportCandidate {
path: format!("/lc-test/img{i}.png"),
name: format!("img{i}.png"),
error: Some("not read in this test".into()),
..Default::default()
})
.collect();
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) });
let rect = |h: &mut Headless| {
let r = h.request("ui.widgets", json!({}), t);
let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen");
[0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap())
};
for _ in 0..10 {
h.step();
}
let r0 = rect(&mut h);
assert!(r0[3] > 600.0, "{r0:?}");
// the Copy options add rows below the grid: the grid makes room, the window doesn't grow
for copy in [true, false] {
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.copy = copy;
}
for _ in 0..10 {
h.step();
}
let r = rect(&mut h);
assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}");
}
let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0);
h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t);
for _ in 0..10 {
h.step();
}
let r1 = rect(&mut h);
assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}");
for _ in 0..60 {
h.step();
}
let r2 = rect(&mut h);
assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}");
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open");
}
/// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo
/// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once
/// restored a re-add selects it in All Photos.
@ -1435,6 +1602,8 @@ mod tests {
h.settle(SETTLE);
assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu");
// Empty Recently Deleted is offered while the trash is the source
assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()));
let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored");
@ -1444,6 +1613,7 @@ mod tests {
assert_eq!(h.app.session.selection.ids, vec![id]);
let toast = h.app.ui.toast.clone().expect("toast").0;
assert!(toast.contains("All Photos"), "{toast}");
assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only");
// deleted permanently, the file imports afresh as a new photo
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
@ -1800,7 +1970,7 @@ mod tests {
assert_ne!(r["ok"], true, "{r}");
let fit = h.app.image_rect.unwrap();
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0));
assert!(h.app.ui.zoom_anim, "animation started");
// every animation frame asks for the same (final-size) render
let mut keys = std::collections::HashSet::new();
@ -1819,12 +1989,185 @@ mod tests {
assert_eq!(h.app.ui.zoom, Zoom::Fit);
// Z zooms to the same ratio as a click
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0));
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
h.settle(SETTLE);
}
#[test]
fn trackpad_navigation_keeps_the_image_cursor_between_events() {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t);
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
h.settle(SETTLE);
let anchor = h.app.canvas_rect.unwrap().center();
h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t);
for (tool, expected) in [
("", egui::CursorIcon::Grab),
("wbPicker", egui::CursorIcon::Crosshair),
("colorRange", egui::CursorIcon::Crosshair),
("pointColor", egui::CursorIcon::Crosshair),
("tat:curve", egui::CursorIcon::ResizeVertical),
] {
h.app.ui.tool = tool.into();
for event in [
None,
Some(egui::Event::Zoom(1.01)),
None,
Some(egui::Event::Zoom(1.01)),
Some(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(10.0, -10.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
}),
None,
] {
let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect());
let mut cursor = egui::CursorIcon::Default;
h.view.run(raw, |ui| {
h.app.logic(ui.ctx());
h.app.ui(ui);
cursor = ui.ctx().output(|output| output.cursor_icon);
});
h.time += FRAME_DT;
assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}");
}
}
}
#[test]
fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() {
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
h.pixels_per_point = 2.0;
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t);
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
h.settle(SETTLE);
let before = h.app.image_rect.unwrap();
let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0);
h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t);
let point = (anchor - before.min) / before.size();
h.request("ui.zoom", json!({"factor": 1.001}), t);
let after = h.app.image_rect.unwrap();
assert!((after.width() / before.width() - 1.001).abs() < 0.00001);
assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved");
assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom);
assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately");
h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t);
for _ in 0..40 {
h.step();
}
let panned = h.app.image_rect.unwrap();
assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes");
h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t);
for _ in 0..40 {
h.step();
}
let edge = h.app.image_rect.unwrap();
let area = h.app.canvas_rect.unwrap().shrink(24.0);
assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}");
h.request("ui.zoom", json!({"factor": 1e20}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0));
h.request("ui.zoom", json!({"factor": 0.000001}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
assert_eq!(h.app.ui.pan, (0.5, 0.5));
// Panel / toolbar gestures must not move the image.
h.request("ui.move", json!({"x": 10, "y": 10}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
assert_eq!(h.app.ui.pan, (0.5, 0.5));
}
#[test]
fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() {
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t);
h.settle(SETTLE);
let active = h.app.session.active().unwrap();
let settings = h.app.session.catalog.photo(active).unwrap().develop.clone();
for right in ["crop", "masking", "remove", "redEye", "none"] {
h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t);
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}");
assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo");
}
h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t);
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch");
h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t);
let canvas = h.app.canvas_rect.unwrap();
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane");
h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t);
h.request("engine.execute", json!({"command": "view.compare"}), t);
// The left Compare pane must navigate too; zoom/pan are shared with the right pane.
let canvas = h.app.canvas_rect.unwrap();
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch");
let pan = h.app.ui.pan;
h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t);
assert_ne!(h.app.ui.pan, pan, "Compare pan");
h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t);
h.request("engine.execute", json!({"command": "view.reference"}), t);
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
h.request("ui.zoom", json!({"factor": 2}), t);
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch");
}
#[test]
fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() {
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
assert_eq!(serde_json::from_value::<Zoom>(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0));
for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] {
let r = h.request("ui.zoom", json!({"factor": factor}), t);
assert_eq!(r["ok"], false, "{r}");
}
for params in [
json!({"zoom": {"percent": -1}}),
json!({"zoom": {"percent": 1e308}}),
json!({"zoom": "oops"}),
json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}),
json!({"pan": [0.5, 1e308]}),
json!({"pan": [0.5]}),
] {
assert!(h.app.run("view.navigate", params).is_err());
assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom");
assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan");
}
h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap();
assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4));
assert_eq!(h.app.ui.pan, (0.4, 0.6));
h.app.run("view.zoomIn", json!({})).unwrap();
assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0));
h.app.run("view.zoomOut", json!({})).unwrap();
assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0));
let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0));
for pan in [(0.0, 0.0), (1.0, 1.0)] {
let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan);
assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred");
assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo");
}
}
/// The Navigator appears when zoomed in; clicking it pans to that point.
#[test]
fn navigator_pans_the_zoomed_loupe() {
@ -1839,7 +2182,7 @@ mod tests {
h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t);
let (u, v) = h.app.ui.pan;
assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan);
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)");
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)");
h.settle(SETTLE);
let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap();
let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7);

View File

@ -121,6 +121,8 @@ language_table! {
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@ -191,6 +193,12 @@ pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
/// A profile's name for display: a built-in profile's is translated, an imported LUT's is shown as
/// its file named it (it may happen to match a message, like "Vivid").
pub fn profile_label<'a>(id: &str, name: &'a str) -> &'a str {
builtin_label(name, lightcraft_engine::presets::profile(id).is_some())
}
/// An Activity (history) step for display. The step's stored label stays English; generated steps
/// are translated around the names they carry, and a user preset's name is kept verbatim.
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
@ -216,6 +224,15 @@ pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcra
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
}
/// What the grid is titled for the session's source: [`source_label`], or a library folder's last
/// two names (`photos/travel`; the folder's name is the user's and stays verbatim).
pub fn source_title(session: &lightcraft_engine::Session) -> String {
match (session.source, session.library_folder.as_deref()) {
(lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path),
(source, _) => source_label(source, &session.catalog),
}
}
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
/// catalog's own wording; every other language formats it with its `*-formats.json` date
@ -272,6 +289,68 @@ pub fn display_time(iso: &str) -> String {
}
}
/// Built-in colour names are translated; custom label names are user data.
pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String {
catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string)
}
/// A legacy smart-album filter summary, using the same display labels as filter chips.
pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
if language() == Locale::En {
filter.describe()
} else {
lightcraft_engine::filter_chips(filter, catalog)
.iter()
.map(|chip| crate::panels::chips::display_label(chip, filter, catalog))
.collect::<Vec<_>>()
.join(" · ")
}
}
/// A rule summary for display, keeping free-text rule values verbatim.
pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
fn describe(rules: &lightcraft_catalog::RuleSet, depth: usize) -> String {
use lightcraft_catalog::{
Match, Rule,
rules::{FIELDS, Kind, ops_for},
};
if depth >= 16 {
return tr("Nested group").to_string();
}
let parts: Vec<String> = rules
.rules
.iter()
.map(|rule| match rule {
Rule::Group { group } => format!("({})", describe(group, depth + 1)),
Rule::Field { field, op, value } => {
let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() };
let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1);
let text = match kind {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
_ if matches!(op.as_str(), "inLast" | "notInLast") => format!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days"))
),
_ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }),
};
format!("{} {} {text}", tr(label), tr(operator)).trim().to_string()
}
})
.collect();
let join = match rules.mode {
Match::All => tr("and"),
Match::Any | Match::None => tr("or"),
};
let text = parts.join(&format!(" {join} "));
if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text }
}
// Preserve the established source-language summary.
if language() == Locale::En { rules.describe() } else { describe(rules, 0) }
}
#[cfg(test)]
mod tests {
use super::*;
@ -344,7 +423,13 @@ mod tests {
assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("de"), None);
for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}");
}
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
#[test]
@ -466,6 +551,8 @@ mod tests {
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
("app.language.portuguese", Locale::PtBr),
("app.language.german", Locale::De),
("app.language.russian", Locale::Ru),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@ -500,6 +587,168 @@ mod tests {
}
}
/// Every command, control, rule and rename label, and every line of What's New, is a message a
/// catalog can translate. What a language lacks is reported, not failed (like
/// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every
/// language, and the translations catch up at their own pace.
#[test]
fn display_label_gaps_are_reported() {
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
let ui_labels = labels.len();
for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) {
if line.is_empty() || line.starts_with("# ") {
continue;
}
let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line);
labels.push(label);
}
for language in Locale::ALL.iter().filter(|language| **language != Locale::En) {
let catalog = language.catalog();
let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect();
let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count();
if !missing.is_empty() || notes > 0 {
eprintln!(
"{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)",
language.code(),
missing.len()
);
}
}
// the German catalog, which set out to cover them all, still covers every other catalog
let german = Locale::De.catalog();
let gaps: Vec<&String> =
Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect();
if !gaps.is_empty() {
eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len());
}
}
#[test]
fn german_switches_via_menu_and_control_persists_and_formats_dates() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.german", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::De);
assert_eq!(tr("File"), "Datei");
assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true));
assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026");
assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20.");
assert_eq!(date_group_label("2026-09", false), "09/2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05");
assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert");
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"de""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De);
let ctx = egui::Context::default();
for code in ["de-DE", "de_AT.UTF-8", "de-CH"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::De);
}
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
let rules = lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::Match::All,
rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }],
};
assert_eq!(rules_label(&rules), "Stichwörter enthält Color");
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
};
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau");
app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap();
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau");
set_language(Locale::En);
}
#[test]
fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}");
app.ui.view = crate::state::ViewMode::People;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Benannte Personen"), "{text}");
for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ÄÖÜäöüß„“".chars() {
assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}");
}
}
});
set_language(Locale::En);
}
#[test]
fn german_buttons_fit_the_bottom_bar_and_export_dialog() {
fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(shape) => {
bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size())))
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)),
_ => {}
}
}
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.language = Locale::De;
let mut bounds = Vec::new();
for export in [false, true] {
if export {
app.run("dialog.export", serde_json::json!({})).unwrap();
}
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
bounds.clear();
for shape in out.shapes {
collect(&shape.shape, &mut bounds);
}
}
let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1;
if export {
let dialog = rect("dialog:window");
for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] {
assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id));
}
} else {
let button = rect("button:copySettings");
let (_, text) = bounds.iter().find(|entry| entry.0 == "Bearbeitungseinstellungen kopieren").unwrap();
assert!(button.contains_rect(*text), "German copy caption overflows its button: {text:?} vs {button:?}");
assert!(!button.intersects(rect("icon:copyGear")));
}
}
set_language(Locale::En);
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {

View File

@ -1,11 +1,11 @@
//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device):
//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
//! place / copy or move into the library's `Originals/` or a chosen folder, filed by day, by month,
//! into one folder or by a custom folder template, optionally renamed), an album
//! (existing or new), a preset and keywords to apply. Importing runs on a worker thread (files are
//! probed, copied or moved there) and its batches join the catalog between frames, with a progress
//! window and Cancel; the whole import is one undo step.
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked; Shift-click sets a
//! range), the destination (add in place / copy or move into the library's `Originals/` or a
//! chosen folder, filed by day, by month, into one folder or by a custom folder template,
//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing
//! runs on a worker thread (files are probed, copied or moved there) and its batches join the
//! catalog between frames, with a progress window and Cancel; the whole import is one undo step.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
@ -21,6 +21,31 @@ use crate::widgets::register;
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
pub(crate) const BATCH: usize = 8;
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last
/// frame. Their difference is the room the grid leaves below it.
const GRID_BOTTOM: &str = "import-grid-bottom";
const CONTENT_BOTTOM: &str = "import-content-bottom";
/// Room below the grid before it has been measured (more than the options and buttons take).
const DEFAULT_BELOW_GRID: f32 = 360.0;
/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free,
/// in whole points so the window never grows by a fraction each frame, and at least one `row`.
pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 {
let h = (available - below.max(0.0)).floor();
if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) }
}
/// Remember where the review dialog's content ends this frame (after its buttons), for
/// [`grid_height`] on the next.
pub(crate) fn note_dialog_bottom(ui: &egui::Ui) {
if !ui.is_sizing_pass() {
let y = ui.min_rect().bottom();
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(CONTENT_BOTTOM), y));
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ImportDialog {
@ -53,6 +78,13 @@ pub struct ImportDialog {
pub metadata_preset: String,
/// Copy: raws are copied as DNG.
pub dng: bool,
/// Per candidate: the library already has it in Recently Deleted.
pub trashed: Vec<bool>,
/// What to do with those: "" (leave them), `restore` or `fresh` (the `onDeleted` param).
pub on_deleted: String,
/// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]).
#[serde(skip)]
pub last_clicked: Option<usize>,
}
impl ImportDialog {
@ -61,14 +93,51 @@ impl ImportDialog {
ImportDialog { candidates, checked, ..Default::default() }
}
pub fn importable(&self, i: usize) -> bool {
self.candidates.get(i).is_some_and(|c| c.duplicate.is_none() && c.error.is_none())
self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty()))
}
/// Candidate `i` is a file whose photo is in Recently Deleted.
pub fn is_trashed(&self, i: usize) -> bool {
self.trashed.get(i).copied().unwrap_or(false)
}
/// Choose what happens to the files in Recently Deleted (`""`, `restore` or `fresh`); they are
/// checked once there is a choice and unchecked again without one.
pub fn set_on_deleted(&mut self, choice: &str) {
// switching between restore and fresh keeps the user's per-cell choices
let changed = self.on_deleted.is_empty() != choice.is_empty();
self.on_deleted = choice.to_string();
for i in (0..self.candidates.len()).filter(|_| changed) {
if self.is_trashed(i)
&& let Some(c) = self.checked.get_mut(i)
{
*c = !choice.is_empty();
}
}
}
/// A click on candidate `i` toggles its checkbox. With `shift`, every importable candidate
/// from the last clicked one to `i` takes `i`'s new state, as in a file manager: click the
/// first, Shift-click the last, and the whole range is checked (or unchecked).
pub fn click(&mut self, i: usize, shift: bool) {
if !self.importable(i) {
return;
}
let Some(on) = self.checked.get(i).map(|c| !c) else { return };
let from = if shift { self.last_clicked.filter(|a| *a < self.checked.len()).unwrap_or(i) } else { i };
for k in from.min(i)..=from.max(i) {
if self.importable(k)
&& let Some(c) = self.checked.get_mut(k)
{
*c = on;
}
}
self.last_clicked = Some(i);
}
pub fn selected_paths(&self) -> Vec<String> {
self.candidates
.iter()
.zip(&self.checked)
.filter(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none())
.map(|(c, _)| c.path.clone())
.enumerate()
.filter(|(i, (_, on))| **on && self.importable(*i))
.map(|(_, (c, _))| c.path.clone())
.collect()
}
/// The `organize` param of `library.import` (`None` = the default, by day); an unusable
@ -104,6 +173,8 @@ pub struct ImportTask {
pub done: usize,
params: Value,
pub imported: usize,
/// Brought back from Recently Deleted (`onDeleted: restore`).
pub restored: usize,
pub duplicates: usize,
/// The library photos the skipped duplicates match (shown when nothing new was added).
existing: Vec<u64>,
@ -373,7 +444,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.scan = None;
app.toast(ctx, "Scan failed");
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
@ -395,11 +466,19 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
if out.candidates.is_empty() {
app.toast(ctx, "No photos found");
app.toast(ctx, crate::i18n::tr("No photos found"));
return;
}
app.renderer.forget_imports();
let mut d = ImportDialog::new(out.candidates);
d.trashed = d
.candidates
.iter()
.map(|c| {
c.duplicate.is_some()
&& c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted))
})
.collect();
d.copy = task.copy;
d.sources = task.sources;
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
@ -461,6 +540,9 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
(true, true) => "move",
};
let mut params = json!({"mode": mode, "keywords": d.keywords()});
if !d.on_deleted.is_empty() {
params["onDeleted"] = json!(d.on_deleted);
}
if let Some(a) = album {
params["album"] = json!(a);
}
@ -520,7 +602,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
Ok(run) => task.run = Some(run),
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, format!("Import failed: {e}"));
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
return;
}
}
@ -564,6 +646,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
}
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
task.imported += len("imported");
task.restored += len("restored");
task.duplicates += len("duplicates");
task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
task.failed += len("failed");
@ -573,7 +656,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or(""));
}
if task.first.is_none() {
task.first = v["imported"].as_array().and_then(|a| a.first()).and_then(Value::as_u64);
task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64));
}
}
Err(e) => {
@ -586,11 +669,18 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
/// The import is done (or cancelled): one undo step, select the first photo, say what happened.
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
let steps = app.session.undo.len().saturating_sub(task.undo0);
let label = crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
let plural = |n: usize| if n == 1 { "" } else { "s" };
let label = if task.imported == 0 && task.restored > 0 {
crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored))
} else if task.restored > 0 {
crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored)
} else {
crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" })
};
app.session.merge_undo(steps, &label);
if task.auto {
if task.imported > 0 {
app.toast(ctx, format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
}
return;
}
@ -606,7 +696,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
let plural = |n: usize| if n == 1 { "" } else { "s" };
// nothing new, only files the library already has: show where they are (Recently Deleted is
// easy to miss, and the side panel that lists it starts collapsed)
if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
if task.imported == 0 && task.restored == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
return;
}
let mut msg = if task.cancelled {
@ -614,10 +704,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
} else {
crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))
};
if task.restored > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));
}
if task.params["mode"] == "move" {
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
if task.kept > 0 {
msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
}
}
if task.duplicates > 0 {
@ -731,18 +824,29 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let avail = ui.available_width();
let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize;
let rows = n.div_ceil(cols);
egui::ScrollArea::vertical().id_salt("import-grid").max_height(330.0).auto_shrink([false, true]).show_viewport(ui, |ui, viewport| {
let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover());
for i in 0..n {
let (c, r) = (i % cols, i / cols);
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
if !local.intersects(viewport.expand(cell)) {
continue;
}
let rect = local.translate(area.min.to_vec2());
candidate_cell(app, ui, d, i, rect);
}
// as tall as the window leaves after the options and buttons below it (measured last frame),
// so resizing the dialog resizes the grid
let below = ui.ctx().data(|m| {
let y = |key: &str| m.get_temp::<f32>(egui::Id::new(key));
y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid)
});
let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0);
let grid =
egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover());
for i in 0..n {
let (c, r) = (i % cols, i / cols);
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
if !local.intersects(viewport.expand(cell)) {
continue;
}
let rect = local.translate(area.min.to_vec2());
candidate_cell(app, ui, d, i, rect);
}
});
if !ui.is_sizing_pass() {
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom()));
}
ui.add_space(4.0);
// options
if !d.sources.is_empty() {
@ -750,9 +854,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
// (whether each source is a folder is checked off the UI thread, once)
let joined = d.sources.join("\n");
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
source_summary(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
inspect_sources(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
})
.unwrap_or_else(|| format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
.map(|summary| summary.display())
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
register(ui.ctx(), "label:importSource", r.rect);
});
@ -788,7 +893,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
let r = ui.label(
egui::RichText::new(match (d.copy, d.move_files) {
egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) {
(true, false) => {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
}
@ -796,7 +901,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
"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."
}
_ => "Add in place: the library references the files where they are; nothing is copied or moved.",
})
}))
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
.small(),
);
@ -804,7 +909,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if d.copy {
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
@ -825,9 +930,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (k, label) in opts {
let r = ui.selectable_label(key == k, label);
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
@ -843,7 +948,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let folders_id = egui::Id::new("import-folder-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
@ -875,8 +980,13 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let rename_id = egui::Id::new("import-rename");
let tags_open = field(ui, "Rename", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w));
let w = tag_field_width(ui);
let r = ui.add(
egui::TextEdit::singleline(&mut d.rename)
.id(rename_id)
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
.desired_width(w),
);
register(ui.ctx(), "field:importRename", r.rect);
tag_toggle(ui, "importRename")
});
@ -895,6 +1005,32 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
);
}
}
if d.trashed.iter().any(|t| *t) {
field(ui, "In Recently Deleted", |ui| {
let cur = match d.on_deleted.as_str() {
"restore" => "Restore them",
"fresh" => "Import as new",
_ => "Leave them",
};
let mut pick = None;
egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (v, label) in [("", "Leave them"), ("restore", "Restore them"), ("fresh", "Import as new")] {
if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() {
pick = Some(v);
}
}
});
if let Some(v) = pick {
d.set_on_deleted(v);
}
let t = Tokens::get(ui.ctx());
ui.label(
egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first."))
.color(t.text_dim)
.small(),
);
});
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
@ -904,8 +1040,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => "New album".into(),
None => "None".into(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
@ -915,7 +1051,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
d.album = None;
if d.new_album.is_empty() {
d.new_album = "Imported Photos".into();
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
@ -931,13 +1067,25 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
field(ui, "Preset", |ui| {
let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into());
let cur = app
.session
.presets
.iter()
.find(|p| p.id == d.preset)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr("None").into());
egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.preset.clear();
}
for p in &app.session.presets {
if ui.selectable_label(d.preset == p.id, format!("{} — {}", p.group, p.name)).clicked() {
if ui
.selectable_label(
d.preset == p.id,
format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)),
)
.clicked()
{
d.preset = p.id.clone();
}
}
@ -945,7 +1093,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.metadata_preset.clear();
@ -1000,6 +1148,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
}
let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted));
let badge = match (&c.duplicate, &c.error) {
(Some(_), _) if in_trash && ok => Some(if d.on_deleted == "restore" { "Will be restored" } else { "Will be replaced" }),
(Some(_), _) if in_trash => Some("In Recently Deleted"),
(Some(r), _) if r == "path" => Some("In library"),
(Some(_), _) => Some("Duplicate"),
@ -1025,10 +1174,18 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
);
let resp = resp.on_hover_text(tip);
if resp.clicked() && ok {
d.checked[i] = !d.checked[i];
let shift = ui.input(|input| input.modifiers.shift);
d.click(i, shift);
}
}
/// Reserve the actual translated tag button width beside a template field.
pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text);
(ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0)
}
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
/// ([`tag_help`]) is open.
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
@ -1048,7 +1205,12 @@ pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
let unknown = lightcraft_engine::rename::unknown_tokens(template);
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" });
let text = crate::i18n::tr_format!(
"Unknown tag{s} {} stay{} as typed (see Tags)",
unknown.join(" "),
if unknown.len() == 1 { "s" } else { "" },
s = s
);
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
}
}
@ -1090,22 +1252,22 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
if tok.aliases.is_empty() {
"Insert at the cursor".to_string()
} else {
format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
},
);
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
if r.clicked() {
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
}
ui.label(egui::RichText::new(tok.meaning).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label));
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
ui.end_row();
}
});
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect();
ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect();
ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
for n in TEMPLATE_NOTES {
ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small());
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small());
}
});
});
@ -1117,7 +1279,7 @@ pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
/// Where the first selected photo would be copied to (destination, folders, name), for the
/// dialog's example line; `None` without a photo or with an unusable folder template.
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
let c = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none()).map(|(c, _)| c)?;
let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?;
let organize = match d.organize_param().ok()? {
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
None => Default::default(),
@ -1152,17 +1314,41 @@ pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<Stri
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
inspect_sources(sources).display()
}
/// Facts cached by the filesystem worker, formatted in the UI thread's active language.
/// Caching a translated String on the worker would always use its default language (English)
/// and also retain that language after the user changes the UI language.
#[derive(Clone)]
enum SourceSummary {
Folder(String),
File(String),
Many { files: usize, folders: usize },
}
impl SourceSummary {
fn display(&self) -> String {
match self {
Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name),
Self::File(name) => name.clone(),
Self::Many { files, folders } => match (files, folders) {
(_, 0) => crate::i18n::tr_format!("{} files", files),
(0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
(_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f),
},
}
}
}
fn inspect_sources(sources: &[String]) -> SourceSummary {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => format!("Folder “{}” (and its subfolders)", name(one)),
[one] => name(one),
[one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)),
[one] => SourceSummary::File(name(one)),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => crate::i18n::tr_format!("{} files", many.len()),
f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f),
}
SourceSummary::Many { files: many.len().saturating_sub(folders), folders }
}
}
}
@ -1173,9 +1359,72 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(78.0);
ui.label(egui::RichText::new(label).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})
.inner
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grid_height_follows_the_window() {
// the grid takes what the options and buttons leave, in whole points
assert_eq!(grid_height(700.0, 300.0, 142.0), 400.0);
assert_eq!(grid_height(1000.5, 300.0, 142.0), 700.0);
// never less than one row of thumbnails
assert_eq!(grid_height(200.0, 300.0, 142.0), 142.0);
// a bad measurement doesn't take the grid away or make it endless
assert_eq!(grid_height(700.0, -50.0, 142.0), 700.0);
assert_eq!(grid_height(700.0, f32::NAN, 142.0), 700.0);
assert_eq!(grid_height(f32::INFINITY, 300.0, 142.0), DIALOG_SIZE[1] - DEFAULT_BELOW_GRID);
}
/// Six candidates, all checked; candidate 3 is a duplicate (unchecked, not importable).
fn dialog() -> ImportDialog {
let mut candidates: Vec<ImportCandidate> = (0..6).map(|i| ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect();
candidates[3].duplicate = Some("content".into());
ImportDialog::new(candidates)
}
#[test]
fn click_toggles_one_candidate() {
let mut d = dialog();
d.click(1, false);
assert_eq!(d.checked, [true, false, true, false, true, true]);
d.click(1, false);
assert_eq!(d.checked, [true, true, true, false, true, true]);
d.click(3, false);
assert_eq!(d.checked, [true, true, true, false, true, true], "a duplicate can't be checked");
}
#[test]
fn shift_click_sets_the_range_to_the_clicked_state() {
let mut d = dialog();
d.click(1, false);
d.click(4, true);
assert_eq!(d.checked, [true, false, false, false, false, true], "1..=4 unchecked, the duplicate left alone");
assert_eq!(d.selected_paths(), ["img0.jpg", "img5.jpg"]);
// backwards from the new anchor (4) re-checks 2..=4
d.click(2, true);
assert_eq!(d.checked, [true, false, true, false, true, true]);
}
#[test]
fn shift_click_without_an_earlier_click_toggles_one() {
let mut d = dialog();
d.click(2, true);
assert_eq!(d.checked, [true, true, false, false, true, true]);
}
#[test]
fn shift_click_with_a_stale_anchor_toggles_one() {
let mut d = dialog();
d.last_clicked = Some(99);
d.click(2, true);
assert_eq!(d.checked, [true, true, false, false, true, true]);
}
}

View File

@ -29,8 +29,12 @@ pub mod widgets;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_labels;
#[cfg(test)]
mod tests_library_problem;
#[cfg(test)]
mod tests_masking;
@ -55,6 +59,8 @@ use serde_json::Value;
pub use control::{ControlRequest, ControlResponse};
pub use state::UiState;
const TOAST_SECONDS: f64 = 1.4;
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
@ -268,6 +274,20 @@ impl LightcraftApp {
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
if r.is_ok() && id == "photo.label" {
let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse);
let text = match label {
Some(label) => {
let name =
self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into());
i18n::tr_format!("{name} Label", name = name)
}
None => i18n::tr("Color label cleared").into(),
};
// Native menu clicks can arrive before logic() updates last_time after an idle gap.
let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time);
self.ui.toast = Some((text, now + TOAST_SECONDS, label));
}
if let Err(e) = &r {
log::warn!("{id}: {e}");
self.ui.status = e.clone();
@ -312,11 +332,13 @@ impl LightcraftApp {
let plural = if done == 1 { "" } else { "s" };
let mut msg = match (b.error(), b.what) {
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
(None, "") => format!("Previews ready for {done} photo{plural}"),
(None, what) => format!("Done ({what}): {done} photo{plural}"),
(None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural),
(None, what) => {
crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what))
}
};
if failed > 0 {
msg.push_str(&format!(" · {failed} couldn't be rendered"));
msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed));
}
self.toast(ctx, msg);
}
@ -324,8 +346,8 @@ impl LightcraftApp {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
let msg = match b.what {
"" => format!("Building previews for {} photos…", b.total),
what => format!("Working on {what} for {} photos…", b.total),
"" => crate::i18n::tr_format!("Building previews for {} photos…", b.total),
what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)),
};
self.toast(ctx, msg);
}
@ -341,8 +363,16 @@ impl LightcraftApp {
(Some((n, e)), false) => {
self.ui.unsaved_seen = true;
let t = ctx.input(|i| i.time);
let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") };
self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0));
self.ui.toast = Some((
crate::i18n::tr_format!(
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
if n == 1 { "" } else { "s" },
n = n,
e = e
),
t + 6.0,
None,
));
}
(None, true) => {
self.ui.unsaved_seen = false;
@ -354,13 +384,13 @@ impl LightcraftApp {
}
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
self.toast_for(ctx, text, 1.4);
self.toast_for(ctx, text, TOAST_SECONDS);
}
/// A toast that stays `secs` seconds (messages that say where to look or what to do next).
pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into<String>, secs: f64) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + secs));
self.ui.toast = Some((text.into(), t + secs, None));
}
/// AI masks: apply finished background requests (clicks, descriptions, detail passes) and
@ -395,7 +425,7 @@ impl LightcraftApp {
(Some(e), false) if e.contains("cancelled") => {
self.toast(ctx, crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time."))
}
(Some(e), false) => self.toast_error(ctx, format!("The SAM 3 download failed: {e}")),
(Some(e), false) => self.toast_error(ctx, crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e)),
(None, false) if download.finished => {
self.toast_error(ctx, crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready."))
}
@ -438,7 +468,7 @@ impl LightcraftApp {
/// A toast for an error the user has to read and act on (stays 6 s).
pub fn toast_error(&mut self, ctx: &egui::Context, text: impl Into<String>) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 6.0));
self.ui.toast = Some((text.into(), t + 6.0, None));
}
fn drain_control(&mut self, ctx: &egui::Context) {
@ -890,6 +920,7 @@ mod drop_tests {
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
counts: Option<(u64, LibraryCounts)>,
@ -926,6 +957,17 @@ pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
}
impl Caches {
/// The folders the library's photos were imported from.
pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> {
match &self.folder_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.folder_tree());
self.folder_tree = Some((cat.revision, t.clone()));
t
}
}
}
/// The library's keyword tree.
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
match &self.keyword_tree {

Some files were not shown because too many files have changed in this diff Show More