Merge remote-tracking branch 'origin/main' into claude/fix-316-section-eye
# Conflicts: # crates/ui-egui/src/headless.rs
This commit is contained in:
commit
57dc811a6a
|
|
@ -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",
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -288,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
|||
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
|
||||
older-Canon raws on one machine.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic and Fujifilm raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **CR3 and compressed Olympus raws:** these open as embedded previews only. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
|
|
@ -302,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
|||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
|
|
@ -312,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
|||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
|
|
@ -353,9 +353,22 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
|||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
|
||||
errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
|
||||
the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a
|
||||
directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
|
||||
too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`.
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
|
|
|
|||
49
ROADMAP.md
49
ROADMAP.md
|
|
@ -6,11 +6,11 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
|||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
|
||||
|
|
@ -22,14 +22,14 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2 and RAF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
|
|
@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
|
||||
| Canon CR3 / Olympus compressed-raw shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
|
||||
|
||||
|
|
@ -46,9 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
|
|
@ -67,6 +68,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
|
|||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
|
@ -84,24 +89,24 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
|||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
| P0 (core) | **98.2%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.6%** | 149 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
|
||||
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
|
|
@ -118,7 +123,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
|||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
|
|
@ -167,8 +172,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1
|
|||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
|
||||
|
|
@ -179,9 +184,9 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
|||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2 and RAF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
|
@ -190,6 +195,8 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
|||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
|
||||
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<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(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
|
||||
let mut sink = match self.sink.try_lock() {
|
||||
Ok(sink) => sink,
|
||||
Err(TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
Err(TryLockError::WouldBlock) => return,
|
||||
};
|
||||
sink.write(&line);
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed
|
||||
/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"))));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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, ¶ms)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,450 @@
|
|||
//! Exploring the library by folder: `library.folders` lists where the imported photos live and
|
||||
//! `library.filter {libraryFolder}` shows one folder's photos (see `lightcraft_catalog::folders`);
|
||||
//! `library.removeFolder` takes a folder's photos out of the library.
|
||||
//!
|
||||
//! * Given photos imported from two folders, an agent can list them with counts, then choose one
|
||||
//! and see exactly its photos; the filter shows as a chip that clears just that choice.
|
||||
//! * A path that is not a library folder shows nothing rather than everything.
|
||||
//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and
|
||||
//! leaves every file where it is.
|
||||
//! * Renaming or moving a folder on disk keeps the chosen folder chosen.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LibrarySource, Session, filter_chips};
|
||||
|
||||
fn source_folder(s: &mut Session, path: &str) -> serde_json::Value {
|
||||
s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, path: &str) {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-folders-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for path in ["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"] {
|
||||
add(&mut s, path);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_command_lists_where_the_photos_were_imported_from() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!((r[0]["name"].as_str(), r[0]["volume"].as_bool(), r[0]["count"].as_u64()), (Some("/"), Some(true), Some(3)));
|
||||
let pics = &r[0]["children"][0];
|
||||
assert_eq!(pics["name"], "pics");
|
||||
let kids: Vec<(&str, u64)> =
|
||||
pics["children"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["count"].as_u64().unwrap())).collect();
|
||||
assert_eq!(kids, [("home", 1), ("trip", 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
let chips = filter_chips(&s.filter, &s.catalog);
|
||||
assert_eq!(chips.len(), 1);
|
||||
assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart");
|
||||
s.execute("library.filter", &chips[0].clear).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "clearing the chip shows everything again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_is_not_a_library_folder_shows_nothing() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/elsewhere"})).unwrap();
|
||||
assert_eq!(r["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_back() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics//trip/"})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
assert_eq!(s.visible().len(), 1, "only /pics/home is left");
|
||||
let tree = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!(tree[0]["count"], 1);
|
||||
s.source = LibrarySource::RecentlyDeleted;
|
||||
assert_eq!(s.visible().len(), 2, "they wait in Recently Deleted");
|
||||
s.source = LibrarySource::All;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/elsewhere"})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/"})).is_err(), "choose a folder, not everything");
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": " "})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({})).is_err());
|
||||
assert_eq!(s.visible().len(), 3, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_leaves_browsed_and_already_deleted_photos_alone() {
|
||||
let mut s = session();
|
||||
for (path, local, deleted) in [("/pics/trip/browsed.jpg", true, false), ("/pics/trip/gone.jpg", false, true)] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
(p.local, p.deleted) = (local, deleted);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "only the two library photos of the folder");
|
||||
let state: Vec<(bool, bool)> = s.catalog.photos().filter(|p| p.local || p.deleted).map(|p| (p.local, p.deleted)).collect();
|
||||
assert!(state.contains(&(true, false)) && state.contains(&(false, true)), "browsed stays browsed, the old deletion stays: {state:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone");
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/pics"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_removes_nothing() {
|
||||
let mut s = session();
|
||||
for path in [".", "./", "a/..", "../..", "x/../.."] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_goes_only_when_asked_for_by_name_and_never_the_startup_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/nas/2.jpg", "/Users/me/3.jpg", r"C:\x\4.jpg", r"\\srv\share\5.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/Volumes/nas", "C:", "C:\\", r"\\?\C:\", "C:\\..", r"\\srv\share"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?} is a whole disk");
|
||||
}
|
||||
for path in ["/", "//"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path, "disk": true})).is_err(), "{path:?}: never the startup disk");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 5, "nothing changed");
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes/nas", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "everything on the nas, nothing elsewhere");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/tokyo/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take two disks' photos at once");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_filter_takes_only_a_text_path() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err());
|
||||
assert_eq!(s.filter.library_folder, None, "refused, nothing changed");
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "a blank path is no choice");
|
||||
s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, renamed) = (dir.path("trip"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{trip}/day1/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
|
||||
assert_eq!(s.visible().len(), 2, "and still shows its photos");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
|
||||
let dir = Scratch::new("move");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("archive")).unwrap();
|
||||
let (day1, moved) = (dir.path("trip/day1"), dir.path("archive/trip/day1"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_a_folder_reads_both_spellings_the_same_way() {
|
||||
// the choice was typed with a `..` in it; the folder that moved was not
|
||||
let mut s = Session::new();
|
||||
s.filter.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
|
||||
s.filter.library_folder = Some("/elsewhere/b".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
|
||||
let mut s = session();
|
||||
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "no picks");
|
||||
let r = source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip")));
|
||||
assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip");
|
||||
let state = s.execute("library.state", &json!({})).unwrap();
|
||||
assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder");
|
||||
// the filter bar narrows it further, as it does an album
|
||||
s.execute("library.filter", &json!({"rating": 1})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0);
|
||||
s.execute("library.clearFilter", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "back to everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_source_takes_a_library_folder_path_only() {
|
||||
let mut s = session();
|
||||
for params in [
|
||||
json!({"kind": "libraryFolder"}),
|
||||
json!({"kind": "libraryFolder", "path": 5}),
|
||||
json!({"kind": "libraryFolder", "path": " "}),
|
||||
json!({"kind": "libraryFolder", "path": "."}),
|
||||
] {
|
||||
assert!(s.execute("library.source", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/elsewhere"})).is_err(), "no photo was imported from it");
|
||||
assert_eq!(s.source, LibrarySource::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_being_shown_returns_to_all_photos() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
// removing another folder leaves the source alone
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/home");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.source, LibrarySource::LibraryFolder);
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("source-rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
source_folder(&mut s, &day1);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_one_folder_to_another_changes_what_is_shown() {
|
||||
let mut s = session();
|
||||
assert_eq!(source_folder(&mut s, "/pics/trip")["count"], 2);
|
||||
assert_eq!(s.source_total(), Some(2));
|
||||
assert_eq!(source_folder(&mut s, "/pics/home")["count"], 1, "same source, another folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
assert_eq!(s.source_total(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smart_album_made_from_a_folder_view_keeps_the_folder() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "the album matches the folder's photos, not the whole library: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_and_a_folder_filter_never_disagree() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over");
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_ok(), "fine while no folder is the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_with_no_folder_falls_back_to_all_photos() {
|
||||
let mut s = session();
|
||||
s.source = LibrarySource::LibraryFolder;
|
||||
s.library_folder = None;
|
||||
assert_eq!(s.visible().len(), 3, "everything, not an empty grid under no name");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.source_total(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_that_shows_exactly_its_own_photos_can_be_the_source() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/", "//", "/elsewhere", "/Volumes"] {
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert_eq!(source_folder(&mut s, "/Volumes/nas")["count"], 1, "a disk is a fine source");
|
||||
assert_eq!(source_folder(&mut s, "/Users/me")["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_above_a_disk_needs_asking_by_name_even_with_one_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take the whole nas");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
assert_eq!(s.execute("library.removeFolder", &json!({"path": "/Volumes/nas/a"})).unwrap()["removed"], 1, "a folder on the disk is just a folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_never_mistaken_for_its_neighbour() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert_eq!(source_folder(&mut s, "/p/shoot ")["count"], 1);
|
||||
let shown: Vec<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");
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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"))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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}标签"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "其中文件夹里的照片也包括在内。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ",{}標籤"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "其中資料夾裡的照片也包含在內。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms).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
Loading…
Reference in New Issue