Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41e67e1edd | ||
|
|
5d437489b9 | ||
|
|
1e6f0e28e1 | ||
|
|
4af709995f | ||
|
|
9b0d0fd05f | ||
|
|
b1d8ba4284 | ||
|
|
d8efa6a147 | ||
|
|
d4c0a7a3d4 | ||
|
|
68e0827633 | ||
|
|
cad024fbff | ||
|
|
86deb5b004 | ||
|
|
a673b2bb7a | ||
|
|
305ff71474 | ||
|
|
a0e49629d3 | ||
|
|
c8f697a128 | ||
|
|
232cbace63 | ||
|
|
efe7cad73c | ||
|
|
a86defd1b9 | ||
|
|
43aa795539 | ||
|
|
38cdd49274 | ||
|
|
0b587a0a8a | ||
|
|
f16f413942 | ||
|
|
48cd9c5e3a | ||
|
|
e2b626b7f7 | ||
|
|
28db7b200b | ||
|
|
3f63db0a0b | ||
|
|
f611e76d0e | ||
|
|
095c7dce87 | ||
|
|
f96f732baa | ||
|
|
b4a1166f4b | ||
|
|
5413c8bb3a | ||
|
|
07d9678209 | ||
|
|
3c76902bbb | ||
|
|
7cf3719c63 | ||
|
|
d16ab1f238 | ||
|
|
bbcfdf085b | ||
|
|
1e6035d9db | ||
|
|
37cfc46e0d | ||
|
|
b256b8b635 | ||
|
|
e8c817ddea | ||
|
|
d367d83ee6 | ||
|
|
6a05f865ee | ||
|
|
866d02e577 | ||
|
|
4a427894ed | ||
|
|
e2d35de867 |
No files matched your search
@@ -258,7 +258,7 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
@@ -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:** 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);
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 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;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
@@ -304,7 +304,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| 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 | ✅ 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 + 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 ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), 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 ⬜ |
|
||||
|
||||
+23
-10
@@ -23,8 +23,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **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) |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), 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, RAF and CR3 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 ⬜ |
|
||||
@@ -38,7 +38,7 @@ 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 |
|
||||
| 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) |
|
||||
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
|
||||
| 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 |
|
||||
|
||||
@@ -49,7 +49,7 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
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, 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 ORF, NEF
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, 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
|
||||
@@ -89,7 +89,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
|
||||
| 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 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 ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, 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 | ⬜ |
|
||||
@@ -170,27 +170,39 @@ The milestone estimates in the table above were made before work started and are
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
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 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
|
||||
it can't decode yet.
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
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**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**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
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 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.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 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.
|
||||
@@ -205,6 +217,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
|
||||
@@ -167,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"# comment\n{}\n{}\n{}\n{}\n",
|
||||
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
// (settled: under load a grid shot taken before its thumbnails is darker than the
|
||||
// dimmed export dialog over them, and the comparison below fails)
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
|
||||
),
|
||||
)
|
||||
@@ -181,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.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(), 4);
|
||||
assert_eq!(replies.len(), 6);
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
for (p, dimmed) in [(&a, false), (&b, true)] {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
|
||||
|
||||
@@ -207,6 +207,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! File-dialog filters that match extensions whatever their letter case.
|
||||
//!
|
||||
//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the
|
||||
//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so
|
||||
//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every
|
||||
//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks
|
||||
//! that nothing else calls `add_filter`), so no extension can hit this.
|
||||
|
||||
use rfd::FileDialog;
|
||||
|
||||
/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot.
|
||||
///
|
||||
/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated.
|
||||
/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match
|
||||
/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the
|
||||
/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket
|
||||
/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...).
|
||||
pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |s: String| {
|
||||
if !s.is_empty() && !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
for ext in exts {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
let lower = ext.to_lowercase();
|
||||
push(lower.clone());
|
||||
if !linux {
|
||||
continue;
|
||||
}
|
||||
push(ext.to_uppercase());
|
||||
// `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone
|
||||
if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) {
|
||||
push(
|
||||
lower
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let up: String = c.to_uppercase().collect();
|
||||
if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`dialog_extensions_for`] for the platform this is built for.
|
||||
pub fn dialog_extensions(exts: &[&str]) -> Vec<String> {
|
||||
dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos"))))
|
||||
}
|
||||
|
||||
/// `add_filter` that matches the extensions in any letter case.
|
||||
pub trait FileDialogExt {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self;
|
||||
}
|
||||
|
||||
impl FileDialogExt for FileDialog {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, &dialog_extensions(exts))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn other_platforms_get_lower_case_once() {
|
||||
assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_gets_lower_upper_and_any_mix() {
|
||||
assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]);
|
||||
// digits have no case; a duplicate adds nothing
|
||||
assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]);
|
||||
assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]);
|
||||
assert_eq!(dialog_extensions_for(&["123"], true), ["123"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_glob_syntax_is_not_wrapped_again() {
|
||||
assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]);
|
||||
}
|
||||
|
||||
/// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of
|
||||
/// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes).
|
||||
fn glob_ext(entry: &str, ext: &str) -> bool {
|
||||
let mut pat = entry.chars().peekable();
|
||||
let mut s = ext.chars();
|
||||
while let Some(p) = pat.next() {
|
||||
let Some(c) = s.next() else { return false };
|
||||
if p == '[' {
|
||||
let mut hit = false;
|
||||
for q in pat.by_ref() {
|
||||
if q == ']' {
|
||||
break;
|
||||
}
|
||||
hit |= q == c;
|
||||
}
|
||||
if !hit {
|
||||
return false;
|
||||
}
|
||||
} else if p != c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
s.next().is_none()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_patterns_match_every_case_mix() {
|
||||
let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true);
|
||||
let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name));
|
||||
for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] {
|
||||
assert!(matches(ok), "{ok}");
|
||||
}
|
||||
for bad in ["nex", "ne", "nefs", "jpg", "cr3"] {
|
||||
assert!(!matches(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's own extension list (the one the folder scan uses) is covered whole.
|
||||
#[test]
|
||||
fn import_extensions_are_all_covered_in_both_cases() {
|
||||
let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true);
|
||||
for e in lightcraft_engine::import::EXTENSIONS {
|
||||
assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}");
|
||||
assert!(pats.iter().any(|p| p == e), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog filter goes through the helper: `main.rs` must not call rfd's own
|
||||
/// `add_filter`.
|
||||
#[test]
|
||||
fn no_dialog_bypasses_the_helper() {
|
||||
for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] {
|
||||
assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase");
|
||||
}
|
||||
assert!(include_str!("main.rs").contains(".add_filter_nocase("));
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -24,10 +24,12 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
@@ -281,6 +283,13 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
@@ -335,13 +344,7 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
lightcraft_ui_egui::i18n::tr("Photos"),
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
|
||||
"pef", "psd", "jxl", "gif", "bmp",
|
||||
],
|
||||
)
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -351,7 +354,7 @@ fn services() -> Services {
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter(
|
||||
.add_filter_nocase(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
@@ -364,7 +367,7 @@ fn services() -> Services {
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
@@ -372,7 +375,7 @@ fn services() -> Services {
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -380,7 +383,7 @@ fn services() -> Services {
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -390,7 +393,7 @@ fn services() -> Services {
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct NativeMenu {
|
||||
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
|
||||
structure: String,
|
||||
text_focus: bool,
|
||||
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
|
||||
/// press reaches it.
|
||||
capturing: bool,
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
|
||||
@@ -211,7 +214,7 @@ impl NativeMenu {
|
||||
let _ = tx.send(e.id.0);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
|
||||
m.rebuild(app);
|
||||
app.native_menu = true;
|
||||
m
|
||||
@@ -281,6 +284,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.menu.init_for_nsapp();
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
|
||||
@@ -345,7 +349,8 @@ impl NativeMenu {
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
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);
|
||||
// (none while the keymap editor records a shortcut: every key goes to it)
|
||||
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -407,14 +412,16 @@ impl NativeMenu {
|
||||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
if focus != self.text_focus {
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
if focus != self.text_focus || capturing != self.capturing {
|
||||
self.text_focus = focus;
|
||||
self.capturing = capturing;
|
||||
for it in self.items.values() {
|
||||
let Some(sc) = it.shortcut.as_deref() else { continue };
|
||||
if it.accel.is_none() || !yields_to_text(sc) {
|
||||
if it.accel.is_none() {
|
||||
continue;
|
||||
}
|
||||
let accel = if focus { None } else { it.accel };
|
||||
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
|
||||
let _ = match &it.handle {
|
||||
Handle::Plain(h) => h.set_accelerator(accel),
|
||||
Handle::Check(h) => h.set_accelerator(accel),
|
||||
|
||||
@@ -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, RW2 and RAF readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2, RAF and CR3 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", "RAF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].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`]).
|
||||
@@ -444,6 +444,7 @@ mod edited_tests {
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", true),
|
||||
("a.raf", "rAf", true),
|
||||
("a.cr3", "cr3", 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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||||
//! RW2, Fujifilm RAF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! RW2, Fujifilm RAF, Canon CR3) 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 | RawFormat::Raf)
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
@@ -83,14 +83,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||||
if raw.format == RawFormat::Cr3 {
|
||||
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
|
||||
sensor = sensor.into_oriented(raw.orientation);
|
||||
reference = reference.into_oriented(raw.orientation);
|
||||
sensor.map_in_place(to_working);
|
||||
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
|
||||
// framing from edge directions, independently of the subsequent colour fit.
|
||||
sensor = align_cr3_framing(sensor, &reference);
|
||||
}
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
|
||||
if raw.format != RawFormat::Cr3 {
|
||||
sensor.map_in_place(to_working);
|
||||
}
|
||||
Some((sensor, reference))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cr3Framing {
|
||||
scale: f32,
|
||||
/// Translation as a fraction of the oriented frame's width and height.
|
||||
offset: [f32; 2],
|
||||
}
|
||||
|
||||
impl Cr3Framing {
|
||||
const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
|
||||
|
||||
fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
|
||||
let (w, h) = (w as f32, h as f32);
|
||||
((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
|
||||
/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
|
||||
/// A separate third of the edges must confirm a substantial improvement and a close match.
|
||||
/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
|
||||
fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Cr3Framing> {
|
||||
if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
|
||||
let (w, h) = (sensor.width, sensor.height);
|
||||
if w < 16 || h < 16 {
|
||||
return None;
|
||||
}
|
||||
let reference = fit(reference, w, h, Filter::Box);
|
||||
if (reference.width, reference.height) != (w, h) {
|
||||
return None;
|
||||
}
|
||||
let mut gradients = Rgb32f::new(w, h);
|
||||
let mut edges = Vec::new();
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let derivative = |image: &Rgb32f| {
|
||||
[
|
||||
luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
|
||||
luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
|
||||
]
|
||||
};
|
||||
let d = derivative(&sensor);
|
||||
gradients.data[y * w + x] = [d[0], d[1], 0.0];
|
||||
let d = derivative(&reference);
|
||||
let magnitude = d[0].hypot(d[1]);
|
||||
let value = luminance_2020(reference.data[y * w + x]);
|
||||
if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
|
||||
edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if edges.len() < 192 {
|
||||
return None;
|
||||
}
|
||||
let score = |framing: Cr3Framing, held_out: bool| -> Option<f64> {
|
||||
let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
|
||||
for &(x, y, target, magnitude, holdout) in &edges {
|
||||
if holdout != held_out {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let (sx, sy) = framing.source(x, y, w, h);
|
||||
if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
|
||||
continue;
|
||||
}
|
||||
let sample = gradients.sample_bilinear(sx, sy);
|
||||
let length = sample[0].hypot(sample[1]);
|
||||
if !length.is_finite() || length <= 0.01 {
|
||||
continue;
|
||||
}
|
||||
let k = f64::from(magnitude.min(0.25));
|
||||
agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
|
||||
weight += k;
|
||||
valid += 1;
|
||||
}
|
||||
(valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
|
||||
};
|
||||
let before = score(Cr3Framing::IDENTITY, false)?;
|
||||
let mut best = (before, Cr3Framing::IDENTITY);
|
||||
// At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
|
||||
// local refinement prevents unbounded optimisation on the photograph's contents.
|
||||
for s in -10..=10 {
|
||||
for tx in -4..=4 {
|
||||
for ty in -4..=4 {
|
||||
let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
|
||||
if let Some(value) = score(framing, false)
|
||||
&& value > best.0
|
||||
{
|
||||
best = (value, framing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let coarse = best.1;
|
||||
for s in -5..=5 {
|
||||
for tx in -4..=4 {
|
||||
for ty in -4..=4 {
|
||||
let framing = Cr3Framing {
|
||||
scale: coarse.scale + s as f32 * 0.002,
|
||||
offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
|
||||
};
|
||||
if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = score(framing, false)
|
||||
&& value > best.0
|
||||
{
|
||||
best = (value, framing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
|
||||
if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
|
||||
return None;
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
|
||||
}
|
||||
Some(best.1)
|
||||
}
|
||||
|
||||
fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f {
|
||||
let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
|
||||
let (w, h) = (sensor.width, sensor.height);
|
||||
let mut aligned = Rgb32f::new(w, h);
|
||||
for (i, pixel) in aligned.data.iter_mut().enumerate() {
|
||||
let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||||
*pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
|
||||
}
|
||||
aligned
|
||||
}
|
||||
|
||||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||||
/// colour matrices, other files or unusable previews.
|
||||
@@ -870,10 +1017,71 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
|
||||
let (w, h) = (192, 128);
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
for (i, pixel) in sensor.data.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
|
||||
*pixel = [value; 3];
|
||||
}
|
||||
let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
|
||||
let mut reference = Rgb32f::new(w, h);
|
||||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||||
let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||||
*pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
|
||||
}
|
||||
let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
|
||||
assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
|
||||
assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
|
||||
assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
|
||||
assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
|
||||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
*pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
|
||||
}
|
||||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
|
||||
}
|
||||
|
||||
/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
|
||||
/// download or an unfinished codec cannot make this test claim a successful RAW calibration.
|
||||
#[test]
|
||||
fn corpus_cr3_gets_a_framing_aligned_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/cr3-canon-r100-raw.cr3");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let raw = match lightcraft_raw::decode(&bytes) {
|
||||
Ok(raw) => raw,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
eprintln!("skip CR3 sensor decoder: {why}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
|
||||
};
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform);
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.raw && info.relative_wb);
|
||||
assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
|
||||
assert_eq!(info.camera_tone.is_some(), look.is_some());
|
||||
}
|
||||
|
||||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||||
/// balance is relative to the as-shot look.
|
||||
#[test]
|
||||
|
||||
@@ -1062,6 +1062,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
// ---- import
|
||||
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
|
||||
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
"Import Lightroom Catalog",
|
||||
[],
|
||||
None,
|
||||
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
|
||||
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.importPreview",
|
||||
"Review Import",
|
||||
|
||||
@@ -99,9 +99,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let m = lightcraft_meta::extract(bytes);
|
||||
let (meta, captured) = meta_of(&m);
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let raw = match lightcraft_raw::probe_info(bytes) {
|
||||
let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
Err(Ok(why)) => {
|
||||
// a raw variant we can't decode yet: describe it from its embedded preview
|
||||
let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
|
||||
return Ok(ProbeInfo {
|
||||
@@ -119,7 +119,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
@@ -179,6 +179,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error
|
||||
/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX
|
||||
/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed.
|
||||
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
|
||||
match e {
|
||||
lightcraft_raw::RawError::Unsupported(why) => Ok(why),
|
||||
e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")),
|
||||
e => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||||
|
||||
@@ -213,13 +224,13 @@ pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo),
|
||||
|
||||
fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes) {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
Err(Ok(why)) => {
|
||||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||||
return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
// 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`).
|
||||
@@ -553,6 +564,43 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage:
|
||||
/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`).
|
||||
fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec<u8> {
|
||||
let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
|
||||
let mut craw = vec![0u8; 82];
|
||||
craw[24..26].copy_from_slice(&(w as u16).to_be_bytes());
|
||||
craw[26..28].copy_from_slice(&(h as u16).to_be_bytes());
|
||||
craw.extend(bx(b"CMP1", &[0xff; 4]));
|
||||
let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1];
|
||||
stsd.extend(bx(b"CRAW", &craw));
|
||||
let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat();
|
||||
let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat();
|
||||
let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat();
|
||||
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))));
|
||||
let preview = cr3_with_preview(w, h);
|
||||
[&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat()
|
||||
}
|
||||
|
||||
/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG:
|
||||
/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review).
|
||||
#[test]
|
||||
fn corrupt_cr3_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_corrupt_raw(48, 32);
|
||||
let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt");
|
||||
assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}");
|
||||
let p = probe_bytes("x.cr3", &b).unwrap();
|
||||
assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
|
||||
assert!(p.preview_only.is_some_and(|why| why.contains("CR3")));
|
||||
let (img, src) = load_bytes(&b, 24).unwrap();
|
||||
assert_eq!((img.width, img.height), (24, 16));
|
||||
assert!(!src.raw);
|
||||
// other formats still report a corrupt file as an error
|
||||
let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
dng.truncate(dng.len() / 2);
|
||||
assert!(load_bytes(&dng, 24).is_err());
|
||||
}
|
||||
|
||||
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
|
||||
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
|
||||
#[test]
|
||||
|
||||
+311
-56
@@ -365,7 +365,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
|
||||
let n = workers(paths.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
@@ -404,18 +404,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
|
||||
/// byte for byte with the source when there is none); a bad one is removed and the photo
|
||||
/// reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the
|
||||
/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and
|
||||
/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte
|
||||
/// with the source when there is none); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
@@ -641,6 +639,50 @@ impl Prepared {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Recheck Add-only entries against the owner catalog immediately before commit.
|
||||
/// Preparation may have run while another import added the same path or content.
|
||||
pub fn revalidate_add(&mut self, s: &Session) {
|
||||
let mut paths = HashMap::new();
|
||||
let mut hashes = HashMap::new();
|
||||
for photo in s.catalog.photos() {
|
||||
if let Source::File { path } = &photo.source {
|
||||
paths.insert(normalize_import_path(path), photo.id);
|
||||
}
|
||||
if let Some(hash) = &photo.content_hash {
|
||||
hashes.insert(hash.clone(), photo.id);
|
||||
}
|
||||
}
|
||||
let mut items = Vec::with_capacity(self.items.len());
|
||||
for item in std::mem::take(&mut self.items) {
|
||||
let PreparedItem::Ready(ready) = item else {
|
||||
items.push(item);
|
||||
continue;
|
||||
};
|
||||
let ReadyFile { path, stored, info, sidecar, placed } = *ready;
|
||||
let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied());
|
||||
let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied());
|
||||
if let Some(existing) = existing_path.or(existing_hash) {
|
||||
if let Some(placed) = &placed {
|
||||
crate::import_move::rollback(placed);
|
||||
}
|
||||
items.push(PreparedItem::Duplicate {
|
||||
path,
|
||||
existing: Some(existing),
|
||||
reason: if existing_path.is_some() { "path" } else { "content" },
|
||||
hash: info.content_hash,
|
||||
});
|
||||
} else {
|
||||
items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
}
|
||||
self.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_import_path(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
@@ -699,8 +741,9 @@ impl ImportJob {
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files — several at once,
|
||||
/// with the names and outcomes of one after another. Nothing in the catalog changes. Stops
|
||||
/// before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
@@ -750,24 +793,39 @@ impl ImportJob {
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
let mut plan = Plan::default();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Failed(path, e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
|
||||
if !opts.local
|
||||
&& let Some(h) = &info.content_hash
|
||||
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
|
||||
{
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
@@ -786,7 +844,6 @@ impl ImportJob {
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
@@ -803,37 +860,35 @@ impl ImportJob {
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
// the name this file gets depends on what a pending transfer of a like-named file
|
||||
// into the same folder ends up with: that one goes first
|
||||
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
|
||||
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
|
||||
if plan.transfers.iter().any(|t| t.family == family) {
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
How::Place { name: name.unwrap_or(own) }
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
How::Copy { name }
|
||||
};
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
|
||||
plan.slots.push(Slot::Transfer);
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
|
||||
continue;
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
@@ -841,27 +896,199 @@ impl ImportJob {
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None }))));
|
||||
}
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
|
||||
/// file order. Transfers not started because `cancel` was set leave no item.
|
||||
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
|
||||
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
|
||||
for slot in std::mem::take(&mut plan.slots) {
|
||||
let item = match slot {
|
||||
Slot::Item(it) => it,
|
||||
Slot::Transfer => match done.next().flatten() {
|
||||
Some(it) => it,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if let PreparedItem::Ready(r) = &item
|
||||
&& let Some(h) = &r.info.content_hash
|
||||
{
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
out.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
slots: Vec<Slot>,
|
||||
transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
enum Slot {
|
||||
Item(PreparedItem),
|
||||
/// The outcome of the next of [`Plan::transfers`].
|
||||
Transfer,
|
||||
}
|
||||
|
||||
/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]).
|
||||
struct Transfer {
|
||||
path: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
dir: PathBuf,
|
||||
how: How,
|
||||
/// Copy as DNG.
|
||||
dng: bool,
|
||||
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
|
||||
/// names, so they never run at the same time.
|
||||
family: (String, String),
|
||||
}
|
||||
|
||||
enum How {
|
||||
/// A verified copy, under `name` (default: the file's own).
|
||||
Copy { name: Option<String> },
|
||||
/// Move: placed (linked or copied) without touching the source.
|
||||
Place { name: String },
|
||||
}
|
||||
|
||||
/// The names a file called `name` could end up with, as one key: the transfers number a taken name
|
||||
/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`,
|
||||
/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems).
|
||||
fn name_family(name: &str) -> String {
|
||||
let mut stem = match name.rsplit_once('.') {
|
||||
Some((s, _)) if !s.is_empty() => s,
|
||||
_ => name,
|
||||
};
|
||||
while let Some((head, tail)) = stem.rsplit_once('-')
|
||||
&& !head.is_empty()
|
||||
&& !tail.is_empty()
|
||||
&& tail.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
stem = head;
|
||||
}
|
||||
stem.to_lowercase()
|
||||
}
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]):
|
||||
/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few
|
||||
/// enough that each batch soon joins the catalog.
|
||||
pub fn batch_size() -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
workers(usize::MAX).saturating_mul(2).clamp(8, 16)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one
|
||||
/// not started because `cancel` was set. `done` counts each one finished.
|
||||
fn run_transfers(
|
||||
transfers: Vec<Transfer>,
|
||||
file_bytes: Option<&crate::merge::ByteReader>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
) -> Vec<Option<PreparedItem>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let one = |t: Transfer| {
|
||||
if cancel.load(Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let path = t.path.clone();
|
||||
// last-resort guard: a panic is this file's failure, not the import's
|
||||
let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e));
|
||||
done.fetch_add(1, Relaxed);
|
||||
Some(item)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = workers(transfers.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<PreparedItem>> = std::iter::repeat_with(|| None).take(transfers.len()).collect();
|
||||
let queue = std::sync::Mutex::new(transfers.into_iter().enumerate());
|
||||
let out = std::sync::Mutex::new(&mut results);
|
||||
// (the tests' injected faults hold for the workers too)
|
||||
#[cfg(test)]
|
||||
let fault = crate::import_move::injected_fault();
|
||||
let work = &|| {
|
||||
#[cfg(test)]
|
||||
crate::import_move::inject(fault);
|
||||
loop {
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some((i, t)) = next else { break };
|
||||
let r = one(t);
|
||||
if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
let handles: Vec<_> =
|
||||
(0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect();
|
||||
if handles.is_empty() {
|
||||
// no thread to be had: copy here, one after another
|
||||
work();
|
||||
}
|
||||
for h in handles {
|
||||
if h.join().is_err() {
|
||||
log::error!("import: a copy worker stopped unexpectedly");
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
transfers.into_iter().map(one).collect()
|
||||
}
|
||||
|
||||
/// Copy or move one planned file (and Copy as DNG); its item.
|
||||
fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem {
|
||||
let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t;
|
||||
let mut placed = None;
|
||||
let mut stored = match how {
|
||||
How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
};
|
||||
if dng {
|
||||
// decoding the raw twice and encoding it takes several times the file's size in memory
|
||||
let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6);
|
||||
let _permit = crate::memory::work_gate().acquire(weight);
|
||||
match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
@@ -1093,3 +1320,31 @@ impl Session {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revalidate_add_only_converts_matching_ready_item() {
|
||||
let mut s = Session::new();
|
||||
let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg");
|
||||
let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg");
|
||||
let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now");
|
||||
s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap();
|
||||
let ready = |path: &Path| ReadyFile {
|
||||
path: path.to_string_lossy().into(),
|
||||
stored: path.to_string_lossy().into(),
|
||||
info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() },
|
||||
sidecar: None,
|
||||
placed: None,
|
||||
};
|
||||
let mut prepared = Prepared {
|
||||
scanned: 2,
|
||||
items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))],
|
||||
};
|
||||
prepared.revalidate_add(&s);
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ pub(crate) enum Fault {
|
||||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
/// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see
|
||||
/// an import's copies run side by side.
|
||||
Slow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) {
|
||||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
/// The calling thread's injected fault (tests only), for the workers it starts to inherit.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn injected_fault() -> Fault {
|
||||
FAULT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// The threads copies made under [`Fault::Slow`] ran on (tests only).
|
||||
#[cfg(test)]
|
||||
static COPY_THREADS: std::sync::Mutex<Vec<std::thread::ThreadId>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn copy_threads() -> Vec<std::thread::ThreadId> {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_pr
|
||||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
#[cfg(test)]
|
||||
if injected(Fault::Slow) {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(std::thread::current().id());
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
pub mod lightroom_job;
|
||||
mod lightroom_sqlite;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
@@ -186,6 +190,8 @@ pub struct Session {
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// Identity of current library opening; refreshed only after a successful open.
|
||||
pub(crate) library_identity: Arc<()>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
@@ -274,6 +280,7 @@ impl Session {
|
||||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
|
||||
@@ -412,6 +412,7 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -504,12 +505,16 @@ impl Session {
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
let result = match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Bounded, deduplicated persistence for Lightroom import source data.
|
||||
//!
|
||||
//! Archives use a deterministic JSON representation wrapped in zlib. The JSON
|
||||
//! writer streams through a bounded compressor, so an import cannot first build
|
||||
//! an unbounded archive buffer. Blob columns are omitted by the catalog reader;
|
||||
//! decoded XMP,
|
||||
//! settings, history, snapshots, identities, paths, collections & membership
|
||||
//! remain available for recovery.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miniz_oxide::deflate::{
|
||||
core::{CompressorOxide, create_comp_flags_from_zip_params},
|
||||
stream::deflate,
|
||||
};
|
||||
use miniz_oxide::{MZFlush, MZStatus};
|
||||
use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
|
||||
/// Maximum size of one complete archive on disk, including format header.
|
||||
pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Maximum total size retained in managed archives.
|
||||
pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Maximum number of managed archives retained.
|
||||
pub const MAX_MANAGED_ARCHIVES: usize = 8;
|
||||
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
const HEADER: &[u8] = b"LC-LRARCH\0\x01Z";
|
||||
const NAME_PREFIX: &str = "lightroom-import-";
|
||||
const NAME_SUFFIX: &str = ".lca";
|
||||
const MAX_SCAN: usize = 1024;
|
||||
const IO_BUFFER: usize = 16 * 1024;
|
||||
|
||||
/// Store compact source data below `directory` and return its managed archive.
|
||||
///
|
||||
/// The operation is safe to call before catalog mutation. It rejects a
|
||||
/// symlink directory, writes only `lightroom-import-<number>.lca`, compares the
|
||||
/// complete bounded byte stream with recent managed archives, and retains at
|
||||
/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy
|
||||
/// `lightroom-import-*.json` files are deliberately ignored and preserved for
|
||||
/// an explicit caller warning.
|
||||
pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
|
||||
prepare_directory(directory)?;
|
||||
let mut managed = managed_files(directory)?;
|
||||
managed.sort_by_key(|entry| entry.index);
|
||||
|
||||
let temporary = temporary_path(directory)?;
|
||||
write_archive(&temporary, data).inspect_err(|_| {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
})?;
|
||||
|
||||
let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES);
|
||||
for entry in recent {
|
||||
let same = match same_bounded(&temporary, &entry.path) {
|
||||
Ok(same) => same,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if same {
|
||||
remove_managed_file(&temporary).map_err(|e| e.to_string())?;
|
||||
let matched = entry.path.clone();
|
||||
let mut all = managed.clone();
|
||||
prune_existing(directory, &mut all, Some(&matched))?;
|
||||
return Ok(matched);
|
||||
}
|
||||
}
|
||||
|
||||
let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1));
|
||||
if managed.last().is_some_and(|entry| entry.index == u64::MAX) {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err("managed Lightroom archive index is exhausted".into());
|
||||
}
|
||||
let destination = match next_destination(directory, index) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let size = match fs::metadata(&temporary) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
let new_entry = Managed { index, path: destination.clone(), size };
|
||||
let mut after = managed;
|
||||
after.push(new_entry);
|
||||
after.sort_by_key(|entry| entry.index);
|
||||
let retirees = match retirement_set(&after) {
|
||||
Ok(retirees) => retirees,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let quarantined = match quarantine(&retirees) {
|
||||
Ok(quarantined) => quarantined,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = publish_new(&temporary, &destination) {
|
||||
rollback_quarantine(&quarantined);
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = remove_managed_file(&temporary) {
|
||||
return Err(format!("archive published but temporary cleanup failed: {error}"));
|
||||
}
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("archive published but could not retire {}: {error}", old.display()));
|
||||
}
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
/// Store source data when possible. An archive that reaches its bounded size is
|
||||
/// omitted with a warning so catalog import can still complete.
|
||||
pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
|
||||
match store(directory, data) {
|
||||
Ok(path) => Ok(Some(path)),
|
||||
Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Managed {
|
||||
index: u64,
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn prepare_directory(directory: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(directory) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir_all(directory).map_err(|e| e.to_string())?;
|
||||
let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_files(directory: &Path) -> Result<Vec<Managed>, String> {
|
||||
let mut files = Vec::new();
|
||||
for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() {
|
||||
if seen >= MAX_SCAN {
|
||||
return Err(format!("too many entries in archive directory (limit {MAX_SCAN})"));
|
||||
}
|
||||
let entry = result.map_err(|e| e.to_string())?;
|
||||
let Some(index) = parse_managed_name(&entry.file_name()) else { continue };
|
||||
let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
files.push(Managed { index, path: entry.path(), size: meta.len() });
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_managed_name(name: &std::ffi::OsStr) -> Option<u64> {
|
||||
let name = name.to_str()?;
|
||||
let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?;
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn temporary_path(directory: &Path) -> Result<PathBuf, String> {
|
||||
let pid = std::process::id();
|
||||
for slot in 0..MAX_SCAN {
|
||||
let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => continue,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe temporary archive name available".into())
|
||||
}
|
||||
|
||||
fn next_destination(directory: &Path, mut index: u64) -> Result<PathBuf, String> {
|
||||
for _ in 0..MAX_SCAN {
|
||||
let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe managed archive name available".into())
|
||||
}
|
||||
|
||||
fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> {
|
||||
lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| {
|
||||
let mut bounded = BoundedWriter { inner: raw, total: 0 };
|
||||
bounded.write_all(HEADER)?;
|
||||
let mut compressor = ZlibWriter::new(&mut bounded);
|
||||
serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?;
|
||||
compressor.finish()?;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("could not write Lightroom archive: {error}"))
|
||||
}
|
||||
|
||||
fn json_io(error: serde_json::Error) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
|
||||
}
|
||||
|
||||
struct BoundedWriter<'a> {
|
||||
inner: &'a mut dyn Write,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Write for BoundedWriter<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.inner.write_all(bytes)?;
|
||||
self.total = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB")
|
||||
}
|
||||
|
||||
struct ZlibWriter<'a, 'b> {
|
||||
sink: &'a mut BoundedWriter<'b>,
|
||||
compressor: CompressorOxide,
|
||||
plain: u64,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ZlibWriter<'a, 'b> {
|
||||
fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> {
|
||||
let flags = create_comp_flags_from_zip_params(6, 15, 0);
|
||||
ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 }
|
||||
}
|
||||
|
||||
fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> {
|
||||
let mut rest = input;
|
||||
loop {
|
||||
let mut output = [0u8; IO_BUFFER];
|
||||
let result = deflate(&mut self.compressor, rest, &mut output, flush);
|
||||
self.sink.write_all(&output[..result.bytes_written])?;
|
||||
match result.status {
|
||||
Ok(MZStatus::StreamEnd) => return Ok(()),
|
||||
Ok(MZStatus::Ok) => {
|
||||
let consumed = result.bytes_consumed;
|
||||
if consumed > rest.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress"));
|
||||
}
|
||||
rest = &rest[consumed..];
|
||||
if rest.is_empty() && flush == MZFlush::None {
|
||||
return Ok(());
|
||||
}
|
||||
if consumed == 0 && result.bytes_written == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress"));
|
||||
}
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}")));
|
||||
}
|
||||
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.run(&[], MZFlush::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ZlibWriter<'_, '_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.run(bytes, MZFlush::None)?;
|
||||
self.plain = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn same_bounded(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
let left_meta = fs::symlink_metadata(left)?;
|
||||
let right_meta = fs::symlink_metadata(right)?;
|
||||
if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let left_len = left_meta.len();
|
||||
let right_len = right_meta.len();
|
||||
if left_len != right_len || left_len > MAX_ARCHIVE_BYTES {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut left = File::open(left)?;
|
||||
let mut right = File::open(right)?;
|
||||
let mut left_buf = [0u8; IO_BUFFER];
|
||||
let mut right_buf = [0u8; IO_BUFFER];
|
||||
loop {
|
||||
let left_n = left.read(&mut left_buf)?;
|
||||
let right_n = right.read(&mut right_buf)?;
|
||||
if left_n != right_n {
|
||||
return Ok(false);
|
||||
}
|
||||
if left_n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if left_buf[..left_n] != right_buf[..right_n] {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retirement_set(entries: &[Managed]) -> Result<Vec<Managed>, String> {
|
||||
retirement_set_protected(entries, None)
|
||||
}
|
||||
|
||||
fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result<Vec<Managed>, String> {
|
||||
let mut ordered = entries.to_vec();
|
||||
ordered.sort_by_key(|entry| entry.index);
|
||||
let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?;
|
||||
let mut retirees = Vec::new();
|
||||
while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES {
|
||||
let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else {
|
||||
return Err("cannot satisfy archive retention limit".into());
|
||||
};
|
||||
let oldest = ordered.remove(position);
|
||||
total = total.saturating_sub(oldest.size);
|
||||
retirees.push(oldest);
|
||||
}
|
||||
Ok(retirees)
|
||||
}
|
||||
|
||||
fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> {
|
||||
let retirees = retirement_set_protected(entries, protected)?;
|
||||
let quarantined = quarantine(&retirees)?;
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quarantine(entries: &[Managed]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
|
||||
let mut moved = Vec::new();
|
||||
let pid = std::process::id();
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&held) {
|
||||
Ok(_) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("retention temporary path already exists: {}", held.display()));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
let meta = fs::symlink_metadata(&entry.path).map_err(|error| {
|
||||
rollback_quarantine(&moved);
|
||||
error.to_string()
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("managed archive changed into non-file: {}", entry.path.display()));
|
||||
}
|
||||
if let Err(error) = fs::rename(&entry.path, &held) {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
moved.push((entry.path.clone(), held));
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
|
||||
for (original, held) in moved.iter().rev() {
|
||||
let _ = fs::rename(held, original);
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(temporary, destination) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_managed_file(path: &Path) -> io::Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path"));
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
struct ArchiveRef<'a>(&'a CatalogImport);
|
||||
|
||||
impl Serialize for ArchiveRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?;
|
||||
out.serialize_field("version", &FORMAT_VERSION)?;
|
||||
out.serialize_field("source", &self.0.source)?;
|
||||
out.serialize_field("photos", &PhotoSeq(&self.0.photos))?;
|
||||
out.serialize_field("collections", &RowSeq(&self.0.collections))?;
|
||||
out.serialize_field("members", &RowSeq(&self.0.members))?;
|
||||
out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?;
|
||||
out.serialize_field("warnings", &self.0.warnings)?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoSeq<'a>(&'a [CatalogPhoto]);
|
||||
impl Serialize for PhotoSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for photo in self.0 {
|
||||
out.serialize_element(&PhotoRef(photo))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoRef<'a>(&'a CatalogPhoto);
|
||||
impl Serialize for PhotoRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?;
|
||||
out.serialize_field("source_id", &p.source_id)?;
|
||||
out.serialize_field("uuid", &p.uuid)?;
|
||||
out.serialize_field("path", &p.path)?;
|
||||
out.serialize_field("image", &RowRef(&p.image))?;
|
||||
out.serialize_field("settings", &p.settings)?;
|
||||
out.serialize_field("xmp", &p.xmp)?;
|
||||
out.serialize_field("keywords", &p.keywords)?;
|
||||
out.serialize_field("history", &RowSeq(&p.history))?;
|
||||
out.serialize_field("snapshots", &RowSeq(&p.snapshots))?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowSeq<'a>(&'a [BTreeMap<String, Value>]);
|
||||
impl Serialize for RowSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for row in self.0 {
|
||||
out.serialize_element(&RowRef(row))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowRef<'a>(&'a BTreeMap<String, Value>);
|
||||
impl Serialize for RowRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_map(None)?;
|
||||
for (key, value) in self.0 {
|
||||
out.serialize_entry(key, value)?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn data(source: String) -> CatalogImport {
|
||||
CatalogImport {
|
||||
source,
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rich_data() -> CatalogImport {
|
||||
let photo = CatalogPhoto {
|
||||
source_id: 7,
|
||||
uuid: "uuid-7".into(),
|
||||
path: "/photos/one.raw".into(),
|
||||
image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]),
|
||||
settings: "s = { Exposure2012 = 0.75 }".into(),
|
||||
xmp: "<xmp>decoded</xmp>".into(),
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
photos: vec![photo],
|
||||
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_count(path: &Path) -> usize {
|
||||
fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_identical_deterministic_archives() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
let second = store(&path, &input).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(managed_count(&path), 1);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_returns_archive_that_retention_keeps() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
for index in 1..MAX_MANAGED_ARCHIVES {
|
||||
let filler = path.join(format!("lightroom-import-{index}.lca"));
|
||||
File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
|
||||
}
|
||||
let reused = store(&path, &input).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
assert!(reused.is_file());
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_managed_archive_count_and_preserves_unrelated_files() {
|
||||
let path = tempdir();
|
||||
let human = path.join("notes.txt");
|
||||
let legacy = path.join("lightroom-import-0.json");
|
||||
fs::write(&human, b"keep").unwrap();
|
||||
fs::write(&legacy, b"legacy").unwrap();
|
||||
for index in 0..(MAX_MANAGED_ARCHIVES + 3) {
|
||||
let input = data(format!("catalog-{index}.lrcat"));
|
||||
store(&path, &input).unwrap();
|
||||
}
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
assert_eq!(fs::read(&human).unwrap(), b"keep");
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"legacy");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_before_creating_archive() {
|
||||
let path = tempdir();
|
||||
let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1));
|
||||
assert!(store(&path, &input).is_err());
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_catalog_history_is_best_effort_and_does_not_abort_import() {
|
||||
let path = tempdir();
|
||||
let mut input = data("catalog.lrcat".into());
|
||||
input.photos = (0..5_000)
|
||||
.map(|index| CatalogPhoto {
|
||||
source_id: index,
|
||||
uuid: format!("photo-{index}"),
|
||||
path: format!("/photos/{index}.raw"),
|
||||
image: BTreeMap::new(),
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
|
||||
let path = tempdir();
|
||||
let archive = store(&path, &rich_data()).unwrap();
|
||||
let bytes = fs::read(archive).unwrap();
|
||||
assert!(bytes.starts_with(HEADER));
|
||||
let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
|
||||
let decoded: Value = serde_json::from_slice(&json).unwrap();
|
||||
assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION));
|
||||
assert_eq!(decoded["source"], serde_json::json!("source.lrcat"));
|
||||
assert_eq!(decoded["photos"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7));
|
||||
assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7"));
|
||||
assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw"));
|
||||
assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }"));
|
||||
assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("<xmp>decoded</xmp>"));
|
||||
assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"]));
|
||||
assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5));
|
||||
assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4]));
|
||||
assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255]));
|
||||
assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers"));
|
||||
assert_eq!(decoded["members"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["members"][0]["image"], serde_json::json!(7));
|
||||
assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["warnings"], serde_json::json!(["one warning"]));
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() {
|
||||
let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64)
|
||||
.map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 })
|
||||
.collect();
|
||||
entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 });
|
||||
let retirees = retirement_set(&entries).unwrap();
|
||||
assert_eq!(retirees.len(), 3);
|
||||
assert_eq!(retirees.iter().map(|entry| entry.index).collect::<Vec<_>>(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_failure_preserves_old_archive() {
|
||||
let path = tempdir();
|
||||
let old = path.join("lightroom-import-0.lca");
|
||||
let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id()));
|
||||
fs::write(&old, b"old archive").unwrap();
|
||||
fs::write(&held, b"occupied").unwrap();
|
||||
let entry = Managed { index: 0, path: old.clone(), size: 11 };
|
||||
assert!(quarantine(&[entry]).is_err());
|
||||
assert_eq!(fs::read(&old).unwrap(), b"old archive");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,450 @@
|
||||
//! Detached Lightroom catalog preparation and owner-thread commit.
|
||||
//!
|
||||
//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog
|
||||
//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations
|
||||
//! to the session that created the job. Archive index bytes are returned for a worker to finish
|
||||
//! after the owner has accepted the catalog operation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared};
|
||||
use crate::lightroom_catalog::{self, CatalogImport, ImportIndex};
|
||||
|
||||
const COMMAND: &str = "library.importLightroom";
|
||||
const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn error(message: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
|
||||
}
|
||||
|
||||
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
|
||||
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
|
||||
if archive_dir.is_some() && archive_path.is_none() {
|
||||
data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into());
|
||||
}
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
/// Memory identity of the library which owns a prepared completion.
|
||||
///
|
||||
/// Each successful open/close refreshes session identity, including reopening the same path.
|
||||
/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation
|
||||
/// runs must survive and are merged by the owner commit.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LightroomLibraryToken {
|
||||
library_dir: Option<PathBuf>,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
|
||||
impl LightroomLibraryToken {
|
||||
fn capture(s: &crate::Session) -> Self {
|
||||
Self {
|
||||
library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()),
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` still refers to the library which started preparation.
|
||||
pub fn matches(&self, s: &crate::Session) -> bool {
|
||||
Arc::ptr_eq(&self.identity, &s.library_identity)
|
||||
&& self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone())
|
||||
}
|
||||
|
||||
pub fn library_dir(&self) -> Option<&Path> {
|
||||
self.library_dir.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-free result of catalog preparation.
|
||||
pub struct PreparedLightroom {
|
||||
pub(crate) data: CatalogImport,
|
||||
pub(crate) normal: Prepared,
|
||||
pub(crate) index: ImportIndex,
|
||||
pub(crate) archive_path: Option<PathBuf>,
|
||||
pub(crate) token: LightroomLibraryToken,
|
||||
pub(crate) missing_sources: HashSet<String>,
|
||||
pub(crate) update_existing: bool,
|
||||
pub(crate) now: String,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl PreparedLightroom {
|
||||
pub fn source(&self) -> &str {
|
||||
&self.data.source
|
||||
}
|
||||
|
||||
pub fn photo_count(&self) -> usize {
|
||||
self.data.photos.len()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled
|
||||
}
|
||||
|
||||
/// Roll back any normal-import filesystem placement held by this prepared value.
|
||||
pub fn rollback(&self) {
|
||||
self.normal.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
/// Index write returned by owner commit. `finish` is intended for a worker after the owner has
|
||||
/// returned, and performs the only archive-index filesystem operation in this pipeline.
|
||||
pub struct LightroomArchiveFinalization {
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl LightroomArchiveFinalization {
|
||||
pub fn finish(self) -> crate::Result<()> {
|
||||
let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) };
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
return Err(error("Lightroom import index exceeds 16 MiB"));
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization
|
||||
/// can remain on the worker while its task stays alive.
|
||||
pub struct LightroomCompletion {
|
||||
pub report: Value,
|
||||
pub finalization: LightroomArchiveFinalization,
|
||||
}
|
||||
|
||||
/// Alias used by hosts that call the result a commit.
|
||||
pub type LightroomCommit = LightroomCompletion;
|
||||
|
||||
/// A Lightroom import detached from the owner thread.
|
||||
pub struct LightroomJob {
|
||||
path: PathBuf,
|
||||
update_existing: bool,
|
||||
import: ImportJob,
|
||||
token: LightroomLibraryToken,
|
||||
archive_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl LightroomJob {
|
||||
/// Snapshot session state only. This constructor performs no filesystem work.
|
||||
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.import.total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.import.done.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.done(), self.total())
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.total)
|
||||
}
|
||||
|
||||
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.done)
|
||||
}
|
||||
|
||||
/// Finish an owner commit's deferred archive-index write on a worker.
|
||||
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
|
||||
finalization.finish()
|
||||
}
|
||||
|
||||
/// Read/probe/stage all source data off the owner thread.
|
||||
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.total.store(1, Ordering::Relaxed);
|
||||
self.import.done.store(0, Ordering::Relaxed);
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
|
||||
lightroom_catalog::validate_for_job(&data).map_err(error)?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.done.fetch_add(1, Ordering::Relaxed);
|
||||
let paths: Vec<String> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.map(|photo| photo.path.clone())
|
||||
.collect();
|
||||
let missing_sources = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.filter(|photo| !Path::new(&photo.path).is_file())
|
||||
.map(|photo| normalize(&photo.path))
|
||||
.collect();
|
||||
let normal = self.import.prepare(&paths, cancel);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
|
||||
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
|
||||
let index = lightroom_catalog::load_index(index_path.as_deref())?;
|
||||
Ok(PreparedLightroom {
|
||||
data,
|
||||
normal,
|
||||
index,
|
||||
archive_path,
|
||||
token: self.token.clone(),
|
||||
missing_sources,
|
||||
update_existing: self.update_existing,
|
||||
now: self.import.now().to_string(),
|
||||
cancelled: cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs.
|
||||
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
|
||||
if prepared.cancelled {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
if !prepared.token.matches(s) {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import belongs to a different open library"));
|
||||
}
|
||||
// A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0
|
||||
// callers do not set this transient flag, so they cannot affect a later command.
|
||||
if s.depth > 0 {
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
|
||||
let undo0 = s.undo.len();
|
||||
prepared.normal.revalidate_add(s);
|
||||
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let now = prepared.now.clone();
|
||||
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
|
||||
let applied = lightroom_catalog::apply_prepared(
|
||||
s,
|
||||
prepared.data,
|
||||
lightroom_catalog::ApplyPrepared {
|
||||
update_existing: prepared.update_existing,
|
||||
files,
|
||||
index: &mut prepared.index,
|
||||
archive_path: prepared.archive_path,
|
||||
missing_sources: &prepared.missing_sources,
|
||||
undo0,
|
||||
},
|
||||
)?;
|
||||
let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json"));
|
||||
let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None };
|
||||
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
|
||||
}
|
||||
|
||||
fn normalize(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_data() -> CatalogImport {
|
||||
CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom {
|
||||
PreparedLightroom {
|
||||
data: empty_data(),
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom {
|
||||
let mut image = std::collections::BTreeMap::new();
|
||||
image.insert("id_local".into(), serde_json::json!(1));
|
||||
image.insert("rating".into(), serde_json::json!(rating));
|
||||
image.insert("pick".into(), serde_json::json!(0));
|
||||
image.insert("fileFormat".into(), serde_json::json!("JPG"));
|
||||
image.insert("fileWidth".into(), serde_json::json!(1));
|
||||
image.insert("fileHeight".into(), serde_json::json!(1));
|
||||
PreparedLightroom {
|
||||
data: CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: vec![lightroom_catalog::CatalogPhoto {
|
||||
source_id: 1,
|
||||
uuid: "fixture-photo".into(),
|
||||
path: path.into(),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: [normalize(path)].into_iter().collect(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
|
||||
let mut session = crate::Session::new();
|
||||
let before = session.catalog.to_snapshot();
|
||||
let prepared = cancelled_prepared(&session);
|
||||
assert!(commit_prepared(&mut session, prepared).is_err());
|
||||
assert_eq!(session.catalog.to_snapshot(), before);
|
||||
assert!(session.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_identity_rejects_late_completion_after_reopen() {
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
assert!(token.matches(&session));
|
||||
session.library_identity = Arc::new(());
|
||||
assert!(!token.matches(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() {
|
||||
let mut session = crate::Session::new();
|
||||
let path = "/missing/lightroom-job-edit.jpg";
|
||||
let prepared = prepared_for(&session, path, 4);
|
||||
let first = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1));
|
||||
assert_eq!(session.undo.len(), 1);
|
||||
let id = lightcraft_catalog::PhotoId(1);
|
||||
let mut personal = lightcraft_develop::DevelopSettings::default();
|
||||
personal.light.exposure = 1.5;
|
||||
session.set_develop(id, personal, "Personal").unwrap();
|
||||
let before = session.catalog.photo(id).map(|photo| photo.develop.clone());
|
||||
let prepared = prepared_for(&session, path, 5);
|
||||
commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before);
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5));
|
||||
assert_eq!(session.undo.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_history_archive_keeps_prepared_import_successful_with_warning() {
|
||||
let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id()));
|
||||
let mut data = empty_data();
|
||||
data.photos = (0..5_000)
|
||||
.map(|source_id| {
|
||||
let image = std::collections::BTreeMap::from([
|
||||
("id_local".into(), serde_json::json!(source_id)),
|
||||
("fileFormat".into(), serde_json::json!("JPG")),
|
||||
("fileWidth".into(), serde_json::json!(1)),
|
||||
("fileHeight".into(), serde_json::json!(1)),
|
||||
]);
|
||||
lightroom_catalog::CatalogPhoto {
|
||||
source_id,
|
||||
uuid: format!("scale-{source_id}"),
|
||||
path: format!("/missing/scale-{source_id}.jpg"),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![std::collections::BTreeMap::from([(
|
||||
"text".into(),
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap();
|
||||
assert!(archive_path.is_none());
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
let prepared = PreparedLightroom {
|
||||
data,
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path,
|
||||
token,
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
};
|
||||
let completion = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(completion.report["photos"], 5_000);
|
||||
assert!(
|
||||
completion.report["warnings"]
|
||||
.as_array()
|
||||
.is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped"))
|
||||
);
|
||||
assert_eq!(session.catalog.len(), 5_000);
|
||||
std::fs::remove_dir_all(archive_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_fn_import_does_not_rewrite_source_sidecar() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id()));
|
||||
let path = dir.join("source.jpg");
|
||||
let sidecar = dir.join("source.xmp");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"fixture").unwrap();
|
||||
std::fs::write(&sidecar, b"original sidecar").unwrap();
|
||||
let original = std::fs::read(&sidecar).unwrap();
|
||||
let mut session = crate::Session::new();
|
||||
session.xmp.auto_write = true;
|
||||
let result = session.execute_fn("library.importLightroom", |s| {
|
||||
let prepared = prepared_for(s, &path.to_string_lossy(), 5);
|
||||
let completion = commit_prepared(s, prepared)?;
|
||||
Ok(completion.report)
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 13;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -100,6 +100,7 @@ impl Lua {
|
||||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
@@ -210,6 +211,15 @@ impl LuaParser<'_> {
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
if self.depth >= 64 {
|
||||
return self.err("Lua settings exceed nesting limit");
|
||||
}
|
||||
self.depth += 1;
|
||||
let result = self.value_inner();
|
||||
self.depth -= 1;
|
||||
result
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
@@ -315,7 +325,10 @@ impl LuaParser<'_> {
|
||||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
if text.len() > 16 << 20 {
|
||||
return Err("Lua settings exceed 16 MiB".into());
|
||||
}
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
@@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
||||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let (props, values) = lua_settings_props(settings)?;
|
||||
Ok((title, props, values))
|
||||
}
|
||||
|
||||
/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua.
|
||||
pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> {
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
@@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
||||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props, values))
|
||||
Ok((props, values))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
||||
@@ -433,3 +433,70 @@ fn write_policy_timing() {
|
||||
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies
|
||||
/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives:
|
||||
/// like-named files take -1, -2… in file order (also against a file already there and a literal
|
||||
/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate
|
||||
/// (also while that copy is still under way).
|
||||
#[test]
|
||||
fn copies_run_side_by_side_with_the_same_outcome() {
|
||||
let base = temp_dir("parallel-copy");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a/IMG_1.png"), 1);
|
||||
write_png(&card.join("a/IMG_2.png"), 2);
|
||||
write_png(&card.join("b/IMG_1.png"), 3);
|
||||
write_png(&card.join("b/IMG_1-1.png"), 4);
|
||||
write_png(&card.join("c/IMG_1.png"), 5);
|
||||
for i in 0..8u8 {
|
||||
write_png(&card.join(format!("d/p{i}.png")), 10 + i);
|
||||
}
|
||||
// the content of a file whose copy is still under way
|
||||
std::fs::create_dir_all(card.join("e")).unwrap();
|
||||
std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap();
|
||||
|
||||
let mut s = session();
|
||||
inject(Fault::Slow);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
|
||||
)
|
||||
.unwrap();
|
||||
inject(Fault::None);
|
||||
let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect();
|
||||
if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 {
|
||||
assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len());
|
||||
}
|
||||
|
||||
assert_eq!(len(&r, "imported"), 13, "{r}");
|
||||
assert_eq!(len(&r, "failed"), 0, "{r}");
|
||||
assert_eq!(len(&r, "duplicates"), 1, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["reason"], "content");
|
||||
assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}");
|
||||
// the order of a one-by-one copy (files in name order), each verified
|
||||
let expect = [
|
||||
("a/IMG_1.png", "IMG_1.png"),
|
||||
("a/IMG_2.png", "IMG_2-1.png"),
|
||||
("b/IMG_1-1.png", "IMG_1-1.png"),
|
||||
("b/IMG_1.png", "IMG_1-2.png"),
|
||||
("c/IMG_1.png", "IMG_1-3.png"),
|
||||
];
|
||||
for (src, dst) in expect {
|
||||
assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}");
|
||||
}
|
||||
for i in 0..8 {
|
||||
assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap());
|
||||
}
|
||||
assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file");
|
||||
assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest));
|
||||
// the duplicate names the photo copied from the same content (d/p3); ids follow the file order
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}");
|
||||
assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}");
|
||||
let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap();
|
||||
assert_eq!(first.file_name, "IMG_1.png");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
+405
-12
@@ -17,7 +17,13 @@ pub enum Cr3TrackKind {
|
||||
/// `CRAW` with a `JPEG` child: the full-size JPEG.
|
||||
Jpeg,
|
||||
/// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file).
|
||||
Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> },
|
||||
Raw {
|
||||
width: u16,
|
||||
height: u16,
|
||||
cmp1: Option<(usize, usize)>,
|
||||
/// `CDI1/IAD1` image-area descriptor (byte range in the file).
|
||||
iad1: Option<(usize, usize)>,
|
||||
},
|
||||
/// Anything else (`CTMD` timed metadata, unknown entries).
|
||||
Other([u8; 4]),
|
||||
}
|
||||
@@ -30,6 +36,134 @@ pub struct Cr3Track {
|
||||
pub data: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample.
|
||||
///
|
||||
/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic,
|
||||
/// including optically masked borders. Unsupported encoding modes are retained for the decoder to
|
||||
/// report explicitly, rather than confused with the full-size embedded JPEG.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3Compression {
|
||||
pub version: u16,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub tile_width: u32,
|
||||
pub tile_height: u32,
|
||||
pub bit_depth: u8,
|
||||
pub planes: u8,
|
||||
/// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR.
|
||||
pub cfa_pattern: u8,
|
||||
pub encoding: u8,
|
||||
pub levels: u8,
|
||||
pub tile_flags: u8,
|
||||
/// Number of bytes at the start of the sample before its entropy-coded data.
|
||||
pub header_size: u32,
|
||||
pub median_bit_depth: Option<u8>,
|
||||
}
|
||||
|
||||
impl Cr3Compression {
|
||||
/// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`.
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
// The first word is reserved in the public notes; real files use FF00, although older
|
||||
// descriptions guessed FFFF. It is not a reliable format signature.
|
||||
let header_len = usize::from(be16(payload, 2)?).checked_add(4)?;
|
||||
if header_len < 52 || header_len > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let packed = *payload.get(25)?;
|
||||
let coding = *payload.get(26)?;
|
||||
let mut out = Self {
|
||||
version: be16(payload, 4)?,
|
||||
width: be32(payload, 8)?,
|
||||
height: be32(payload, 12)?,
|
||||
tile_width: be32(payload, 16)?,
|
||||
tile_height: be32(payload, 20)?,
|
||||
bit_depth: *payload.get(24)?,
|
||||
planes: packed >> 4,
|
||||
cfa_pattern: packed & 15,
|
||||
encoding: coding >> 4,
|
||||
levels: coding & 15,
|
||||
tile_flags: *payload.get(27)?,
|
||||
header_size: be32(payload, 28)?,
|
||||
median_bit_depth: None,
|
||||
};
|
||||
if out.width == 0
|
||||
|| out.height == 0
|
||||
|| out.tile_width == 0
|
||||
|| out.tile_height == 0
|
||||
|| out.tile_width > out.width
|
||||
|| out.tile_height > out.height
|
||||
|| !(1..=16).contains(&out.bit_depth)
|
||||
|| !(1..=4).contains(&out.planes)
|
||||
|| (out.planes > 1 && out.cfa_pattern > 3)
|
||||
|| out.header_size == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) {
|
||||
// Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional
|
||||
// median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw.
|
||||
let extension_len = usize::try_from(be32(payload, 52)?).ok()?;
|
||||
let extension_end = 52usize.checked_add(extension_len)?;
|
||||
if extension_len < 32 || extension_end > payload.len() {
|
||||
return None;
|
||||
}
|
||||
if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) {
|
||||
let precision = *payload.get(84).filter(|_| extension_end > 84)?;
|
||||
if !(1..=16).contains(&precision) {
|
||||
return None;
|
||||
}
|
||||
out.median_bit_depth = Some(precision);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`.
|
||||
/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3ImageArea {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub crop: [u16; 4],
|
||||
pub active: Option<[u16; 4]>,
|
||||
pub masked_left: [u16; 4],
|
||||
pub masked_top: Option<[u16; 4]>,
|
||||
}
|
||||
|
||||
impl Cr3ImageArea {
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]);
|
||||
let width = be16(payload, 4)?;
|
||||
let height = be16(payload, 6)?;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let full = be16(payload, 10)? == 2;
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
crop: bounds(16)?,
|
||||
masked_left: bounds(24)?,
|
||||
masked_top: if full { Some(bounds(32)?) } else { None },
|
||||
active: if full { Some(bounds(40)?) } else { None },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Cr3Track {
|
||||
/// Decode this track's image coding descriptor without reading its pixel data.
|
||||
pub fn compression(&self, bytes: &[u8]) -> Option<Cr3Compression> {
|
||||
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None };
|
||||
Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?)
|
||||
}
|
||||
|
||||
pub fn image_area(&self, bytes: &[u8]) -> Option<Cr3ImageArea> {
|
||||
let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None };
|
||||
Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a CR3 file LightCraft reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Cr3<'a> {
|
||||
@@ -40,6 +174,57 @@ pub struct Cr3<'a> {
|
||||
pub tracks: Vec<Cr3Track>,
|
||||
}
|
||||
|
||||
/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3TimedExif<'a> {
|
||||
/// CTMD record type (7, 8 or 9).
|
||||
pub record_type: u16,
|
||||
/// Exif IFD (`0x8769`) or Canon maker note (`0x927c`).
|
||||
pub tag: u32,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
impl Cr3<'_> {
|
||||
/// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor
|
||||
/// levels often live here rather than in the static `CMT3` maker note. Both record layers use
|
||||
/// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin.
|
||||
pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec<Cr3TimedExif<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut budget = MAX_BOXES;
|
||||
for track in &self.tracks {
|
||||
if track.kind != Cr3TrackKind::Other(*b"CTMD") {
|
||||
continue;
|
||||
}
|
||||
let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue };
|
||||
let mut at = 0usize;
|
||||
while budget > 0 {
|
||||
budget -= 1;
|
||||
let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break };
|
||||
let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break };
|
||||
let Some(record) = sample.get(at..end) else { break };
|
||||
let Some(kind) = le16(record, 4) else { break };
|
||||
if matches!(kind, 7..=9) {
|
||||
let mut item_at = 12usize;
|
||||
while budget > 0 {
|
||||
budget -= 1;
|
||||
let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break };
|
||||
let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break };
|
||||
let Some(item) = record.get(item_at..item_end) else { break };
|
||||
if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4)
|
||||
&& let Some(data) = item.get(8..)
|
||||
{
|
||||
out.push(Cr3TimedExif { record_type: kind, tag, data });
|
||||
}
|
||||
item_at = item_end;
|
||||
}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
|
||||
const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac];
|
||||
const MAX_DEPTH: usize = 12;
|
||||
@@ -158,19 +343,21 @@ fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option<Cr3Track> {
|
||||
let stbl = stbl?;
|
||||
let (mut kind, mut size, mut offset) = (None, None, None);
|
||||
for b in boxes(bytes, stbl.body, stbl.end, budget) {
|
||||
let body = bytes.get(b.body..b.end)?;
|
||||
match &b.kind {
|
||||
// full box: version/flags, entry count, entries
|
||||
b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
|
||||
b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
|
||||
// version/flags, sample_size (0 = per-sample table), count, sizes
|
||||
b"stsz" => {
|
||||
size = match be32(bytes, b.body + 4)? {
|
||||
0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12),
|
||||
size = match be32(body, 4)? {
|
||||
0 if be32(body, 8)? > 0 => be32(body, 12),
|
||||
0 => None,
|
||||
n => Some(n),
|
||||
n if be32(body, 8)? > 0 => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()),
|
||||
b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize),
|
||||
b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()),
|
||||
b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -185,18 +372,37 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
|
||||
if &e.kind != b"CRAW" {
|
||||
return Cr3TrackKind::Other(e.kind);
|
||||
}
|
||||
let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else {
|
||||
let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) };
|
||||
let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else {
|
||||
return Cr3TrackKind::Other(e.kind);
|
||||
};
|
||||
let mut cmp1 = None;
|
||||
let (mut cmp1, mut iad1, mut rendered) = (None, None, None);
|
||||
for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) {
|
||||
match &c.kind {
|
||||
b"JPEG" => return Cr3TrackKind::Jpeg,
|
||||
b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg),
|
||||
// HDR-capable cameras also put an HEVC preview in a CRAW entry,
|
||||
// often with the same displayed dimensions as the main raw track.
|
||||
b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)),
|
||||
b"CMP1" => cmp1 = Some((c.body, c.end - c.body)),
|
||||
b"CDI1" => {
|
||||
for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) {
|
||||
if &area.kind == b"IAD1" {
|
||||
iad1 = Some((area.body, area.end - area.body));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Cr3TrackKind::Raw { width, height, cmp1 }
|
||||
// A present, malformed CMP1 must remain a raw descriptor so its corruption
|
||||
// is reported. Only an explicitly identified rendered codec can establish
|
||||
// that a CRAW entry without CMP1 is a preview rather than a broken raw.
|
||||
if cmp1.is_none()
|
||||
&& let Some(kind) = rendered
|
||||
{
|
||||
return kind;
|
||||
}
|
||||
Cr3TrackKind::Raw { width, height, cmp1, iad1 }
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> Option<u16> {
|
||||
@@ -212,6 +418,16 @@ fn be64(b: &[u8], at: usize) -> Option<u64> {
|
||||
Some(u64::from_be_bytes(a))
|
||||
}
|
||||
|
||||
fn le16(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3
|
||||
/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block.
|
||||
pub fn merged_exif(c: &Cr3<'_>) -> Option<Vec<u8>> {
|
||||
@@ -308,12 +524,50 @@ mod tests {
|
||||
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".as_slice()));
|
||||
assert_eq!(c.tracks.len(), 2);
|
||||
assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) });
|
||||
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
|
||||
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
|
||||
assert_eq!((width, height, len), (60, 40, 4));
|
||||
assert_eq!(&f[at..at + 2], &[0xff, 0x10]);
|
||||
assert_eq!(c.tracks[1].data, Some((4112, 32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_preview_is_distinct_from_an_equal_size_raw_track() {
|
||||
let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16);
|
||||
let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16);
|
||||
let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16);
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &[preview, reduced, sensor].concat()));
|
||||
file.resize(4144, 0);
|
||||
let container = parse_cr3(&file).unwrap();
|
||||
assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC"));
|
||||
assert!(container.tracks[0].compression(&file).is_none());
|
||||
assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. }));
|
||||
assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_child_does_not_hide_a_malformed_raw_descriptor() {
|
||||
for codec in [b"JPEG", b"HEVC"] {
|
||||
for first in [true, false] {
|
||||
let rendered = bx(codec, &[0; 4]);
|
||||
let corrupt = bx(b"CMP1", &[0xff, 0]);
|
||||
let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat();
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16)));
|
||||
file.resize(4112, 0);
|
||||
let container = parse_cr3(&file).unwrap();
|
||||
assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. }));
|
||||
assert!(container.tracks[0].compression(&file).is_none());
|
||||
}
|
||||
}
|
||||
// Absence alone is not evidence of a preview: retain a broken raw so
|
||||
// main-track selection cannot quietly fall back to a reduced image.
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16)));
|
||||
file.resize(4112, 0);
|
||||
assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_exif_reads_like_a_tiff_raw() {
|
||||
let f = sample();
|
||||
@@ -322,6 +576,145 @@ mod tests {
|
||||
assert_eq!(m.iso, Some(400));
|
||||
}
|
||||
|
||||
fn compression_payload(levels: u8) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 52];
|
||||
b[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
|
||||
b[2..4].copy_from_slice(&48u16.to_be_bytes());
|
||||
b[4..6].copy_from_slice(&0x100u16.to_be_bytes());
|
||||
for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] {
|
||||
b[at..at + 4].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
b[24] = 14;
|
||||
b[25] = 0x40;
|
||||
b[26] = levels;
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_descriptor_retains_sensor_geometry_and_cfa() {
|
||||
let b = compression_payload(0);
|
||||
let h = Cr3Compression::parse(&b).unwrap();
|
||||
assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546));
|
||||
assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216));
|
||||
let mut craw = compression_payload(3);
|
||||
craw[25] = 0x41;
|
||||
craw[27] = 0x80;
|
||||
let h = Cr3Compression::parse(&craw).unwrap();
|
||||
assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80));
|
||||
for len in 0..b.len() {
|
||||
assert!(Cr3Compression::parse(&b[..len]).is_none());
|
||||
}
|
||||
for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] {
|
||||
let mut bad = b.clone();
|
||||
bad[at] = value;
|
||||
assert!(Cr3Compression::parse(&bad).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_descriptor_checks_optional_median_precision() {
|
||||
let mut b = compression_payload(0x30);
|
||||
b[32] = 0x80;
|
||||
b.extend_from_slice(&33u32.to_be_bytes());
|
||||
b.resize(85, 0);
|
||||
b[56] = 0xc0;
|
||||
b[84] = 14;
|
||||
assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14));
|
||||
assert!(Cr3Compression::parse(&b[..84]).is_none());
|
||||
b[84] = 0;
|
||||
assert!(Cr3Compression::parse(&b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_track_reads_distinct_active_area_and_default_crop() {
|
||||
let mut area = vec![0u8; 48];
|
||||
for (at, value) in [
|
||||
(4, 6888u16),
|
||||
(6, 4546),
|
||||
(10, 2),
|
||||
(16, 156),
|
||||
(18, 158),
|
||||
(20, 6875),
|
||||
(22, 4537),
|
||||
(24, 0),
|
||||
(26, 0),
|
||||
(28, 143),
|
||||
(30, 4545),
|
||||
(32, 144),
|
||||
(34, 0),
|
||||
(36, 6887),
|
||||
(38, 45),
|
||||
(40, 144),
|
||||
(42, 46),
|
||||
(44, 6887),
|
||||
(46, 4545),
|
||||
] {
|
||||
area[at..at + 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let cmp1 = bx(b"CMP1", &compression_payload(0));
|
||||
let iad1 = full(b"CDI1", &bx(b"IAD1", &area));
|
||||
let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32);
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &track));
|
||||
file.resize(4128, 0);
|
||||
let c = parse_cr3(&file).unwrap();
|
||||
assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888);
|
||||
let geometry = c.tracks[0].image_area(&file).unwrap();
|
||||
assert_eq!(geometry.crop, [156, 158, 6875, 4537]);
|
||||
assert_eq!(geometry.active, Some([144, 46, 6887, 4545]));
|
||||
assert_eq!(geometry.masked_left, [0, 0, 143, 4545]);
|
||||
for len in 0..area.len() {
|
||||
assert!(Cr3ImageArea::parse(&area[..len]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_sample_table_does_not_read_the_next_box() {
|
||||
let stbl = [
|
||||
full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()),
|
||||
bx(b"stsz", &[]),
|
||||
full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()),
|
||||
]
|
||||
.concat();
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))));
|
||||
file.resize(512, 0);
|
||||
assert!(parse_cr3(&file).unwrap().tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_metadata_exposes_the_dynamic_maker_note() {
|
||||
use lightcraft_tiff::{Value, tags as t};
|
||||
let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800]));
|
||||
let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200]));
|
||||
let item = |tag: u32, data: &[u8]| {
|
||||
let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec();
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
};
|
||||
let items = [item(0x8769, &exif), item(0x927c, &maker)].concat();
|
||||
let mut sample = vec![0u8; 12];
|
||||
sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes());
|
||||
sample[4..6].copy_from_slice(&8u16.to_le_bytes());
|
||||
sample.extend(items);
|
||||
let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() };
|
||||
let parts = container.timed_exif(&sample);
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice()));
|
||||
assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice()));
|
||||
for len in 0..sample.len() {
|
||||
assert!(container.timed_exif(&sample[..len]).is_empty());
|
||||
}
|
||||
for at in [0, 12] {
|
||||
let mut corrupt = sample.clone();
|
||||
corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(container.timed_exif(&corrupt).is_empty());
|
||||
corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert!(container.timed_exif(&corrupt).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
let f = sample();
|
||||
|
||||
@@ -5,8 +5,8 @@ proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn random_containers_never_panic(mut data in proptest::collection::vec(any::<u8>(), 0..600), kind in 0usize..5) {
|
||||
let heads: [&[u8]; 5] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*"];
|
||||
fn random_containers_never_panic(mut data in proptest::collection::vec(any::<u8>(), 0..600), kind in 0usize..6) {
|
||||
let heads: [&[u8]; 6] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx "];
|
||||
let h = heads[kind];
|
||||
if data.len() >= h.len() {
|
||||
data[..h.len()].copy_from_slice(h);
|
||||
|
||||
+13
-4
@@ -12,11 +12,11 @@
|
||||
//! look tables and tone curve.
|
||||
//!
|
||||
//! 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
|
||||
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! 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, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources
|
||||
//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF,
|
||||
//! compressed ORF, CR3 unverified marker families / C-RAW configurations) 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)]
|
||||
@@ -92,7 +92,15 @@ impl RawFormat {
|
||||
pub fn is_supported(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
RawFormat::Dng | RawFormat::Cr2 | RawFormat::Nef | RawFormat::Nrw | RawFormat::Arw | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef
|
||||
RawFormat::Dng
|
||||
| RawFormat::Cr2
|
||||
| RawFormat::Cr3
|
||||
| RawFormat::Nef
|
||||
| RawFormat::Nrw
|
||||
| RawFormat::Arw
|
||||
| RawFormat::Raf
|
||||
| RawFormat::Rw2
|
||||
| RawFormat::Pef
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +188,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
match probe(bytes).ok_or(RawError::NotRaw)? {
|
||||
RawFormat::Dng => dng::decode(bytes, mode),
|
||||
RawFormat::Cr2 => vendor::cr2::decode(bytes, mode),
|
||||
RawFormat::Cr3 => vendor::cr3::decode(bytes, mode),
|
||||
RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes),
|
||||
RawFormat::Arw => vendor::arw::decode(bytes, mode),
|
||||
RawFormat::Raf => vendor::raf::decode(bytes, mode),
|
||||
|
||||
Vendored
+470
@@ -0,0 +1,470 @@
|
||||
//! Canon CR3 container, camera metadata and sensor geometry.
|
||||
//!
|
||||
//! Sources: Laurent Clévy's public CR3 format description (`CMP1`, `CDI1/IAD1`, `CMT3`),
|
||||
//! <https://github.com/lclevy/canon_cr3/blob/master/readme.md>, and ExifTool's published Canon
|
||||
//! tag-name tables (`SensorInfo`, `ColorData9`…`ColorData12`, `LensModel`),
|
||||
//! <https://exiftool.org/TagNames/Canon.html>. No third-party decoder source was consulted.
|
||||
//!
|
||||
//! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive
|
||||
//! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later.
|
||||
//! Unknown camera metadata versions are ignored rather than assigned a guessed white balance.
|
||||
|
||||
use super::{black_from_columns, crx, white_from_data};
|
||||
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_meta::cr3::{Cr3ImageArea, Cr3Track, Cr3TrackKind, parse_cr3};
|
||||
use lightcraft_tiff::{Ifd, Tiff};
|
||||
|
||||
const SENSOR_INFO: u16 = 0x00e0;
|
||||
const COLOR_DATA: u16 = 0x4001;
|
||||
const LENS_MODEL: u16 = 0x0095;
|
||||
|
||||
/// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which
|
||||
/// must never replace the main sensor image. An invalid full-size descriptor is reported, not
|
||||
/// silently replaced with the reduced raw preview.
|
||||
fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> {
|
||||
let mut best = None;
|
||||
let mut best_area = 0u64;
|
||||
for track in tracks {
|
||||
if let Cr3TrackKind::Raw { width, height, .. } = track.kind {
|
||||
let area = u64::from(width) * u64::from(height);
|
||||
if area > best_area {
|
||||
best = Some(track);
|
||||
best_area = area;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn cfa(pattern: u8) -> Option<Cfa> {
|
||||
let name = match pattern {
|
||||
0 => "RGGB",
|
||||
1 => "GRBG",
|
||||
2 => "GBRG",
|
||||
3 => "BGGR",
|
||||
_ => return None,
|
||||
};
|
||||
Some(Cfa::bayer_static(name))
|
||||
}
|
||||
|
||||
/// Inclusive offsets, clipped to the sample dimensions (some Canon crop-mode files record right
|
||||
/// and bottom borders a few pixels beyond the encoded image).
|
||||
fn inclusive_rect(bounds: [u16; 4], width: usize, height: usize) -> Option<Rect> {
|
||||
let [left, top, right, bottom] = bounds.map(usize::from);
|
||||
if left >= width || top >= height || right < left || bottom < top {
|
||||
return None;
|
||||
}
|
||||
let end_x = right.checked_add(1)?.min(width);
|
||||
let end_y = bottom.checked_add(1)?.min(height);
|
||||
Some(Rect::new(left, top, end_x.checked_sub(left)?, end_y.checked_sub(top)?))
|
||||
}
|
||||
|
||||
fn relative_crop(crop: Rect, active: Rect) -> Option<Rect> {
|
||||
let x = crop.x.checked_sub(active.x)?;
|
||||
let y = crop.y.checked_sub(active.y)?;
|
||||
if crop.width < 2 || crop.height < 2 || x.checked_add(crop.width)? > active.width || y.checked_add(crop.height)? > active.height {
|
||||
return None;
|
||||
}
|
||||
Some(Rect::new(x, y, crop.width, crop.height))
|
||||
}
|
||||
|
||||
fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option<Rect> {
|
||||
let data = maker?.u64s(SENSOR_INFO)?;
|
||||
let bounds = data.get(5..9)?;
|
||||
inclusive_rect(
|
||||
[
|
||||
u16::try_from(*bounds.first()?).ok()?,
|
||||
u16::try_from(*bounds.get(1)?).ok()?,
|
||||
u16::try_from(*bounds.get(2)?).ok()?,
|
||||
u16::try_from(*bounds.get(3)?).ok()?,
|
||||
],
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) {
|
||||
let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height);
|
||||
let sensor = sensor_crop(maker, width, height);
|
||||
let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
|
||||
let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new(
|
||||
0,
|
||||
0,
|
||||
active.width,
|
||||
active.height,
|
||||
));
|
||||
(active, crop)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CameraLevels {
|
||||
wb: Option<[f32; 3]>,
|
||||
black: Option<[f32; 4]>,
|
||||
white: Option<f32>,
|
||||
}
|
||||
|
||||
/// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not
|
||||
/// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout.
|
||||
fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels {
|
||||
let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() };
|
||||
let offsets = match values.first().copied() {
|
||||
Some(16..=19) => (71, 329, 797),
|
||||
Some(32 | 33) => (85, 343, 811),
|
||||
Some(34 | 48) => (105, 363, 641),
|
||||
Some(64 | 65) => (105, 383, 661),
|
||||
_ => return CameraLevels::default(),
|
||||
};
|
||||
let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32;
|
||||
let quadruple = |at: usize| -> Option<[f32; 4]> {
|
||||
let v = values.get(at..at + 4)?;
|
||||
Some([*v.first()? as f32, *v.get(1)? as f32, *v.get(2)? as f32, *v.get(3)? as f32])
|
||||
};
|
||||
let wb = quadruple(offsets.0).and_then(|[r, g1, g2, b]| {
|
||||
let green = (g1 + g2) * 0.5;
|
||||
if !(64.0..=32767.0).contains(&green) || (g1 - g2).abs() > green * 0.1 {
|
||||
return None;
|
||||
}
|
||||
let red = r / green;
|
||||
let blue = b / green;
|
||||
((0.1..8.0).contains(&red) && (0.1..8.0).contains(&blue)).then_some([red, 1.0, blue])
|
||||
});
|
||||
let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5));
|
||||
let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0);
|
||||
let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full);
|
||||
CameraLevels { wb, black, white }
|
||||
}
|
||||
|
||||
fn black_at_active(levels: [f32; 4], pattern: u8, active: Rect) -> BlackLevel {
|
||||
// Convert Canon's channel order to full-sensor site order, then shift to the active origin.
|
||||
let sites = match pattern {
|
||||
1 => [1, 0, 3, 2],
|
||||
2 => [2, 3, 0, 1],
|
||||
3 => [3, 2, 1, 0],
|
||||
_ => [0, 1, 2, 3],
|
||||
};
|
||||
let values = (0..4)
|
||||
.map(|i| {
|
||||
let x = (active.x + i % 2) % 2;
|
||||
let y = (active.y + i / 2) % 2;
|
||||
levels.get(*sites.get(y * 2 + x).unwrap_or(&0)).copied().unwrap_or(0.0)
|
||||
})
|
||||
.collect();
|
||||
BlackLevel { repeat_rows: 2, repeat_cols: 2, values, delta_h: vec![], delta_v: vec![] }
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let container = parse_cr3(bytes).ok_or(RawError::NotRaw)?;
|
||||
let track = main_track(&container.tracks).ok_or_else(|| RawError::Unsupported("CR3 without a Bayer raw track".into()))?;
|
||||
let coding = track.compression(bytes).ok_or_else(|| RawError::Corrupt("CR3 raw track has an invalid CMP1 descriptor".into()))?;
|
||||
if coding.planes != 4 || coding.encoding != 0 {
|
||||
return Err(RawError::Unsupported(format!("Canon CR3 encoding {} with {} planes", coding.encoding, coding.planes)));
|
||||
}
|
||||
let (width, height) = (coding.width as usize, coding.height as usize);
|
||||
let samples = width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("CR3 sensor dimensions"))?;
|
||||
let cfa = cfa(coding.cfa_pattern).ok_or_else(|| RawError::Unsupported(format!("Canon CR3 CFA {}", coding.cfa_pattern)))?;
|
||||
let (offset, len) = track.data.ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?;
|
||||
let sample =
|
||||
offset.checked_add(len).and_then(|end| bytes.get(offset..end)).ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?;
|
||||
if coding.header_size as usize > sample.len() {
|
||||
return Err(RawError::Corrupt("CR3 coding header outside sample".into()));
|
||||
}
|
||||
crx::validate(sample, &coding)?;
|
||||
let data = match mode {
|
||||
Mode::Full => {
|
||||
let data = crx::decode(sample, &coding)?;
|
||||
if data.len() != samples {
|
||||
return Err(RawError::Corrupt("CR3 decoded sample count does not match CMP1".into()));
|
||||
}
|
||||
data
|
||||
}
|
||||
Mode::Header => vec![],
|
||||
};
|
||||
let static_maker = container.cmt.get(2).and_then(|b| *b).and_then(|b| Tiff::parse(b).ok());
|
||||
let maker = static_maker.as_ref().and_then(|m| m.ifds.first());
|
||||
// Retain only the first useful timed maker note: repeated CTMD records must not accumulate
|
||||
// independent copies of every bounded TIFF value in memory.
|
||||
let timed_maker = container
|
||||
.timed_exif(bytes)
|
||||
.into_iter()
|
||||
.filter(|b| b.tag == 0x927c)
|
||||
.filter_map(|b| Tiff::parse(b.data).ok())
|
||||
.find(|t| t.ifds.first().is_some_and(|m| m.contains(COLOR_DATA)));
|
||||
let color_maker = timed_maker.as_ref().and_then(|t| t.ifds.first()).or(maker);
|
||||
let area = track.image_area(bytes);
|
||||
let (active, crop) = geometry(area.as_ref(), maker, width, height);
|
||||
let levels = camera_levels(color_maker, coding.bit_depth);
|
||||
let black = match levels.black {
|
||||
Some(levels) => black_at_active(levels, coding.cfa_pattern, active),
|
||||
None if active.x >= 8 => black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active),
|
||||
None => BlackLevel::uniform(0.0),
|
||||
};
|
||||
let white = levels.white.unwrap_or_else(|| white_from_data(&data, u32::from(coding.bit_depth)));
|
||||
let mut metadata = lightcraft_meta::extract(bytes);
|
||||
if metadata.lens_model.is_none() {
|
||||
metadata.lens_model = maker.and_then(|m| m.string(LENS_MODEL)).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||
}
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let image = RawImage {
|
||||
format: RawFormat::Cr3,
|
||||
width,
|
||||
height,
|
||||
cpp: 1,
|
||||
data: RawData::U16(data),
|
||||
cfa: Some(cfa),
|
||||
bits: u32::from(coding.bit_depth),
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: active,
|
||||
crop,
|
||||
orientation: metadata.orientation.unwrap_or_default(),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: levels.wb,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
};
|
||||
image.validate_for(mode)?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{IfdBuilder, Value};
|
||||
|
||||
fn maker(version: u16, wb_at: usize, black_at: usize, white_at: usize) -> Ifd {
|
||||
let mut words = vec![0u16; white_at + 1];
|
||||
words[0] = version;
|
||||
words[wb_at..wb_at + 4].copy_from_slice(&[2048, 1024, 1024, 1536]);
|
||||
words[black_at..black_at + 4].copy_from_slice(&[100, 101, 102, 103]);
|
||||
words[white_at] = 15000;
|
||||
let b = IfdBuilder::new().with(COLOR_DATA, Value::Short(words));
|
||||
let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap();
|
||||
Tiff::parse(&bytes).unwrap().ifds.remove(0)
|
||||
}
|
||||
|
||||
fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut b = ((body.len() + 8) as u32).to_be_bytes().to_vec();
|
||||
b.extend_from_slice(kind);
|
||||
b.extend_from_slice(body);
|
||||
b
|
||||
}
|
||||
|
||||
fn track(entry: &[u8], offset: u64, size: u32) -> Vec<u8> {
|
||||
let mut stsd = vec![0u8; 4];
|
||||
stsd.extend(1u32.to_be_bytes());
|
||||
stsd.extend(entry);
|
||||
let mut stsz = vec![0u8; 4];
|
||||
stsz.extend(size.to_be_bytes());
|
||||
stsz.extend(1u32.to_be_bytes());
|
||||
let mut co64 = vec![0u8; 4];
|
||||
co64.extend(1u32.to_be_bytes());
|
||||
co64.extend(offset.to_be_bytes());
|
||||
let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat();
|
||||
box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables))))
|
||||
}
|
||||
|
||||
fn synthetic_header() -> Vec<u8> {
|
||||
use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t};
|
||||
let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap();
|
||||
let cmt1 = tiff(
|
||||
IfdBuilder::new()
|
||||
.with(t::MAKE, Value::Ascii("Canon".into()))
|
||||
.with(t::MODEL, Value::Ascii("EOS Test".into()))
|
||||
.with(t::ORIENTATION, Value::Short(vec![6])),
|
||||
);
|
||||
let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800])));
|
||||
let cmt3 = tiff(
|
||||
IfdBuilder::new()
|
||||
.with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45]))
|
||||
.with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())),
|
||||
);
|
||||
let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
|
||||
canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat());
|
||||
let mut coding = vec![0u8; 52];
|
||||
coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
|
||||
coding[2..4].copy_from_slice(&48u16.to_be_bytes());
|
||||
coding[4..6].copy_from_slice(&0x100u16.to_be_bytes());
|
||||
for (at, value) in [(8, 64u32), (12, 48), (16, 32), (20, 48), (28, 216)] {
|
||||
coding[at..at + 4].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
coding[24] = 14;
|
||||
coding[25] = 0x40;
|
||||
let mut area = vec![0u8; 48];
|
||||
for (at, value) in [
|
||||
(4, 64u16),
|
||||
(6, 48),
|
||||
(10, 2),
|
||||
(16, 6),
|
||||
(18, 4),
|
||||
(20, 61),
|
||||
(22, 45),
|
||||
(28, 3),
|
||||
(30, 47),
|
||||
(32, 4),
|
||||
(36, 63),
|
||||
(38, 1),
|
||||
(40, 4),
|
||||
(42, 2),
|
||||
(44, 63),
|
||||
(46, 47),
|
||||
] {
|
||||
area[at..at + 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let mut craw = vec![0u8; 82];
|
||||
craw[24..26].copy_from_slice(&64u16.to_be_bytes());
|
||||
craw[26..28].copy_from_slice(&48u16.to_be_bytes());
|
||||
craw.extend(box_bytes(b"CMP1", &coding));
|
||||
craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", &area)].concat()));
|
||||
let levels = maker(48, 105, 363, 641).value(COLOR_DATA).unwrap().clone();
|
||||
let dynamic_maker = tiff(IfdBuilder::new().with(COLOR_DATA, levels));
|
||||
let mut item = ((dynamic_maker.len() + 8) as u32).to_le_bytes().to_vec();
|
||||
item.extend(0x927cu32.to_le_bytes());
|
||||
item.extend(dynamic_maker);
|
||||
let mut timed = vec![0u8; 12];
|
||||
timed[0..4].copy_from_slice(&((12 + item.len()) as u32).to_le_bytes());
|
||||
timed[4..6].copy_from_slice(&8u16.to_le_bytes());
|
||||
timed.extend(item);
|
||||
// Two tiles, each with four one-byte lossless plane streams. Header
|
||||
// probing checks the complete marker layout without decoding entropy.
|
||||
let mut sample = Vec::new();
|
||||
let marker = |out: &mut Vec<u8>, code: u16, size: u32, flags: u32| {
|
||||
out.extend(code.to_be_bytes());
|
||||
out.extend(8u16.to_be_bytes());
|
||||
out.extend(size.to_be_bytes());
|
||||
out.extend(flags.to_be_bytes());
|
||||
};
|
||||
for tile in 0..2 {
|
||||
marker(&mut sample, 0xff01, 4, tile << 16);
|
||||
for plane in 0..4 {
|
||||
marker(&mut sample, 0xff02, 1, (plane << 28) | 0x0800_0000);
|
||||
marker(&mut sample, 0xff03, 1, 0x0020_0000);
|
||||
}
|
||||
}
|
||||
assert_eq!(sample.len(), 216);
|
||||
sample.extend([0x1e; 8]);
|
||||
let moov =
|
||||
[box_bytes(b"uuid", &canon), track(&box_bytes(b"CRAW", &craw), 4096, 224), track(&box_bytes(b"CTMD", &[]), 4320, timed.len() as u32)]
|
||||
.concat();
|
||||
let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(box_bytes(b"moov", &moov));
|
||||
assert!(file.len() < 4096);
|
||||
file.resize(4096, 0);
|
||||
file.extend(sample);
|
||||
file.extend(timed);
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_reads_timed_white_balance_and_distinct_geometry() {
|
||||
let bytes = synthetic_header();
|
||||
let image = decode(&bytes, Mode::Header).unwrap();
|
||||
assert_eq!((image.width, image.height, image.bits), (64, 48, 14));
|
||||
assert_eq!(image.active_area, Rect::new(4, 2, 60, 46));
|
||||
assert_eq!(image.crop, Rect::new(2, 2, 56, 42));
|
||||
assert_eq!(image.wb_multipliers, Some([2.0, 1.0, 1.5]));
|
||||
assert_eq!(image.black.values, vec![100.0, 101.0, 102.0, 103.0]);
|
||||
assert_eq!(image.white, vec![15000.0]);
|
||||
assert_eq!(image.metadata.iso, Some(800));
|
||||
assert_eq!(image.metadata.lens_model.as_deref(), Some("RF-S18-45mm F4.5-6.3 IS STM"));
|
||||
assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(42)));
|
||||
assert_eq!(image.orientation, crate::Orientation::Rotate90);
|
||||
assert!(image.data.is_empty());
|
||||
assert!(decode(&bytes[..4200], Mode::Header).is_err());
|
||||
}
|
||||
|
||||
fn prepend_tracks(bytes: &[u8], tracks: &[u8]) -> Vec<u8> {
|
||||
let ftyp_end = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize;
|
||||
let moov_len = u32::from_be_bytes(bytes[ftyp_end..ftyp_end + 4].try_into().unwrap()) as usize;
|
||||
assert_eq!(&bytes[ftyp_end + 4..ftyp_end + 8], b"moov");
|
||||
let moov = [tracks, &bytes[ftyp_end + 8..ftyp_end + moov_len]].concat();
|
||||
let mut out = [bytes[..ftyp_end].to_vec(), box_bytes(b"moov", &moov)].concat();
|
||||
assert!(out.len() < 4096);
|
||||
out.resize(4096, 0);
|
||||
out.extend_from_slice(&bytes[4096..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_preview_with_equal_dimensions_does_not_replace_the_sensor_track() {
|
||||
let bytes = synthetic_header();
|
||||
let mut preview = vec![0u8; 82];
|
||||
preview[24..26].copy_from_slice(&64u16.to_be_bytes());
|
||||
preview[26..28].copy_from_slice(&48u16.to_be_bytes());
|
||||
preview.extend(box_bytes(b"HEVC", &[0; 4]));
|
||||
let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &preview), 4096, 224));
|
||||
let container = parse_cr3(&bytes).unwrap();
|
||||
assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC"));
|
||||
let image = decode(&bytes, Mode::Header).unwrap();
|
||||
assert_eq!((image.width, image.height, image.bits), (64, 48, 14));
|
||||
assert_eq!(image.active_area, Rect::new(4, 2, 60, 46));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_main_descriptor_is_not_replaced_by_the_reduced_raw() {
|
||||
let bytes = synthetic_header();
|
||||
let container = parse_cr3(&bytes).unwrap();
|
||||
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("raw fixture") };
|
||||
let mut reduced = vec![0u8; 82];
|
||||
reduced[24..26].copy_from_slice(&32u16.to_be_bytes());
|
||||
reduced[26..28].copy_from_slice(&24u16.to_be_bytes());
|
||||
reduced.extend(box_bytes(b"CMP1", &bytes[at..at + len]));
|
||||
let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &reduced), 4096, 224));
|
||||
let container = parse_cr3(&bytes).unwrap();
|
||||
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("main raw fixture") };
|
||||
let mut malformed = bytes.clone();
|
||||
malformed[at..at + len].fill(0);
|
||||
assert!(matches!(decode(&malformed, Mode::Header), Err(RawError::Corrupt(_))));
|
||||
let mut missing = bytes;
|
||||
missing[at - 4..at].copy_from_slice(b"free");
|
||||
assert!(matches!(decode(&missing, Mode::Header), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_data_uses_versioned_as_shot_offsets() {
|
||||
for (version, wb_at, black_at, white_at) in
|
||||
[(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661)]
|
||||
{
|
||||
let m = maker(version, wb_at, black_at, white_at);
|
||||
let levels = camera_levels(Some(&m), 14);
|
||||
assert_eq!(levels.wb, Some([2.0, 1.0, 1.5]));
|
||||
assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0]));
|
||||
assert_eq!(levels.white, Some(15000.0));
|
||||
}
|
||||
assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none());
|
||||
assert!(camera_levels(None, 14).black.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_is_relative_to_the_valid_sensor_area() {
|
||||
let area = Cr3ImageArea {
|
||||
width: 6888,
|
||||
height: 4546,
|
||||
crop: [156, 158, 6875, 4537],
|
||||
active: Some([144, 46, 6887, 4545]),
|
||||
masked_left: [0, 0, 143, 4545],
|
||||
masked_top: None,
|
||||
};
|
||||
assert_eq!(geometry(Some(&area), None, 6888, 4546), (Rect::new(144, 46, 6744, 4500), Rect::new(12, 112, 6720, 4380)));
|
||||
assert_eq!(inclusive_rect([152, 46, 4353, 2851], 4352, 2850), Some(Rect::new(152, 46, 4200, 2804)));
|
||||
assert_eq!(inclusive_rect([100, 100, 50, 50], 200, 200), None);
|
||||
assert_eq!(relative_crop(Rect::new(0, 0, 100, 100), Rect::new(4, 4, 92, 92)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_full_size_track_wins_over_dual_pixel_delta() {
|
||||
let raw = |w, h, offset| Cr3Track { kind: Cr3TrackKind::Raw { width: w, height: h, cmp1: None, iad1: None }, data: Some((offset, 100)) };
|
||||
let tracks = [raw(1624, 1080, 1), raw(6888, 4546, 2), raw(6888, 4546, 3)];
|
||||
assert_eq!(main_track(&tracks).unwrap().data, Some((2, 100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cfa_and_black_follow_the_full_sensor_origin() {
|
||||
for (code, name) in [(0, "RGGB"), (1, "GRBG"), (2, "GBRG"), (3, "BGGR")] {
|
||||
assert_eq!(cfa(code).unwrap().name(), name);
|
||||
}
|
||||
let b = black_at_active([100.0, 101.0, 102.0, 103.0], 0, Rect::new(3, 1, 32, 20));
|
||||
assert_eq!(b.values, vec![103.0, 102.0, 101.0, 100.0]);
|
||||
}
|
||||
}
|
||||
Vendored
+1032
File diff suppressed because it is too large.
Load diff
Vendored
+338
@@ -0,0 +1,338 @@
|
||||
//! Reversible integer Le Gall 5/3 synthesis for Canon CRX subbands.
|
||||
//!
|
||||
//! Independent implementation of the lifting equations in ITU-T T.800, Annex F;
|
||||
//! the equations are also printed in US20030031370A1, paragraph 0047.
|
||||
//! Each tile level has even local origin. Internal boundaries retain the
|
||||
//! neighbouring low/high coefficients; only outer image edges are symmetric.
|
||||
|
||||
use crate::{RawError, Result};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Band {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub data: Vec<i32>,
|
||||
}
|
||||
|
||||
fn corrupt() -> RawError {
|
||||
RawError::Corrupt("invalid CRX wavelet subband geometry".into())
|
||||
}
|
||||
|
||||
fn checked_sample(v: i64) -> Result<i32> {
|
||||
i32::try_from(v).map_err(|_| RawError::Corrupt("CRX wavelet coefficient overflow".into()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct TileEdges {
|
||||
pub left: bool,
|
||||
pub right: bool,
|
||||
pub top: bool,
|
||||
pub bottom: bool,
|
||||
}
|
||||
|
||||
fn sizes(length: usize, before: bool, after: bool) -> (usize, usize) {
|
||||
(length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after))
|
||||
}
|
||||
|
||||
fn inverse_line(low: &[i32], high: &[i32], out: &mut [i32], before: bool, after: bool) -> Result<()> {
|
||||
let (low_count, high_count) = sizes(out.len(), before, after);
|
||||
if out.is_empty() || low.len() != low_count || high.len() != high_count {
|
||||
return Err(corrupt());
|
||||
}
|
||||
if high.is_empty() {
|
||||
let value = low.first().copied().ok_or_else(corrupt)?;
|
||||
*out.first_mut().ok_or_else(corrupt)? = value;
|
||||
return Ok(());
|
||||
}
|
||||
// The high-pass storage starts at -1 when there is a preceding tile.
|
||||
// Only absent external neighbours are reflected at the image boundary.
|
||||
let high_at = |index: usize| -> Result<i32> { high.get(index.min(high.len().saturating_sub(1))).copied().ok_or_else(corrupt) };
|
||||
let even = |i: usize| -> Result<i32> {
|
||||
let left = high_at((i + usize::from(before)).saturating_sub(1))?;
|
||||
let right = high_at(i + usize::from(before))?;
|
||||
let update = (i64::from(left) + i64::from(right) + 2).div_euclid(4);
|
||||
checked_sample(i64::from(*low.get(i).ok_or_else(corrupt)?) - update)
|
||||
};
|
||||
for i in 0..out.len().div_ceil(2) {
|
||||
*out.get_mut(i * 2).ok_or_else(corrupt)? = even(i)?;
|
||||
}
|
||||
for i in 0..out.len() / 2 {
|
||||
let left = out.get(i * 2).copied().ok_or_else(corrupt)?;
|
||||
let right = match out.get(i * 2 + 2).copied() {
|
||||
Some(value) => value,
|
||||
None if after => even(i + 1)?,
|
||||
None => left,
|
||||
};
|
||||
let prediction = (i64::from(left) + i64::from(right)).div_euclid(2);
|
||||
let value = high_at(i + usize::from(before))?;
|
||||
*out.get_mut(i * 2 + 1).ok_or_else(corrupt)? = checked_sample(i64::from(value) + prediction)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_band(band: &Band, width: usize, height: usize) -> bool {
|
||||
band.width == width && band.height == height && width.checked_mul(height) == Some(band.data.len())
|
||||
}
|
||||
|
||||
/// A zeroed buffer, or a limit error instead of an abort when the allocation fails.
|
||||
fn zeroed(count: usize) -> Result<Vec<i32>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX wavelet allocation"))?;
|
||||
v.resize(count, 0);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// One inverse level. `HL` is horizontally high-pass, `LH` vertically high-pass.
|
||||
pub(crate) fn inverse_level(ll: &Band, hl: &Band, lh: &Band, hh: &Band, width: usize, height: usize, edges: TileEdges) -> Result<Band> {
|
||||
let count = width.checked_mul(height).filter(|n| *n > 0).ok_or_else(corrupt)?;
|
||||
if count > crate::MAX_SAMPLES {
|
||||
return Err(RawError::Limit("CRX wavelet image size"));
|
||||
}
|
||||
let (lw, hw) = sizes(width, edges.left, edges.right);
|
||||
let (lhgt, hhgt) = sizes(height, edges.top, edges.bottom);
|
||||
if !valid_band(ll, lw, lhgt) || !valid_band(hl, hw, lhgt) || !valid_band(lh, lw, hhgt) || !valid_band(hh, hw, hhgt) {
|
||||
return Err(corrupt());
|
||||
}
|
||||
let intermediate_count = width.checked_mul(lhgt + hhgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("CRX wavelet image size"))?;
|
||||
let mut temporary = zeroed(intermediate_count)?;
|
||||
// CRX analysis is vertical then horizontal. Integer rounding means that
|
||||
// synthesis must reverse that order, including for quantized subbands.
|
||||
for y in 0..lhgt + hhgt {
|
||||
let (lo, hi, by) = if y < lhgt { (ll, hl, y) } else { (lh, hh, y - lhgt) };
|
||||
inverse_line(
|
||||
lo.data.get(by * lw..(by + 1) * lw).ok_or_else(corrupt)?,
|
||||
hi.data.get(by * hw..(by + 1) * hw).ok_or_else(corrupt)?,
|
||||
temporary.get_mut(y * width..(y + 1) * width).ok_or_else(corrupt)?,
|
||||
edges.left,
|
||||
edges.right,
|
||||
)?;
|
||||
}
|
||||
let mut data = zeroed(count)?;
|
||||
let mut low = zeroed(lhgt)?;
|
||||
let mut high = zeroed(hhgt)?;
|
||||
let mut column = zeroed(height)?;
|
||||
for x in 0..width {
|
||||
for (y, value) in low.iter_mut().enumerate() {
|
||||
*value = temporary.get(y * width + x).copied().ok_or_else(corrupt)?;
|
||||
}
|
||||
for (y, value) in high.iter_mut().enumerate() {
|
||||
*value = temporary.get((y + lhgt) * width + x).copied().ok_or_else(corrupt)?;
|
||||
}
|
||||
inverse_line(&low, &high, &mut column, edges.top, edges.bottom)?;
|
||||
for (y, &value) in column.iter().enumerate() {
|
||||
*data.get_mut(y * width + x).ok_or_else(corrupt)? = value;
|
||||
}
|
||||
}
|
||||
Ok(Band { width, height, data })
|
||||
}
|
||||
|
||||
/// Subbands in CRX order: LLn, HLn, LHn, HHn, ... HL1, LH1, HH1.
|
||||
pub(crate) fn reconstruct(bands: &[Band], width: usize, height: usize, levels: u8, edges: TileEdges) -> Result<Vec<i32>> {
|
||||
if levels > 3 || bands.len() != 1 + usize::from(levels) * 3 || width == 0 || height == 0 {
|
||||
return Err(corrupt());
|
||||
}
|
||||
let count = width.checked_mul(height).ok_or_else(corrupt)?;
|
||||
if count > crate::MAX_SAMPLES {
|
||||
return Err(RawError::Limit("CRX wavelet image size"));
|
||||
}
|
||||
let mut dimensions = vec![(width, height)];
|
||||
for _ in 0..levels {
|
||||
let &(w, h) = dimensions.last().ok_or_else(corrupt)?;
|
||||
dimensions.push((sizes(w, edges.left, edges.right).0, sizes(h, edges.top, edges.bottom).0));
|
||||
}
|
||||
let &(low_width, low_height) = dimensions.last().ok_or_else(corrupt)?;
|
||||
if !bands.first().is_some_and(|b| valid_band(b, low_width, low_height)) {
|
||||
return Err(corrupt());
|
||||
}
|
||||
let mut ll = bands.first().cloned().ok_or_else(corrupt)?;
|
||||
for step in 0..usize::from(levels) {
|
||||
let &(w, h) = dimensions.get(usize::from(levels) - step - 1).ok_or_else(corrupt)?;
|
||||
let index = 1 + step * 3;
|
||||
ll = inverse_level(
|
||||
&ll,
|
||||
bands.get(index).ok_or_else(corrupt)?,
|
||||
bands.get(index + 1).ok_or_else(corrupt)?,
|
||||
bands.get(index + 2).ok_or_else(corrupt)?,
|
||||
w,
|
||||
h,
|
||||
edges,
|
||||
)?;
|
||||
}
|
||||
if !valid_band(&ll, width, height) {
|
||||
return Err(corrupt());
|
||||
}
|
||||
Ok(ll.data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn integer_lifting_preserves_signed_rounding_and_symmetric_ends() {
|
||||
let mut out = [0; 9];
|
||||
inverse_line(&[6, 11, 8, 6, 3], &[2, 2, -1, -2], &mut out, false, false).unwrap();
|
||||
assert_eq!(out, [5, 9, 10, 11, 8, 6, 7, 3, 4]);
|
||||
let mut out = [0; 2];
|
||||
inverse_line(&[25], &[20], &mut out, false, false).unwrap();
|
||||
assert_eq!(out, [15, 35]);
|
||||
let mut out = [0];
|
||||
inverse_line(&[-27], &[], &mut out, false, false).unwrap();
|
||||
assert_eq!(out, [-27]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_dimensional_subband_order() {
|
||||
let band = |value| Band { width: 1, height: 1, data: vec![value] };
|
||||
let out = reconstruct(&[band(25), band(10), band(20), band(0)], 2, 2, 1, TileEdges::default()).unwrap();
|
||||
assert_eq!(out, [10, 20, 30, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_geometry_and_overflow_are_errors() {
|
||||
let mut out = [0; 2];
|
||||
assert!(inverse_line(&[], &[0], &mut out, false, false).is_err());
|
||||
assert!(inverse_line(&[i32::MAX], &[i32::MIN], &mut out, false, false).is_err());
|
||||
let b = Band { width: 1, height: 1, data: vec![0] };
|
||||
assert!(inverse_level(&b, &b, &b, &b, usize::MAX, 2, TileEdges::default()).is_err());
|
||||
assert!(reconstruct(&[b], 2, 2, 0, TileEdges::default()).is_err());
|
||||
}
|
||||
|
||||
// Independent forward lifting in tests checks all odd/even boundary
|
||||
// combinations; hand-computed vectors above also establish sign/order.
|
||||
fn forward_line(input: &[i32]) -> (Vec<i32>, Vec<i32>) {
|
||||
let high: Vec<_> = (0..input.len() / 2)
|
||||
.map(|i| input[i * 2 + 1] - (input[i * 2] + input.get(i * 2 + 2).copied().unwrap_or(input[i * 2])).div_euclid(2))
|
||||
.collect();
|
||||
let low = input
|
||||
.iter()
|
||||
.step_by(2)
|
||||
.enumerate()
|
||||
.map(|(i, &v)| {
|
||||
if high.is_empty() {
|
||||
v
|
||||
} else {
|
||||
v + (high[i.saturating_sub(1)] + high.get(i).copied().unwrap_or(high[high.len() - 1]) + 2).div_euclid(4)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(low, high)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_boundary_parity_round_trips_signed_coefficients() {
|
||||
for width in 1..=17usize {
|
||||
for height in 1..=17usize {
|
||||
let input: Vec<i32> = (0..width * height).map(|i| ((i * 193 + i * i * 17) % 1001) as i32 - 500).collect();
|
||||
let (lw, hw, lhgt, hhgt) = (width.div_ceil(2), width / 2, height.div_ceil(2), height / 2);
|
||||
let mut vertical = vec![0; width * height];
|
||||
for x in 0..width {
|
||||
let column: Vec<_> = (0..height).map(|y| input[y * width + x]).collect();
|
||||
let (lo, hi) = forward_line(&column);
|
||||
for (y, value) in lo.into_iter().chain(hi).enumerate() {
|
||||
vertical[y * width + x] = value;
|
||||
}
|
||||
}
|
||||
let mut bands = [
|
||||
Band { width: lw, height: lhgt, data: vec![0; lw * lhgt] },
|
||||
Band { width: hw, height: lhgt, data: vec![0; hw * lhgt] },
|
||||
Band { width: lw, height: hhgt, data: vec![0; lw * hhgt] },
|
||||
Band { width: hw, height: hhgt, data: vec![0; hw * hhgt] },
|
||||
];
|
||||
for y in 0..height {
|
||||
let (lo, hi) = forward_line(&vertical[y * width..(y + 1) * width]);
|
||||
let (b, by) = if y < lhgt { (0, y) } else { (2, y - lhgt) };
|
||||
bands[b].data[by * lw..(by + 1) * lw].copy_from_slice(&lo);
|
||||
bands[b + 1].data[by * hw..(by + 1) * hw].copy_from_slice(&hi);
|
||||
}
|
||||
assert_eq!(reconstruct(&bands, width, height, 1, TileEdges::default()).unwrap(), input, "{width}x{height}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Independently generated from ITU-T T.800 integer 5/3 forward equations.
|
||||
// Original40x9 samples: ((x*23+y*37+x*y*7)%101)-50.
|
||||
// Tile width20 creates the same level3phase4 mismatch as R1001572.
|
||||
fn left_halo_fixture() -> Vec<Band> {
|
||||
vec![
|
||||
Band { width: 4, height: 2, data: vec![1, 1, -4, 1, 20, -2, -1, 2] },
|
||||
Band { width: 4, height: 2, data: vec![35, 21, -2, 19, -24, -10, -7, -2] },
|
||||
Band { width: 4, height: 1, data: vec![31, -16, -19, -7] },
|
||||
Band { width: 4, height: 1, data: vec![22, 9, 20, 18] },
|
||||
Band { width: 6, height: 3, data: vec![24, -34, -55, -21, 2, 7, 25, 5, -3, 14, 31, 14, 24, 37, 55, 19, -42, -41] },
|
||||
Band { width: 6, height: 2, data: vec![9, -21, 20, -4, 31, -6, -32, -3, 7, 31, 14, -11] },
|
||||
Band { width: 6, height: 2, data: vec![3, 62, 40, 50, 50, 51, -3, -62, -38, -45, 50, 35] },
|
||||
Band {
|
||||
width: 11,
|
||||
height: 5,
|
||||
data: vec![
|
||||
13, -38, -12, 13, -50, -13, 13, 39, 25, -38, 38, -44, 6, 0, -44, -50, 57, 0, -6, 38, 19, -13, 26, 26, 39, -37, -62, -37, -37,
|
||||
-25, -25, -44, -44, 6, -44, -12, 57, 39, -44, 13, 32, -18, 0, 57, -38, 13, 13, -13, 51, 13, -12, -38, 13, 13, -13,
|
||||
],
|
||||
},
|
||||
Band {
|
||||
width: 11,
|
||||
height: 4,
|
||||
data: vec![
|
||||
13, 39, 0, -25, 6, -6, 26, 0, -43, 20, -25, -50, 26, 7, -19, 0, 13, 32, -6, -25, 38, 32, 1, 26, -44, 19, 0, -12, -6, 45, -25, -6,
|
||||
32, -37, 39, -12, 26, -6, 6, -25, 0, 39, -12, 26,
|
||||
],
|
||||
},
|
||||
Band {
|
||||
width: 11,
|
||||
height: 4,
|
||||
data: vec![
|
||||
26, -75, 76, 25, 0, -25, -75, 76, -50, -75, -25, 0, -101, -75, 0, 0, 51, 76, 101, 0, -51, -25, -101, 0, 25, 51, -50, 0, -25, 0,
|
||||
101, 76, 51, -75, 26, -75, -25, 0, 25, 76, -75, 26, -75, -25,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
fn right_halo_fixture() -> Vec<Band> {
|
||||
vec![
|
||||
Band { width: 3, height: 2, data: vec![-12, 8, -5, 0, 4, -2] },
|
||||
Band { width: 3, height: 2, data: vec![-10, -5, -15, 8, 6, 5] },
|
||||
Band { width: 3, height: 1, data: vec![-10, -14, -10] },
|
||||
Band { width: 3, height: 1, data: vec![-17, -14, -25] },
|
||||
Band { width: 6, height: 3, data: vec![2, 7, -4, 7, 26, 38, 31, 14, 6, -12, -2, -22, -42, -41, -47, -7, 10, 44] },
|
||||
Band { width: 5, height: 2, data: vec![-6, 15, 31, -7, 12, -11, 6, 9, 5, -5] },
|
||||
Band { width: 6, height: 2, data: vec![50, 51, -46, -36, -58, -61, 50, 35, 69, 18, -36, -21] },
|
||||
Band {
|
||||
width: 11,
|
||||
height: 5,
|
||||
data: vec![
|
||||
-38, 38, -13, 13, -38, 39, -25, -38, 63, 13, 49, 19, -13, -57, 70, 25, -6, -12, 32, 0, -44, -51, -44, -44, -50, -37, -37, -25, 1,
|
||||
-31, -44, -50, -50, 0, 57, -37, -44, 13, 32, -12, 6, 57, -37, -36, 13, -13, 77, 13, -12, -38, -25, -25, -13, 77, -22,
|
||||
],
|
||||
},
|
||||
Band {
|
||||
width: 10,
|
||||
height: 4,
|
||||
data: vec![
|
||||
-25, 39, 0, -37, 25, -18, 45, -12, -37, 19, 32, -6, 13, 32, -6, 1, -25, 45, -6, 0, 32, 13, 0, -6, 45, -25, -6, 32, 13, 0, 26, 7,
|
||||
19, -25, 0, 45, -50, 32, 7, 13,
|
||||
],
|
||||
},
|
||||
Band {
|
||||
width: 11,
|
||||
height: 4,
|
||||
data: vec![
|
||||
-75, -25, -25, 25, 25, 76, 51, -75, 25, 25, 51, -51, -25, 0, 51, 76, 101, 101, 0, -25, 0, 0, 76, 51, 0, 0, -25, 0, 101, 76, 51,
|
||||
0, 0, -75, -25, 51, 25, 76, -75, 51, -50, -25, 51, 0,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_levels_keep_internal_tile_halos_and_local_phase() {
|
||||
for (tile, bands, edges) in [
|
||||
(0, left_halo_fixture(), TileEdges { right: true, ..Default::default() }),
|
||||
(1, right_halo_fixture(), TileEdges { left: true, ..Default::default() }),
|
||||
] {
|
||||
let expected: Vec<i32> =
|
||||
(0..9).flat_map(|y| (tile * 20..tile * 20 + 20).map(move |x| ((x * 23 + y * 37 + x * y * 7) % 101) - 50)).collect();
|
||||
assert_eq!(reconstruct(&bands, 20, 9, 3, edges).unwrap(), expected);
|
||||
assert!(reconstruct(&bands, 20, 9, 3, TileEdges::default()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -2,6 +2,9 @@
|
||||
|
||||
pub mod arw;
|
||||
pub mod cr2;
|
||||
pub mod cr3;
|
||||
pub mod crx;
|
||||
pub(crate) mod crx_wavelet;
|
||||
pub mod nef;
|
||||
pub mod nefc;
|
||||
pub mod orf;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with
|
||||
//! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent.
|
||||
//!
|
||||
//! Every file must be recognised, carry an embedded JPEG preview (DNG: optional), and either decode to a valid image or report
|
||||
//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report
|
||||
//! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times
|
||||
//! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`).
|
||||
|
||||
@@ -15,7 +15,7 @@ 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)
|
||||
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
|
||||
"orf-olympus-em", // Olympus compressed ORF
|
||||
"sraw", // Canon sRAW / mRAW
|
||||
];
|
||||
@@ -41,11 +41,14 @@ fn corpus_raw_decodes() {
|
||||
let preview = embedded_preview(&bytes);
|
||||
let tp = t0.elapsed().as_secs_f64() * 1e3;
|
||||
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
|
||||
// except the Panasonic `.RAW` files of 2005–2007
|
||||
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
|
||||
if let Some(p) = &preview {
|
||||
assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG");
|
||||
} else {
|
||||
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-"), "{name}: no embedded preview");
|
||||
let hevc_preview = fmt == RawFormat::Cr3
|
||||
&& lightcraft_meta::cr3::parse_cr3(&bytes)
|
||||
.is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some()));
|
||||
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview");
|
||||
}
|
||||
let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024);
|
||||
let t1 = Instant::now();
|
||||
@@ -85,7 +88,7 @@ fn corpus_raw_decodes() {
|
||||
Err(e) => panic!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (preview only)");
|
||||
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (embedded JPEG when available)");
|
||||
}
|
||||
|
||||
/// Green-channel means of 32×32 blocks.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Exact sensor-pixel regressions, not embedded-JPEG comparisons.
|
||||
//!
|
||||
//! Public-domain inputs from raw.pixls.us, kept in the gitignored corpus/raw.
|
||||
//! Reference pixels were independently obtained by running the DNGLab v0.8.0
|
||||
//! release executable as a black-box oracle (analyze --raw-pixel). Its source
|
||||
//! and camera matrices were not used. Canonical FNV-1a hashes below are over
|
||||
//! decoded u16 samples in row-major, little-endian order, including masked borders.
|
||||
//! macOS oracle archive SHA256:
|
||||
//! ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e.
|
||||
|
||||
use lightcraft_raw::{RawData, RawFormat, decode, probe_info};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn corpus() -> PathBuf {
|
||||
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
}
|
||||
|
||||
fn check(name: &str, hash: u64, sensor: (usize, usize)) {
|
||||
let path = corpus().join("raw").join(name);
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
raw.validate().unwrap();
|
||||
assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1));
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
assert_eq!(raw.info(), probe_info(&bytes).unwrap());
|
||||
let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") };
|
||||
let actual =
|
||||
samples.iter().flat_map(|v| v.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3));
|
||||
assert_eq!(actual, hash, "{name}: sensor pixels differ from the independent reference");
|
||||
assert!(raw.wb_multipliers.is_some(), "{name}: as-shot white balance missing");
|
||||
assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_m50_lossless_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/4657; SHA256
|
||||
// 1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058
|
||||
check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_r100_lossless_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/7896; SHA256
|
||||
// 6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521
|
||||
check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_m50_craw_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/2663; SHA256
|
||||
// 15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c
|
||||
check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_r100_craw_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/7897; SHA256
|
||||
// 0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942
|
||||
check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_r8_lossless_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/6585; SHA256
|
||||
// 7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c
|
||||
check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_r8_craw_sensor_is_exact() {
|
||||
// raw.pixls.us/getfile.php/6587; SHA256
|
||||
// df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812
|
||||
check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120));
|
||||
}
|
||||
@@ -69,6 +69,12 @@
|
||||
"Label Set: {name}": "Farbmarkierungssatz: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}",
|
||||
"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}%",
|
||||
@@ -86,11 +92,13 @@
|
||||
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
|
||||
"Quick Develop: {name}": "Schnellentwicklung: {name}",
|
||||
"Rated {}": "Bewertet mit {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}",
|
||||
"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 from {}": "Entfernt von {}",
|
||||
"Removed {what} from {name}": "{what} aus {name} entfernt",
|
||||
"Rename {n} Photos…": "{n} Fotos umbenennen…",
|
||||
"Rename “{name}”": "„{name}“ umbenennen",
|
||||
@@ -99,6 +107,7 @@
|
||||
"Replace {} with:": "{} ersetzen durch:",
|
||||
"Reset {name}": "{name} zurücksetzen",
|
||||
"Reset {} Channel": "Kanal {} zurücksetzen",
|
||||
"Restore {}": "{} wiederherstellen",
|
||||
"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).",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden",
|
||||
"Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.",
|
||||
"Adding": "Wird hinzugefügt",
|
||||
"Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …",
|
||||
"Advanced search…": "Erweiterte Suche…",
|
||||
"After": "Nachher",
|
||||
"After 30 days": "Nach 30 Tagen",
|
||||
@@ -165,6 +166,7 @@
|
||||
"Blue Primary": "Primärfarbe Blau",
|
||||
"Blue Saturation": "Blau – Sättigung",
|
||||
"Blue channel": "Blaukanal",
|
||||
"Blue label": "Blaue Markierung",
|
||||
"Blue/Yellow Fringe": "Blaue/gelbe Farbsäume",
|
||||
"Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.",
|
||||
"Boundary Warp": "Randverkrümmung",
|
||||
@@ -208,6 +210,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….",
|
||||
"Check All": "Alle auswählen",
|
||||
"Check Output Path": "Ausgabepfad prüfen",
|
||||
"Check the files of this type": "Dateien dieses Typs auswählen",
|
||||
"Checking names…": "Namen werden geprüft…",
|
||||
"Choose Another Library…": "Andere Bibliothek auswählen…",
|
||||
"Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen",
|
||||
@@ -229,8 +232,11 @@
|
||||
"Clear Reference": "Referenz entfernen",
|
||||
"Clear all": "Alles löschen",
|
||||
"Clear form": "Formular leeren",
|
||||
"Clear rating": "Bewertung entfernen",
|
||||
"Clear rating and advance": "Bewertung entfernen und weiter",
|
||||
"Cleared History": "Verlauf gelöscht",
|
||||
"Click Zoom Ratio": "Zoomfaktor beim Klicken",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Auf ein Tastenkürzel klicken, dann die neuen Tasten drücken (Esc bricht ab).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus",
|
||||
@@ -511,6 +517,7 @@
|
||||
"File Size": "Dateigröße",
|
||||
"File Type": "Dateityp",
|
||||
"File name": "Dateiname",
|
||||
"File types:": "Dateitypen:",
|
||||
"Filename": "Dateiname",
|
||||
"Fill": "Ausfüllen",
|
||||
"Fill Edges": "Ränder füllen",
|
||||
@@ -536,6 +543,7 @@
|
||||
"Fit to width": "An Breite anpassen",
|
||||
"Fit visible": "Sichtbaren Bereich einpassen",
|
||||
"Five stars": "Fünf Sterne",
|
||||
"Fixed keys": "Feste Tasten",
|
||||
"Flag": "Markierung",
|
||||
"Flag as Pick": "Als ausgewählt markieren",
|
||||
"Flag as Reject": "Als abgelehnt markieren",
|
||||
@@ -598,6 +606,7 @@
|
||||
"Green Primary": "Primärfarbe Grün",
|
||||
"Green Saturation": "Grün – Sättigung",
|
||||
"Green channel": "Grünkanal",
|
||||
"Green label": "Grüne Markierung",
|
||||
"Grid": "Raster",
|
||||
"Grid Info": "Rasterinformationen",
|
||||
"Gritty Street": "Raue Straße",
|
||||
@@ -649,7 +658,6 @@
|
||||
"Horizontal": "Horizontal",
|
||||
"Horizontal text": "Horizontaler Text",
|
||||
"Hours": "Stunden",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Hue": "Farbton",
|
||||
"Hue Range": "Farbtonbereich",
|
||||
"ISO Speed": "ISO-Empfindlichkeit",
|
||||
@@ -659,6 +667,8 @@
|
||||
"Image": "Bild",
|
||||
"Import": "Importieren",
|
||||
"Import Date": "Importdatum",
|
||||
"Import Lightroom Catalog": "Lightroom-Katalog importieren",
|
||||
"Import Lightroom Catalog…": "Lightroom-Katalog importieren …",
|
||||
"Import Photos": "Fotos importieren",
|
||||
"Import Photos…": "Fotos importieren…",
|
||||
"Import Point Curve Presets": "Punktkurvenvorgaben importieren",
|
||||
@@ -678,7 +688,10 @@
|
||||
"Imported": "Importiert",
|
||||
"Imported Photos": "Importierte Fotos",
|
||||
"Importing": "Wird importiert",
|
||||
"Importing Lightroom Catalog": "Lightroom-Katalog wird importiert",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen",
|
||||
"In Recently Deleted": "In „Zuletzt gelöscht“",
|
||||
"In library": "In Bibliothek",
|
||||
"In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert",
|
||||
@@ -694,8 +707,9 @@
|
||||
"Info Overlay: File": "Infoüberlagerung: Datei",
|
||||
"Info overlay": "Infoüberlagerung",
|
||||
"Insert at the cursor": "An Cursorposition einfügen",
|
||||
"Inspect Lightroom Catalog": "Lightroom-Katalog prüfen",
|
||||
"Inspect Photo": "Foto untersuchen",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft",
|
||||
"Instant ’70s": "Sofortbild der 70er",
|
||||
"Interface": "Oberfläche",
|
||||
"Interface language": "Sprache der Oberfläche",
|
||||
@@ -762,6 +776,9 @@
|
||||
"LightCraft on GitHub": "LightCraft auf GitHub",
|
||||
"LightCraft website": "LightCraft-Website",
|
||||
"LightCraft — Second Window": "LightCraft — zweites Fenster",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic-Katalog",
|
||||
"Lightroom import cancelled": "Lightroom-Import abgebrochen",
|
||||
"Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen",
|
||||
"Lights": "Helle Mitteltöne",
|
||||
"Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)",
|
||||
"Line delta": "Zeilendifferenz",
|
||||
@@ -921,6 +938,7 @@
|
||||
"No filter presets yet": "Noch keine Filtervorgaben",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).",
|
||||
"No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.",
|
||||
"No matching commands": "Keine passenden Befehle",
|
||||
"No matching photos": "Keine passenden Fotos",
|
||||
"No metadata found": "Keine Metadaten gefunden",
|
||||
"No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.",
|
||||
@@ -1048,6 +1066,7 @@
|
||||
"Presets (Shift+P)": "Vorgaben (Umschalt+P)",
|
||||
"Presets and profiles": "Vorgaben und Profile",
|
||||
"Presets ▾": "Vorgaben ⌄",
|
||||
"Press keys…": "Tasten drücken …",
|
||||
"Preview": "Vorschau",
|
||||
"Preview Build Progress": "Fortschritt der Vorschauerstellung",
|
||||
"Preview only": "Nur Vorschau",
|
||||
@@ -1092,6 +1111,16 @@
|
||||
"Radius": "Radius",
|
||||
"Random": "Zufällig",
|
||||
"Range": "Bereich",
|
||||
"Rate ★": "Mit ★ bewerten",
|
||||
"Rate ★ and advance": "Mit ★ bewerten und weiter",
|
||||
"Rate ★★": "Mit ★★ bewerten",
|
||||
"Rate ★★ and advance": "Mit ★★ bewerten und weiter",
|
||||
"Rate ★★★": "Mit ★★★ bewerten",
|
||||
"Rate ★★★ and advance": "Mit ★★★ bewerten und weiter",
|
||||
"Rate ★★★★": "Mit ★★★★ bewerten",
|
||||
"Rate ★★★★ and advance": "Mit ★★★★ bewerten und weiter",
|
||||
"Rate ★★★★★": "Mit ★★★★★ bewerten",
|
||||
"Rate ★★★★★ and advance": "Mit ★★★★★ bewerten und weiter",
|
||||
"Rating": "Bewertung",
|
||||
"Rating cleared": "Bewertung entfernt",
|
||||
"Ratings & flags": "Bewertungen und Markierungen",
|
||||
@@ -1105,6 +1134,7 @@
|
||||
"Read mode": "Lesemodus",
|
||||
"Read the SAM License": "SAM-Lizenz lesen",
|
||||
"Reading": "Wird gelesen",
|
||||
"Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …",
|
||||
"Reading folder…": "Ordner wird gelesen…",
|
||||
"Real name": "Echter Name",
|
||||
"Recent": "Zuletzt verwendet",
|
||||
@@ -1118,6 +1148,7 @@
|
||||
"Red Primary": "Primärfarbe Rot",
|
||||
"Red Saturation": "Rot – Sättigung",
|
||||
"Red channel": "Rotkanal",
|
||||
"Red label": "Rote Markierung",
|
||||
"Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume",
|
||||
"Redo": "Wiederholen",
|
||||
"Reference": "Referenz",
|
||||
@@ -1158,6 +1189,7 @@
|
||||
"Remove from Target Album": "Aus Zielalbum entfernen",
|
||||
"Remove from survey": "Aus Übersicht entfernen",
|
||||
"Remove location info": "Ortsinformationen entfernen",
|
||||
"Remove shortcut": "Tastenkürzel entfernen",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)",
|
||||
"Remove this filter": "Diesen Filter entfernen",
|
||||
"Remove this group": "Diese Gruppe entfernen",
|
||||
@@ -1184,7 +1216,9 @@
|
||||
"Rendering…": "Wird gerendert…",
|
||||
"Reorder Masks": "Masken umordnen",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Reset All": "Alle zurücksetzen",
|
||||
"Reset All Curves": "Alle Kurven zurücksetzen",
|
||||
"Reset All Keyboard Shortcuts": "Alle Tastenkürzel zurücksetzen",
|
||||
"Reset Before to Import State": "Vorher auf Importzustand zurücksetzen",
|
||||
"Reset Control": "Regler zurücksetzen",
|
||||
"Reset Crop": "Zuschnitt zurücksetzen",
|
||||
@@ -1199,6 +1233,7 @@
|
||||
"Resize to": "Größe ändern auf",
|
||||
"Resolution (ppi)": "Auflösung (ppi)",
|
||||
"Restore": "Wiederherstellen",
|
||||
"Restore (no shortcut)": "Wiederherstellen (kein Tastenkürzel)",
|
||||
"Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…",
|
||||
"Restore Version": "Version wiederherstellen",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.",
|
||||
@@ -1245,6 +1280,7 @@
|
||||
"Save as set": "Als Satz speichern",
|
||||
"Save as…": "Speichern unter…",
|
||||
"Save preset": "Vorgabe speichern",
|
||||
"Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …",
|
||||
"Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine",
|
||||
"Scale": "Skalierung",
|
||||
"Scan failed": "Durchsuchen fehlgeschlagen",
|
||||
@@ -1253,6 +1289,7 @@
|
||||
"Screen": "Bildschirm",
|
||||
"Search": "Suche",
|
||||
"Search Photos": "Fotos suchen",
|
||||
"Search commands or keys": "Befehle oder Tasten suchen",
|
||||
"Search…": "Suchen…",
|
||||
"Seasons": "Jahreszeiten",
|
||||
"Second Window": "Zweites Fenster",
|
||||
@@ -1283,6 +1320,7 @@
|
||||
"Set Develop Values": "Entwicklungswerte festlegen",
|
||||
"Set Flag": "Markierung festlegen",
|
||||
"Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen",
|
||||
"Set Keyboard Shortcut": "Tastenkürzel festlegen",
|
||||
"Set Mask Adjustments": "Maskenkorrekturen festlegen",
|
||||
"Set Point Curve": "Punktkurve festlegen",
|
||||
"Set Profile": "Profil festlegen",
|
||||
@@ -1333,6 +1371,7 @@
|
||||
"Show Pins": "Pins anzeigen",
|
||||
"Show Source": "Quelle anzeigen",
|
||||
"Show Thumbnails": "Miniaturbilder anzeigen",
|
||||
"Show commands without a shortcut": "Befehle ohne Tastenkürzel anzeigen",
|
||||
"Show destination gamut warning": "Zielfarbraumwarnung anzeigen",
|
||||
"Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen",
|
||||
"Show file names": "Dateinamen anzeigen",
|
||||
@@ -1443,6 +1482,7 @@
|
||||
"Text direction": "Textrichtung",
|
||||
"Texture": "Struktur",
|
||||
"That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.",
|
||||
"That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.",
|
||||
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek",
|
||||
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält",
|
||||
@@ -1457,7 +1497,6 @@
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
|
||||
"Thirds": "Drittel",
|
||||
"This Computer": "Dieser Computer",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
|
||||
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
|
||||
"Threshold": "Schwellenwert",
|
||||
@@ -1485,6 +1524,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner",
|
||||
"Type": "Typ",
|
||||
"Uncheck All": "Alle abwählen",
|
||||
"Uncheck the files of this type": "Auswahl der Dateien dieses Typs aufheben",
|
||||
"Undo": "Rückgängig",
|
||||
"Unedited": "Unbearbeitet",
|
||||
"Unedited raws": "Unbearbeitete RAW-Fotos",
|
||||
@@ -1563,6 +1603,7 @@
|
||||
"Yellow Hue": "Gelb – Farbton",
|
||||
"Yellow Luminance": "Gelb – Luminanz",
|
||||
"Yellow Saturation": "Gelb – Sättigung",
|
||||
"Yellow label": "Gelbe Markierung",
|
||||
"Yes": "Ja",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.",
|
||||
"Your Name": "Dein Name",
|
||||
@@ -1750,5 +1791,8 @@
|
||||
"Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera",
|
||||
"Captured from": "Aufgenommen ab",
|
||||
"Captured until": "Aufgenommen bis",
|
||||
"Only": "Nur diese"
|
||||
"Only": "Nur diese",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen."
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
" ({unsure} with several look-alike files: use Locate)": " ({unsure} con varios archivos parecidos: usar Localizar)",
|
||||
" ({} in Recently Deleted)": " ({} en Eliminadas recientemente)",
|
||||
" · {:.0}% clipped": " · {:.0}% recortado",
|
||||
" · {failed} couldn't be rendered": " · no se pudieron renderizar: {failed}",
|
||||
" · {skipped} skipped (file exists)": " · omitidas: {skipped} (el archivo existe)",
|
||||
" · {} duplicate{} skipped": " · {0} duplicado{1} omitido{1}",
|
||||
" · {} failed: {}": " · {} con errores: {}",
|
||||
" · {} moved": " · movidas: {}",
|
||||
" · {} not readable": " · ilegibles: {}",
|
||||
" · {} original{} left at the source": " · originales conservados en el origen: {}{:.0}",
|
||||
" · {} restored from Recently Deleted": " · restauradas desde Eliminadas recientemente: {}",
|
||||
" — not carried over: {}{}": " — no transferido: {}{}",
|
||||
"+{} more": "+{} más",
|
||||
", {failed} file{} not readable": ", {failed} archivo{0} ilegible{0}",
|
||||
"; {kept} already had a location": "; ya tenían ubicación: {kept}",
|
||||
"; {outside} outside its time range": "; fuera de su intervalo de tiempo: {outside}",
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commits, +{} / −{} líneas (Δ {}), +{} / −{} recursos binarios, {} – {}",
|
||||
"Add {local_n} to My Photos": "Añadir {local_n} a Mis fotos",
|
||||
"Add {} Photo{}": "Añadir {} foto{}",
|
||||
"Add {} Photo{}, restore {}": "Añadir {} foto{}, restaurar {}",
|
||||
"Add “{}” to Favorites": "Añadir “{}” a Favoritos",
|
||||
"Added {n} photo{} to My Photos": "{n} foto{0} añadida{0} a Mis fotos",
|
||||
"Added {n} photo{} to “{}”": "{n} foto{0} añadida{0} a “{1}”",
|
||||
"Added {what} to {name}": "{what} añadido a {name}",
|
||||
"Adding photos… {} of {}": "Añadiendo fotos… {} de {}",
|
||||
"All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho ▸ Restaurar, o Eliminar definitivamente para importar de nuevo",
|
||||
"Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos",
|
||||
"Auto ({})": "Automático ({})",
|
||||
"Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}",
|
||||
"Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos",
|
||||
"Automatic ({} MB)": "Automático ({} MB)",
|
||||
"Build 1:1 Previews ({scope})": "Generar vistas previas 1:1 ({scope})",
|
||||
"Build Smart Previews ({scope})": "Generar vistas previas inteligentes ({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})",
|
||||
"Building previews for {} photos…": "Generando vistas previas de {} fotos…",
|
||||
"Click {}:1": "Clic {}:1",
|
||||
"Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}",
|
||||
"Create {n} Virtual Copies": "Crear {n} copias virtuales",
|
||||
"Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}",
|
||||
"Decoder: {reason}": "Decodificador: {reason}",
|
||||
"Delete \"{label}\"": "Eliminar \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)",
|
||||
"Delete {n} Photos": "Eliminar {n} fotos",
|
||||
"Delete “{current}”": "Eliminar “{current}”",
|
||||
"Discard Smart Previews ({scope})": "Descartar vistas previas inteligentes ({scope})",
|
||||
"Done ({what}): {done} photo{plural}": "Listo ({what}): {done} foto{plural}",
|
||||
"Duplicate \"{label}\"": "Duplicar \"{label}\"",
|
||||
"Example: {example}": "Ejemplo: {example}",
|
||||
"Exported {n} photo{}": "{n} foto{0} exportada{0}",
|
||||
"Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",
|
||||
"Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",
|
||||
"Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",
|
||||
"Exporting {} of {total}": "Exportando {} de {total}",
|
||||
"Filter bar — {active} active filter{}": "Barra de filtros: {active} filtro{0} activo{0}",
|
||||
"Focus {:.0}": "Enfoque {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "Carpeta “{}” (y sus subcarpetas)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "Fotos que faltaban encontradas: {n}{:.0}; siguen faltando: {left}{unsure}",
|
||||
"Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}",
|
||||
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
|
||||
"Import failed: {e}": "Error de importación: {e}",
|
||||
"Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}",
|
||||
"Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n} ajuste{0} preestablecido{0} importado{0}, {f} archivo{1} ilegible{1}",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Perfiles importados: {profiles}{:.0} (Explorador de perfiles ▸ sus grupos)",
|
||||
"Imported {} photo{}": "{0} foto{1} importada{1}",
|
||||
"Insert at the cursor (also written {})": "Insertar en el cursor (también se escribe {})",
|
||||
"Label Set: {name}": "Conjunto de etiquetas: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo de Lightroom inspeccionado: {photos} fotos, {collections} colecciones, {missing} no encontradas",
|
||||
"Lightroom import complete: {imported} photos added": "Importación de Lightroom completada: {imported} fotos añadidas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importación de Lightroom completada: {imported} fotos añadidas, {failed} con errores",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importación de Lightroom completada: {imported} fotos añadidas; aviso del índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Error al importar de Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Error al inspeccionar Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.",
|
||||
"Matches: {}": "Coincidencias: {}",
|
||||
"Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "¿Mover {what} a Eliminadas recientemente?",
|
||||
"Named ({named_n})": "Con nombre ({named_n})",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "Pintando “{k}”: hacer clic en las fotos para añadirla o quitarla · Esc para terminar",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Pegar en {n} foto{}: solo se pegan los ajustes copiados",
|
||||
"Preset: {name}": "Ajuste preestablecido: {name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "Solo vista previa: editando el JPEG incrustado de la cámara ({})",
|
||||
"Preview only — {}": "Solo vista previa: {}",
|
||||
"Previews ready for {done} photo{plural}": "Vistas previas listas para {done} foto{plural}",
|
||||
"Profile: {}": "Perfil: {}",
|
||||
"Proof Copy ({})": "Copia de prueba ({})",
|
||||
"Proof Preview · {}": "Vista previa de prueba · {}",
|
||||
"Quick Develop · {n} photos": "Revelado rápido · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelado rápido: {name}",
|
||||
"Rated {}": "Valorada con {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Leyendo catálogo de Lightroom… {done} de {total}",
|
||||
"Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Leyendo fotos… {} de {}",
|
||||
"Redo {}": "Rehacer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?",
|
||||
"Remove “{}” from Favorites": "Quitar “{}” de Favoritos",
|
||||
"Removed from {}": "Quitado de {}",
|
||||
"Removed {what} from {name}": "{what} quitado de {name}",
|
||||
"Rename {n} Photos…": "Cambiar nombre de {n} fotos…",
|
||||
"Rename “{name}”": "Cambiar nombre de “{name}”",
|
||||
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Cambia el nombre de “{from}” en {n} foto{} (también las palabras clave que dependen de ella). | separa niveles, p. ej. Viajes|Italia. Un nombre existente combina las dos.",
|
||||
"Rendering on the CPU: {why}": "Renderizado en la CPU: {why}",
|
||||
"Replace {} with:": "Reemplazar {} por:",
|
||||
"Reset {name}": "Restablecer {name}",
|
||||
"Reset {} Channel": "Restablecer canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{}",
|
||||
"Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).",
|
||||
"Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}",
|
||||
"Showing {n} of {}": "Mostrando {n} de {}",
|
||||
"Smart album: {}": "Álbum inteligente: {}",
|
||||
"Tagged {n} photo{} from the tracklog": "{n} foto{0} ubicada{0} desde el registro de ruta",
|
||||
"The SAM 3 download failed: {e}": "Error en la descarga de SAM 3: {e}",
|
||||
"Undo {}": "Deshacer {}",
|
||||
"Unknown tag{s} {} stay{} as typed (see Tags)": "Etiqueta{s} desconocida{s} {} sin cambios{:.0} (ver Etiquetas)",
|
||||
"Updated “{name}”": "“{name}” actualizado",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versión {}: biblioteca de fotos y revelador RAW de desarrollo independiente (clean-room) en Rust puro.",
|
||||
"Version: {}": "Versión: {}",
|
||||
"Virtual copy “{name}” of {of}": "Copia virtual “{name}” de {of}",
|
||||
"Working on {what} for {} photos…": "Procesando {what} para {} fotos…",
|
||||
"none (CPU rendering): {why}": "ninguna (renderizado por CPU): {why}",
|
||||
"the {n} selected photos": "las {n} fotos seleccionadas",
|
||||
"the {} photos in view": "las {} fotos de la vista",
|
||||
"these {count} photos": "estas {count} fotos",
|
||||
"{count} smart preview{} · {:.1} MB": "{count} vista{0} previa{0} inteligente{0} · {1:.1} MB",
|
||||
"{f} folders (and their subfolders)": "{f} carpetas (y sus subcarpetas)",
|
||||
"{label} Grading": "Gradación de {label}",
|
||||
"{label} {d:+} on every selected photo": "{label} {d:+} en cada foto seleccionada",
|
||||
"{label} {index} of {n}": "{label} {index} de {n}",
|
||||
"{label}, {n} photos": "{label}, {n} fotos",
|
||||
"{matching} of {t} photos": "{matching} de {t} fotos",
|
||||
"{matching} photos": "{matching} fotos",
|
||||
"{mode} spot {} of {}": "{mode}: mancha {} de {}",
|
||||
"{month}": "{month:02}",
|
||||
"{month} {year}": "{month:02}/{year}",
|
||||
"{msg} ({failed} failed)": "{msg} ({failed} con errores)",
|
||||
"{name} Label": "Etiqueta {name}",
|
||||
"{name} Off": "{name}: desactivado",
|
||||
"{name} On": "{name}: activado",
|
||||
"{name} opened for editing; it is stacked with the original": "{name} abierta para editar; está apilada con el original",
|
||||
"{name} — double-click to reset it": "{name}: doble clic para restablecer",
|
||||
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Cambios que no se pudieron escribir en el disco: {n}{0:.0}: {e}\nSi se sale ahora, se perderán.{1:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Cambios guardados en memoria, pero no escritos en el disco: {n}{0:.0}: {e}\nLightCraft vuelve a intentarlo automáticamente; si se sale ahora, se perderán.{1:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Cambios guardados solo en memoria: {n}{:.0}: {e}; LightCraft volverá a intentarlo",
|
||||
"{n} found · {sel} selected": "{n} encontradas · {sel} seleccionadas",
|
||||
"{n} photos": "{n} fotos",
|
||||
"{n} photos in {} are hidden by the filters above": "Los filtros de arriba ocultan {n} fotos en {}",
|
||||
"{n} photo{}": "{n} foto{}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos",
|
||||
"{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos",
|
||||
"{pid} (missing)": "{pid} (falta)",
|
||||
"{sel_n} selected · {counted}": "{sel_n} seleccionadas · {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": "Combinación {what} añadida",
|
||||
"{what} merge failed: {e}": "Error en la combinación {what}: {e}",
|
||||
"{year}": "{year}",
|
||||
"{{date:…}} directives: {}": "Directivas de {{date:…}}: {}",
|
||||
"{} and higher": "{} o más",
|
||||
"{} contributors · {} commits": "{} colaboradores · {} commits",
|
||||
"{} failed": "Con errores: {}",
|
||||
"{} files": "{} archivos",
|
||||
"{} files and {f} folder{}": "{} archivos y {f} carpeta{}",
|
||||
"{} item{}": "{0} elemento{1}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guías: arrastrar a lo largo de líneas que deban ser verticales u horizontales.",
|
||||
"{} photos": "{} fotos",
|
||||
"{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",
|
||||
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
|
||||
"{} photo{} not readable": "{0} foto{1} ilegible{1}",
|
||||
"{} sample{}": "{} muestra{}",
|
||||
"{} sec": "{} s",
|
||||
"{} spot(s) on this photo": "{} mancha(s) en esta foto",
|
||||
"{} — show their photos": "{}: mostrar sus fotos",
|
||||
"· {dups} already in the library": "· ya en la biblioteca: {dups}",
|
||||
"· {n} photo{}": "· {n} foto{}",
|
||||
"… and {more} more": "… y {more} más",
|
||||
"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 aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.",
|
||||
"Filtered: {label}": "Filtrado: {label}",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "Fotos similares: {n}{:.0} · Vista ▸ Borrar filtros para ver todas",
|
||||
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo",
|
||||
"{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)",
|
||||
", {} star{}": ", {} estrella{}",
|
||||
", {} label": ", etiqueta {}"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -50,13 +50,16 @@
|
||||
"Proof Preview · {}": "校正プレビュー・{}",
|
||||
"Quick Develop · {n} photos": "クイック現像・{n}枚",
|
||||
"Rated {}": "評価:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom カタログを読み取り中… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
|
||||
"Redo {}": "やり直す:{}",
|
||||
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
|
||||
"Removed from {}": "{} から削除しました",
|
||||
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Restore {}": "{} に戻す",
|
||||
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
@@ -114,6 +117,12 @@
|
||||
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
|
||||
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom カタログを確認しました:写真 {photos} 枚、コレクション {collections} 件、見つからないファイル {missing} 件",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom の読み込みが完了しました:{imported} 枚の写真を追加",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom の読み込みが完了しました:{imported} 枚を追加、{failed} 枚が失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom の読み込みが完了しました:{imported} 枚を追加。インデックスの警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom の読み込みに失敗しました:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom の確認に失敗しました:{e}{warning}",
|
||||
"Label Set: {name}": "ラベルセット:{name}",
|
||||
"Quick Develop: {name}": "クイック現像: {name}",
|
||||
"Reset {name}": "{name}をリセット",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Add/Subtract/Intersect Mask": "マスクを追加/減算/交差",
|
||||
"Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します",
|
||||
"Adding": "追加中",
|
||||
"Adding Lightroom photos…": "Lightroom の写真を追加中…",
|
||||
"After": "編集後",
|
||||
"After 30 days": "30日後",
|
||||
"After 7 days": "7日後",
|
||||
@@ -149,6 +150,7 @@
|
||||
"Blue Primary": "ブルー原色",
|
||||
"Blue Saturation": "ブルーの彩度",
|
||||
"Blue channel": "ブルーチャンネル",
|
||||
"Blue label": "ブルーラベル",
|
||||
"Blue/Yellow Fringe": "青/黄のフリンジ",
|
||||
"Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます",
|
||||
"Boundary Warp": "境界の変形",
|
||||
@@ -190,6 +192,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ファイル名に使えない文字(/ \\ : * ? \" < > |)は - に置き換え、既存の名前と重なる場合は -1、-2… を付けます",
|
||||
"Check All": "すべて選択",
|
||||
"Check Output Path": "書き出し先のパスを確認",
|
||||
"Check the files of this type": "この種類のファイルをすべてチェック",
|
||||
"Checking names…": "名前を確認中…",
|
||||
"Choose Edit Settings to Copy": "コピーする編集設定を選択",
|
||||
"Choose Edit Settings to Copy…": "コピーする編集設定を選択…",
|
||||
@@ -209,8 +212,11 @@
|
||||
"Clear Quick Collection": "クイックコレクションを空にする",
|
||||
"Clear Reference": "参照写真を解除",
|
||||
"Clear all": "すべて解除",
|
||||
"Clear rating": "評価を解除",
|
||||
"Clear rating and advance": "評価を解除して次へ",
|
||||
"Cleared History": "履歴を消去しました",
|
||||
"Click Zoom Ratio": "クリック時の拡大率",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "ショートカットをクリックして新しいキーを押します(Esc でキャンセル)。",
|
||||
"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": "オブジェクトをクリックして選択(⌥クリックで一部を除外)",
|
||||
@@ -458,6 +464,7 @@
|
||||
"File Path": "ファイルの場所",
|
||||
"File Size": "ファイルサイズ",
|
||||
"File Type": "ファイルの種類",
|
||||
"File types:": "ファイルの種類:",
|
||||
"Filename": "ファイル名",
|
||||
"Fill": "画面いっぱい",
|
||||
"Film": "フィルム",
|
||||
@@ -466,6 +473,7 @@
|
||||
"First": "最初",
|
||||
"First commit": "最初のコミット",
|
||||
"Five stars": "星5つ",
|
||||
"Fixed keys": "固定キー",
|
||||
"Flagged as Pick": "採用フラグ付き",
|
||||
"Flagged as Reject": "不採用フラグ付き",
|
||||
"Focal Length": "焦点距離",
|
||||
@@ -483,6 +491,7 @@
|
||||
"Golden Glow": "ゴールデングロー",
|
||||
"Golden Hour": "ゴールデンアワー",
|
||||
"Grab bag": "名前の一覧",
|
||||
"Green label": "グリーンラベル",
|
||||
"Gritty Street": "ざらついたストリート",
|
||||
"Group by Date: Automatic": "日付でグループ化:自動",
|
||||
"Group name": "グループ名",
|
||||
@@ -499,21 +508,26 @@
|
||||
"High Contrast B&W": "ハイコントラスト白黒",
|
||||
"Home": "ホーム",
|
||||
"Hours": "時間",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"ISO Speed": "ISO感度",
|
||||
"ISO speed": "ISO感度",
|
||||
"Import Lightroom Catalog": "Lightroom カタログを読み込む",
|
||||
"Import Lightroom Catalog…": "Lightroom カタログを読み込む…",
|
||||
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
|
||||
"Import Profiles": "プロファイルを読み込む",
|
||||
"Import as new": "新規として読み込む",
|
||||
"Imported": "読み込み済み",
|
||||
"Importing Lightroom Catalog": "Lightroom カタログを読み込み中",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "写真グリッドと正方形グリッドでは:採用フラグを付けて次へ",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "写真グリッドと正方形グリッドでは:スタックを展開または折りたたむ",
|
||||
"Increase Brush Feather": "ブラシのぼかしを大きく",
|
||||
"Increase Brush Size": "ブラシのサイズを大きく",
|
||||
"Info Overlay Off": "情報オーバーレイなし",
|
||||
"Info Overlay: Exposure": "情報オーバーレイ:露出",
|
||||
"Info Overlay: File": "情報オーバーレイ:ファイル",
|
||||
"Insert at the cursor": "カーソル位置に挿入",
|
||||
"Inspect Lightroom Catalog": "Lightroom カタログを確認",
|
||||
"Inspect Photo": "写真を調べる",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"Inspecting Lightroom Catalog": "Lightroom カタログを確認中",
|
||||
"Instant ’70s": "インスタント’70s",
|
||||
"Invert Component": "構成要素を反転",
|
||||
"JPEG (Large)": "JPEG(大)",
|
||||
@@ -534,6 +548,9 @@
|
||||
"Library saved": "ライブラリを保存しました",
|
||||
"Licence:": "ライセンス:",
|
||||
"LightCraft could not start": "LightCraftを起動できませんでした",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic カタログ",
|
||||
"Lightroom import cancelled": "Lightroom の読み込みをキャンセルしました",
|
||||
"Lightroom inspection cancelled": "Lightroom の確認をキャンセルしました",
|
||||
"Line delta": "行数の増減",
|
||||
"Linear": "リニア",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -590,6 +607,7 @@
|
||||
"Next Photo": "次の写真",
|
||||
"Night Blue": "ナイトブルー",
|
||||
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
|
||||
"No matching commands": "一致するコマンドはありません",
|
||||
"No model credits were built into this copy.": "このビルドにはモデルのクレジットが含まれていません",
|
||||
"No named people in these photos": "これらの写真に名前付きの人物はいません",
|
||||
"No named people yet": "名前付きの人物はまだいません",
|
||||
@@ -619,6 +637,7 @@
|
||||
"Pictures": "ピクチャ",
|
||||
"Prepare AI Masks": "AIマスクを準備",
|
||||
"Presets and profiles": "プリセットとプロファイル",
|
||||
"Press keys…": "キーを押してください…",
|
||||
"Preview Build Progress": "プレビュー作成の進行状況",
|
||||
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
|
||||
"Previous Photo": "前の写真",
|
||||
@@ -632,12 +651,24 @@
|
||||
"RAW decoding": "RAWデコード",
|
||||
"Radial": "円形",
|
||||
"Random": "ランダム",
|
||||
"Rate ★": "評価 ★",
|
||||
"Rate ★ and advance": "評価 ★ にして次へ",
|
||||
"Rate ★★": "評価 ★★",
|
||||
"Rate ★★ and advance": "評価 ★★ にして次へ",
|
||||
"Rate ★★★": "評価 ★★★",
|
||||
"Rate ★★★ and advance": "評価 ★★★ にして次へ",
|
||||
"Rate ★★★★": "評価 ★★★★",
|
||||
"Rate ★★★★ and advance": "評価 ★★★★ にして次へ",
|
||||
"Rate ★★★★★": "評価 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評価 ★★★★★ にして次へ",
|
||||
"Rating cleared": "評価を解除しました",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "SAMライセンスを読む",
|
||||
"Reading Lightroom catalog…": "Lightroom カタログを読み取り中…",
|
||||
"Real name": "本名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用したキーワード",
|
||||
"Red label": "レッドラベル",
|
||||
"Refine": "微調整",
|
||||
"Refine AI Mask Detail": "AIマスクの細部を微調整",
|
||||
"Refine Edges": "境界を微調整",
|
||||
@@ -652,6 +683,7 @@
|
||||
"Remove Face Box": "顔の枠を除去",
|
||||
"Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください",
|
||||
"Remove from Target Album": "追加先アルバムから除去",
|
||||
"Remove shortcut": "ショートカットを削除",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)",
|
||||
"Rename Component": "構成要素の名前を変更",
|
||||
"Rename Folder": "フォルダ名を変更",
|
||||
@@ -660,6 +692,8 @@
|
||||
"Rename Preview": "名前変更のプレビュー",
|
||||
"Rename Template Tags": "名前変更テンプレートのタグ",
|
||||
"Reorder Masks": "マスクを並べ替え",
|
||||
"Reset All": "すべてリセット",
|
||||
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
|
||||
"Reshuffle": "シャッフルし直す",
|
||||
"File name": "ファイル名",
|
||||
"Fill Edges": "境界を補完",
|
||||
@@ -1090,6 +1124,7 @@
|
||||
"Resize to": "サイズ変更",
|
||||
"Resolution (ppi)": "解像度(ppi)",
|
||||
"Restore": "復元",
|
||||
"Restore (no shortcut)": "元に戻す(ショートカットなし)",
|
||||
"Restore Version": "バージョンを復元",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。",
|
||||
"Restore them": "復元する",
|
||||
@@ -1131,6 +1166,7 @@
|
||||
"Save as Smart Album…": "スマートアルバムとして保存…",
|
||||
"Save as set": "セットとして保存",
|
||||
"Save preset": "プリセットを保存",
|
||||
"Saving Lightroom import index…": "Lightroom 読み込みインデックスを保存中…",
|
||||
"Scale": "拡大率",
|
||||
"Scan failed": "スキャンに失敗しました",
|
||||
"Scanning": "検索中",
|
||||
@@ -1138,6 +1174,7 @@
|
||||
"Screen": "画面用",
|
||||
"Search": "検索",
|
||||
"Search Photos": "写真を検索",
|
||||
"Search commands or keys": "コマンドまたはキーを検索",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第2ウィンドウ",
|
||||
"Select": "選択",
|
||||
@@ -1167,6 +1204,7 @@
|
||||
"Set Develop Values": "現像設定を変更",
|
||||
"Set Flag": "フラグを設定",
|
||||
"Set Image Zoom and Pan": "画像のズームとパンを設定",
|
||||
"Set Keyboard Shortcut": "キーボードショートカットを設定",
|
||||
"Set Mask Adjustments": "マスクの調整を設定",
|
||||
"Set Point Curve": "ポイントカーブを変更",
|
||||
"Set Profile": "プロファイルを設定",
|
||||
@@ -1216,6 +1254,7 @@
|
||||
"Show Pins": "ピンを表示",
|
||||
"Show Source": "コピー元を表示",
|
||||
"Show Thumbnails": "サムネイルを表示",
|
||||
"Show commands without a shortcut": "ショートカットのないコマンドも表示",
|
||||
"Show destination gamut warning": "出力先の色域警告を表示",
|
||||
"Show display gamut warning": "ディスプレイの色域警告を表示",
|
||||
"Show file names": "ファイル名を表示",
|
||||
@@ -1313,6 +1352,7 @@
|
||||
"Template": "テンプレート",
|
||||
"Text": "文字",
|
||||
"Texture": "テクスチャ",
|
||||
"That key can't be used as a shortcut": "そのキーはショートカットに使えません",
|
||||
"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)",
|
||||
@@ -1321,7 +1361,6 @@
|
||||
"The photo has these settings": "この写真に適用されている設定",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
|
||||
"Thirds": "三分割",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
|
||||
"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": "しきい値",
|
||||
@@ -1345,6 +1384,7 @@
|
||||
"Turn Off": "無効にする",
|
||||
"Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください",
|
||||
"Uncheck All": "すべて選択解除",
|
||||
"Uncheck the files of this type": "この種類のファイルのチェックをすべて外す",
|
||||
"Undo": "取り消す",
|
||||
"Unedited": "未編集",
|
||||
"Unedited raws": "未編集のRAW",
|
||||
@@ -1418,6 +1458,7 @@
|
||||
"Yellow Hue": "イエローの色相",
|
||||
"Yellow Luminance": "イエローの輝度",
|
||||
"Yellow Saturation": "イエローの彩度",
|
||||
"Yellow label": "イエローラベル",
|
||||
"Yes": "はい",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "このウィンドウは閉じてもかまいません。ダウンロードは続行され、中断しても再開します",
|
||||
"Your Name": "あなたの名前",
|
||||
@@ -1595,5 +1636,8 @@
|
||||
"≤ at most": "≤ 以下",
|
||||
"≥ at least": "≥ 以上",
|
||||
"日本語": "日本語",
|
||||
"繁體中文(台灣)": "繁體中文(台灣)"
|
||||
"繁體中文(台灣)": "繁體中文(台灣)",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。"
|
||||
}
|
||||
@@ -45,6 +45,12 @@
|
||||
"Imported {n} preset{}": "Importadas {n} predefinição{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível",
|
||||
"Imported {} photo{}": "Importadas {} foto{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo do Lightroom inspecionado: {photos} fotos, {collections} coleções, {missing} ausentes",
|
||||
"Lightroom import complete: {imported} photos added": "Importação do Lightroom concluída: {imported} fotos adicionadas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importação do Lightroom concluída: {imported} fotos adicionadas, {failed} com falha",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importação do Lightroom concluída: {imported} fotos adicionadas; aviso do índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Falha na importação do Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Falha na inspeção do Lightroom: {e}{warning}",
|
||||
"Label Set: {name}": "Conjunto de rótulos: {name}",
|
||||
"LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
@@ -62,16 +68,19 @@
|
||||
"Quick Develop · {n} photos": "Revelação rápida · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelação rápida: {name}",
|
||||
"Rated {}": "Avaliada {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lendo catálogo do Lightroom… {done} de {total}",
|
||||
"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",
|
||||
"Removed from {}": "Removido de {}",
|
||||
"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 {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
|
||||
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
|
||||
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Adicionar/Subtrair/Interseção de máscara",
|
||||
"Added to photos you import that don't already have it.": "Adicionado às fotos importadas que ainda não têm isso.",
|
||||
"Adding": "Adicionando",
|
||||
"Adding Lightroom photos…": "Adicionando fotos do Lightroom…",
|
||||
"Advanced search…": "Busca avançada…",
|
||||
"After": "Depois",
|
||||
"After 30 days": "Após 30 dias",
|
||||
@@ -153,6 +154,7 @@
|
||||
"Blue Primary": "Azul primário",
|
||||
"Blue Saturation": "Saturação do azul",
|
||||
"Blue channel": "Canal azul",
|
||||
"Blue label": "Rótulo azul",
|
||||
"Blue/Yellow Fringe": "Franja azul/amarela",
|
||||
"Blurry photos below the score are flagged as rejects.": "Fotos desfocadas abaixo da pontuação são marcadas como rejeitadas.",
|
||||
"Boundary Warp": "Deformação de borda",
|
||||
@@ -195,6 +197,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Caracteres não permitidos em nomes de arquivo (/ \\ : * ? \" < > |) viram -; um nome já existente recebe -1, -2….",
|
||||
"Check All": "Marcar todos",
|
||||
"Check Output Path": "Verificar caminho de saída",
|
||||
"Check the files of this type": "Marcar os arquivos deste tipo",
|
||||
"Checking names…": "Verificando nomes…",
|
||||
"Choose Another Library…": "Escolher outra biblioteca…",
|
||||
"Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar",
|
||||
@@ -216,8 +219,11 @@
|
||||
"Clear Reference": "Limpar referência",
|
||||
"Clear all": "Limpar tudo",
|
||||
"Clear form": "Limpar formulário",
|
||||
"Clear rating": "Limpar avaliação",
|
||||
"Clear rating and advance": "Limpar avaliação e avançar",
|
||||
"Cleared History": "Histórico limpo",
|
||||
"Click Zoom Ratio": "Proporção de zoom por clique",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Clique em um atalho e pressione as novas teclas (Esc cancela).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Clique em uma tag para inseri-la. Os exemplos são de IMG_0042.CR3 tirada em 14 de jan. de 2026, 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Clique nas fotos da grade para alternar a palavra-chave; Esc encerra",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Clique no objeto para selecioná-lo · ⌥-clique deixa uma parte de fora",
|
||||
@@ -485,6 +491,7 @@
|
||||
"File Size": "Tamanho do arquivo",
|
||||
"File Type": "Tipo de arquivo",
|
||||
"File name": "Nome do arquivo",
|
||||
"File types:": "Tipos de arquivo:",
|
||||
"Filename": "Nome do arquivo",
|
||||
"Fill": "Preencher",
|
||||
"Fill Edges": "Preencher bordas",
|
||||
@@ -510,6 +517,7 @@
|
||||
"Fit to width": "Ajustar à largura",
|
||||
"Fit visible": "Ajustar ao visível",
|
||||
"Five stars": "Cinco estrelas",
|
||||
"Fixed keys": "Teclas fixas",
|
||||
"Flag": "Marcar",
|
||||
"Flag as Pick": "Marcar como seleção",
|
||||
"Flag as Reject": "Marcar como rejeitada",
|
||||
@@ -572,6 +580,7 @@
|
||||
"Green Primary": "Primária verde",
|
||||
"Green Saturation": "Saturação do verde",
|
||||
"Green channel": "Canal verde",
|
||||
"Green label": "Rótulo verde",
|
||||
"Grid": "Grade",
|
||||
"Grid Info": "Informações da grade",
|
||||
"Gritty Street": "Urbano cru",
|
||||
@@ -632,6 +641,8 @@
|
||||
"Image": "Imagem",
|
||||
"Import": "Importar",
|
||||
"Import Date": "Data de importação",
|
||||
"Import Lightroom Catalog": "Importar catálogo do Lightroom",
|
||||
"Import Lightroom Catalog…": "Importar catálogo do Lightroom…",
|
||||
"Import Photos": "Importar fotos",
|
||||
"Import Photos…": "Importar fotos…",
|
||||
"Import Point Curve Presets": "Importar predefinições de curva de pontos",
|
||||
@@ -647,7 +658,10 @@
|
||||
"Imported": "Importados",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
"Importing Lightroom Catalog": "Importando catálogo do Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "Na Grade de fotos e na Grade quadrada: marcar como seleção e avançar",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "Na Grade de fotos e na Grade quadrada: expandir ou recolher a pilha",
|
||||
"In Recently Deleted": "Em Recentemente excluídos",
|
||||
"In library": "Na biblioteca",
|
||||
"In memory — nothing is saved": "Na memória — nada é salvo",
|
||||
@@ -663,7 +677,9 @@
|
||||
"Info Overlay: File": "Sobreposição de informações: arquivo",
|
||||
"Info overlay": "Sobreposição de informações",
|
||||
"Insert at the cursor": "Inserir no cursor",
|
||||
"Inspect Lightroom Catalog": "Inspecionar catálogo do Lightroom",
|
||||
"Inspect Photo": "Inspecionar foto",
|
||||
"Inspecting Lightroom Catalog": "Inspecionando catálogo do Lightroom",
|
||||
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
|
||||
"Instant ’70s": "Instantâneo anos 70",
|
||||
"Interface": "Interface",
|
||||
@@ -729,6 +745,9 @@
|
||||
"LightCraft on GitHub": "LightCraft no GitHub",
|
||||
"LightCraft website": "Site do LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — segunda janela",
|
||||
"Lightroom Classic Catalog": "Catálogo do Lightroom Classic",
|
||||
"Lightroom import cancelled": "Importação do Lightroom cancelada",
|
||||
"Lightroom inspection cancelled": "Inspeção do Lightroom cancelada",
|
||||
"Lights": "Luzes",
|
||||
"Limit file size (KB, 0 = off)": "Limitar tamanho do arquivo (KB, 0 = desativado)",
|
||||
"Line delta": "Diferença de linhas",
|
||||
@@ -884,6 +903,7 @@
|
||||
"No filter presets yet": "Ainda não há predefinições de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.",
|
||||
"No matching commands": "Nenhum comando corresponde",
|
||||
"No matching photos": "Nenhuma foto correspondente",
|
||||
"No metadata found": "Nenhum metadado encontrado",
|
||||
"No model credits were built into this copy.": "Esta cópia não inclui créditos de modelos.",
|
||||
@@ -1008,6 +1028,7 @@
|
||||
"Presets (Shift+P)": "Predefinições (Shift+P)",
|
||||
"Presets and profiles": "Predefinições e perfis",
|
||||
"Presets ▾": "Predefinições",
|
||||
"Press keys…": "Pressione as teclas…",
|
||||
"Preview": "Pré-visualização",
|
||||
"Preview Build Progress": "Progresso da geração de pré-visualizações",
|
||||
"Preview only": "Somente pré-visualização",
|
||||
@@ -1052,6 +1073,16 @@
|
||||
"Radius": "Raio",
|
||||
"Random": "Aleatória",
|
||||
"Range": "Faixa",
|
||||
"Rate ★": "Avaliar ★",
|
||||
"Rate ★ and advance": "Avaliar ★ e avançar",
|
||||
"Rate ★★": "Avaliar ★★",
|
||||
"Rate ★★ and advance": "Avaliar ★★ e avançar",
|
||||
"Rate ★★★": "Avaliar ★★★",
|
||||
"Rate ★★★ and advance": "Avaliar ★★★ e avançar",
|
||||
"Rate ★★★★": "Avaliar ★★★★",
|
||||
"Rate ★★★★ and advance": "Avaliar ★★★★ e avançar",
|
||||
"Rate ★★★★★": "Avaliar ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Avaliar ★★★★★ e avançar",
|
||||
"Rating": "Avaliação",
|
||||
"Rating cleared": "Avaliação removida",
|
||||
"Ratings & flags": "Avaliações e marcações",
|
||||
@@ -1065,6 +1096,7 @@
|
||||
"Read mode": "Modo de leitura",
|
||||
"Read the SAM License": "Ler a licença do SAM",
|
||||
"Reading": "Lendo",
|
||||
"Reading Lightroom catalog…": "Lendo catálogo do Lightroom…",
|
||||
"Reading folder…": "Lendo pasta…",
|
||||
"Real name": "Nome real",
|
||||
"Recent": "Recentes",
|
||||
@@ -1078,6 +1110,7 @@
|
||||
"Red Primary": "Primária vermelha",
|
||||
"Red Saturation": "Saturação do vermelho",
|
||||
"Red channel": "Canal vermelho",
|
||||
"Red label": "Rótulo vermelho",
|
||||
"Red/Cyan Fringe": "Franja vermelha/ciano",
|
||||
"Redo": "Refazer",
|
||||
"Reference": "Referência",
|
||||
@@ -1118,6 +1151,7 @@
|
||||
"Remove from Target Album": "Remover do álbum de destino",
|
||||
"Remove from survey": "Remover da inspeção",
|
||||
"Remove location info": "Remover informações de local",
|
||||
"Remove shortcut": "Remover atalho",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar ▸ Desfazer)",
|
||||
"Remove this filter": "Remover este filtro",
|
||||
"Remove this group": "Remover este grupo",
|
||||
@@ -1141,7 +1175,9 @@
|
||||
"Rendering…": "Renderizando…",
|
||||
"Reorder Masks": "Reordenar máscaras",
|
||||
"Reset": "Redefinir",
|
||||
"Reset All": "Redefinir tudo",
|
||||
"Reset All Curves": "Redefinir todas as curvas",
|
||||
"Reset All Keyboard Shortcuts": "Redefinir todos os atalhos de teclado",
|
||||
"Reset Before to Import State": "Redefinir Antes para o estado de importação",
|
||||
"Reset Control": "Redefinir controle",
|
||||
"Reset Crop": "Redefinir recorte",
|
||||
@@ -1156,6 +1192,7 @@
|
||||
"Resize to": "Redimensionar para",
|
||||
"Resolution (ppi)": "Resolução (ppi)",
|
||||
"Restore": "Restaurar",
|
||||
"Restore (no shortcut)": "Restaurar (sem atalho)",
|
||||
"Restore Library from Backup…": "Restaurar biblioteca do backup…",
|
||||
"Restore Version": "Restaurar versão",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Restaurar mantém as edições; Importar como novas apaga antes o registro antigo.",
|
||||
@@ -1202,6 +1239,7 @@
|
||||
"Save as set": "Salvar como conjunto",
|
||||
"Save as…": "Salvar como…",
|
||||
"Save preset": "Salvar predefinição",
|
||||
"Saving Lightroom import index…": "Salvando índice de importação do Lightroom…",
|
||||
"Scale": "Escala",
|
||||
"Scan failed": "Falha na verificação",
|
||||
"Scanning": "Examinando",
|
||||
@@ -1209,6 +1247,7 @@
|
||||
"Screen": "Tela",
|
||||
"Search": "Pesquisar",
|
||||
"Search Photos": "Pesquisar fotos",
|
||||
"Search commands or keys": "Pesquisar comandos ou teclas",
|
||||
"Search…": "Pesquisar…",
|
||||
"Seasons": "Estações",
|
||||
"Second Window": "Segunda janela",
|
||||
@@ -1239,6 +1278,7 @@
|
||||
"Set Develop Values": "Definir valores de revelação",
|
||||
"Set Flag": "Definir marcação",
|
||||
"Set Image Zoom and Pan": "Definir zoom e posição da imagem",
|
||||
"Set Keyboard Shortcut": "Definir atalho de teclado",
|
||||
"Set Mask Adjustments": "Definir ajustes da máscara",
|
||||
"Set Point Curve": "Definir curva de pontos",
|
||||
"Set Profile": "Definir perfil",
|
||||
@@ -1288,6 +1328,7 @@
|
||||
"Show Pins": "Mostrar pinos",
|
||||
"Show Source": "Mostrar origem",
|
||||
"Show Thumbnails": "Mostrar miniaturas",
|
||||
"Show commands without a shortcut": "Mostrar comandos sem atalho",
|
||||
"Show destination gamut warning": "Mostrar aviso de gamut de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gamut da tela",
|
||||
"Show file names": "Mostrar nomes dos arquivos",
|
||||
@@ -1392,6 +1433,7 @@
|
||||
"Text direction": "Direção do texto",
|
||||
"Texture": "Textura",
|
||||
"That includes the photos in the folders inside it.": "Isso inclui as fotos das pastas dentro dela.",
|
||||
"That key can't be used as a shortcut": "Essa tecla não pode ser usada como atalho",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.",
|
||||
"The download didn't work:": "O download não funcionou:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "O número no fim do nome original (IMG_0042 → 0042)",
|
||||
@@ -1431,6 +1473,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Ative Incluir subpastas ou escolha outra pasta em Local",
|
||||
"Type": "Tipo",
|
||||
"Uncheck All": "Desmarcar tudo",
|
||||
"Uncheck the files of this type": "Desmarcar os arquivos deste tipo",
|
||||
"Undo": "Desfazer",
|
||||
"Unedited": "Não editada",
|
||||
"Unedited raws": "RAWs não editados",
|
||||
@@ -1507,6 +1550,7 @@
|
||||
"Yellow Hue": "Matiz do amarelo",
|
||||
"Yellow Luminance": "Luminância do amarelo",
|
||||
"Yellow Saturation": "Saturação do amarelo",
|
||||
"Yellow label": "Rótulo amarelo",
|
||||
"Yes": "Sim",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Você pode fechar isto: o download continua e retoma se for interrompido.",
|
||||
"Your Name": "Seu nome",
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
"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-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
|
||||
"LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom проверен: фото — {photos}, коллекций — {collections}, отсутствует — {missing}",
|
||||
"Lightroom import complete: {imported} photos added": "Импорт из Lightroom завершён: добавлено фото — {imported}",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Импорт из Lightroom завершён: добавлено фото — {imported}, ошибок — {failed}",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Импорт из Lightroom завершён: добавлено фото — {imported}; предупреждение индекса: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Ошибка импорта из Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Ошибка проверки Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
|
||||
"Matches: {}": "Совпадения: {}",
|
||||
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
|
||||
@@ -91,11 +97,13 @@
|
||||
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
|
||||
"Quick Develop: {name}": "Быстрая коррекция: {name}",
|
||||
"Rated {}": "Оценка: {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Чтение каталога Lightroom… {done} из {total}",
|
||||
"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 from {}": "Удалено из: {}",
|
||||
"Removed {what} from {name}": "{what}: удалено из {name}",
|
||||
"Rename {n} Photos…": "Переименовать фото: {n}…",
|
||||
"Rename “{name}”": "Переименовать «{name}»",
|
||||
@@ -104,6 +112,7 @@
|
||||
"Replace {} with:": "Заменить {} на:",
|
||||
"Reset {name}": "Сбросить: {name}",
|
||||
"Reset {} Channel": "Сбросить канал: {}",
|
||||
"Restore {}": "Восстановить {}",
|
||||
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
|
||||
"Saved export preset “{}”": "Пресет экспорта «{}» сохранён",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Добавить/вычесть/пересечь маску",
|
||||
"Added to photos you import that don't already have it.": "Добавляется к импортируемым фото, у которых этого ещё нет.",
|
||||
"Adding": "Добавление",
|
||||
"Adding Lightroom photos…": "Добавление фото из Lightroom…",
|
||||
"Advanced search…": "Расширенный поиск…",
|
||||
"After": "После",
|
||||
"After 30 days": "Через 30 дней",
|
||||
@@ -124,6 +125,7 @@
|
||||
"Blue Primary": "Основной синий",
|
||||
"Blue Saturation": "Насыщенность синего",
|
||||
"Blue channel": "Синий канал",
|
||||
"Blue label": "Синяя метка",
|
||||
"Blue/Yellow Fringe": "Сине-жёлтая кайма",
|
||||
"Blurry photos below the score are flagged as rejects.": "Размытые фото с оценкой ниже порога помечаются как отклонённые.",
|
||||
"Boundary Warp": "Деформация границ",
|
||||
@@ -155,6 +157,7 @@
|
||||
"Catalog Statistics": "Статистика каталога",
|
||||
"Check All": "Отметить все",
|
||||
"Check Output Path": "Проверить путь вывода",
|
||||
"Check the files of this type": "Отметить файлы этого типа",
|
||||
"Choose Another Library…": "Выбрать другую библиотеку…",
|
||||
"Choose Edit Settings to Copy": "Выбрать настройки для копирования",
|
||||
"Choose Edit Settings to Copy…": "Выбрать настройки для копирования…",
|
||||
@@ -175,8 +178,11 @@
|
||||
"Clear Reference": "Убрать эталон",
|
||||
"Clear all": "Очистить всё",
|
||||
"Clear form": "Очистить форму",
|
||||
"Clear rating": "Снять оценку",
|
||||
"Clear rating and advance": "Снять оценку и перейти дальше",
|
||||
"Cleared History": "История очищена",
|
||||
"Click Zoom Ratio": "Масштаб по щелчку",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Щёлкните сочетание и нажмите новые клавиши (Esc — отмена).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Щёлкните по тегу, чтобы вставить его. Примеры приведены для IMG_0042.CR3, снятого 14 января 2026 в 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Щёлкайте по фото в сетке, чтобы переключать ключевое слово; Esc — остановить",
|
||||
"Click the photo to pick a colour · ⇧-click adds more": "Щёлкните по фото, чтобы выбрать цвет · ⇧+щелчок добавляет ещё",
|
||||
@@ -395,6 +401,7 @@
|
||||
"File Path": "Путь к файлу",
|
||||
"File Size": "Размер файла",
|
||||
"File name": "Имя файла",
|
||||
"File types:": "Типы файлов:",
|
||||
"Fill Edges": "Заполнить края",
|
||||
"Film": "Плёнка",
|
||||
"Filmstrip": "Лента",
|
||||
@@ -415,6 +422,7 @@
|
||||
"Fit on Screen": "Вписать в экран",
|
||||
"Fit to width": "По ширине",
|
||||
"Fit visible": "Вписать видимое",
|
||||
"Fixed keys": "Фиксированные клавиши",
|
||||
"Flag": "Флаг",
|
||||
"Flag as Pick": "Пометить как выбранное",
|
||||
"Flag as Reject": "Пометить как отклонённое",
|
||||
@@ -468,6 +476,7 @@
|
||||
"Green Primary": "Основной зелёный",
|
||||
"Green Saturation": "Насыщенность зелёного",
|
||||
"Green channel": "Зелёный канал",
|
||||
"Green label": "Зелёная метка",
|
||||
"Grid": "Сетка",
|
||||
"Grid Info": "Информация сетки",
|
||||
"Gritty Street": "Жёсткая улица",
|
||||
@@ -516,6 +525,8 @@
|
||||
"Image": "Изображение",
|
||||
"Import": "Импорт",
|
||||
"Import Date": "Дата импорта",
|
||||
"Import Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Import Lightroom Catalog…": "Импорт каталога Lightroom…",
|
||||
"Import Photos": "Импорт фото",
|
||||
"Import Photos…": "Импорт фото…",
|
||||
"Import Point Curve Presets": "Импортировать пресеты точечной кривой",
|
||||
@@ -529,7 +540,10 @@
|
||||
"Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Импортируйте фото через Файл → Импорт фото… (Cmd+Shift+I) или перетащите их сюда",
|
||||
"Imported Photos": "Импортированные фото",
|
||||
"Importing": "Импорт",
|
||||
"Importing Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "При импорте источник сканируется на фото; папка не добавляется в «Локальное» (чтобы работать с папкой без импорта, используйте Локальное → Выбрать папку…).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "В сетке фото и квадратной сетке: пометить как выбранное и перейти дальше",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "В сетке фото и квадратной сетке: развернуть или свернуть стопку",
|
||||
"In Recently Deleted": "В «Недавно удалённых»",
|
||||
"In library": "В библиотеке",
|
||||
"In memory — nothing is saved": "В памяти — ничего не сохраняется",
|
||||
@@ -541,7 +555,9 @@
|
||||
"Info": "Информация",
|
||||
"Info (I)": "Информация (I)",
|
||||
"Info overlay": "Информационная накладка",
|
||||
"Inspect Lightroom Catalog": "Проверить каталог Lightroom",
|
||||
"Inspect Photo": "Просмотр фото",
|
||||
"Inspecting Lightroom Catalog": "Проверка каталога Lightroom",
|
||||
"Install the SAM 3 Model": "Установка модели SAM 3",
|
||||
"Instant ’70s": "Мгновенный стиль 70-х",
|
||||
"Interface": "Интерфейс",
|
||||
@@ -598,6 +614,9 @@
|
||||
"LightCraft on GitHub": "LightCraft на GitHub",
|
||||
"LightCraft website": "Сайт LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — второе окно",
|
||||
"Lightroom Classic Catalog": "Каталог Lightroom Classic",
|
||||
"Lightroom import cancelled": "Импорт из Lightroom отменён",
|
||||
"Lightroom inspection cancelled": "Проверка Lightroom отменена",
|
||||
"Lights": "Светлые тона",
|
||||
"Limit file size (KB, 0 = off)": "Ограничить размер файла (КБ, 0 = выкл.)",
|
||||
"Linear Gradient": "Линейный градиент",
|
||||
@@ -726,6 +745,7 @@
|
||||
"No filter presets yet": "Пресетов фильтров пока нет",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.",
|
||||
"No matching commands": "Нет подходящих команд",
|
||||
"No matching photos": "Нет подходящих фото",
|
||||
"No metadata found": "Метаданные не найдены",
|
||||
"No new presets": "Новых пресетов нет",
|
||||
@@ -828,6 +848,7 @@
|
||||
"Presets & Profiles": "Пресеты и профили",
|
||||
"Presets (Shift+P)": "Пресеты (Shift+P)",
|
||||
"Presets ▾": "Пресеты ⌄",
|
||||
"Press keys…": "Нажмите клавиши…",
|
||||
"Preview": "Просмотр",
|
||||
"Preview Build Progress": "Ход создания просмотров",
|
||||
"Preview only": "Только просмотр",
|
||||
@@ -866,6 +887,16 @@
|
||||
"Radius": "Радиус",
|
||||
"Random": "Случайно",
|
||||
"Range": "Диапазон",
|
||||
"Rate ★": "Оценка ★",
|
||||
"Rate ★ and advance": "Оценка ★ и перейти дальше",
|
||||
"Rate ★★": "Оценка ★★",
|
||||
"Rate ★★ and advance": "Оценка ★★ и перейти дальше",
|
||||
"Rate ★★★": "Оценка ★★★",
|
||||
"Rate ★★★ and advance": "Оценка ★★★ и перейти дальше",
|
||||
"Rate ★★★★": "Оценка ★★★★",
|
||||
"Rate ★★★★ and advance": "Оценка ★★★★ и перейти дальше",
|
||||
"Rate ★★★★★": "Оценка ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Оценка ★★★★★ и перейти дальше",
|
||||
"Rating": "Оценка",
|
||||
"Ratings & flags": "Оценки и флаги",
|
||||
"Raw default": "Стандарт RAW",
|
||||
@@ -877,6 +908,7 @@
|
||||
"Read mode": "Режим чтения",
|
||||
"Read the SAM License": "Прочитать лицензию SAM",
|
||||
"Reading": "Чтение",
|
||||
"Reading Lightroom catalog…": "Чтение каталога Lightroom…",
|
||||
"Reading folder…": "Чтение папки…",
|
||||
"Recently Added": "Недавно добавленные",
|
||||
"Recently Deleted": "Недавно удалённые",
|
||||
@@ -887,6 +919,7 @@
|
||||
"Red Primary": "Основной красный",
|
||||
"Red Saturation": "Насыщенность красного",
|
||||
"Red channel": "Красный канал",
|
||||
"Red label": "Красная метка",
|
||||
"Red/Cyan Fringe": "Красно-голубая кайма",
|
||||
"Redo": "Повторить",
|
||||
"Reference": "Эталон",
|
||||
@@ -912,6 +945,7 @@
|
||||
"Remove from Stack": "Убрать из стопки",
|
||||
"Remove from survey": "Убрать из обзора",
|
||||
"Remove location info": "Удалить сведения о местоположении",
|
||||
"Remove shortcut": "Удалить сочетание",
|
||||
"Remove this filter": "Убрать этот фильтр",
|
||||
"Remove this group": "Убрать эту группу",
|
||||
"Remove this rule": "Убрать это условие",
|
||||
@@ -932,7 +966,9 @@
|
||||
"Rendering": "Рендеринг",
|
||||
"Rendering…": "Рендеринг…",
|
||||
"Reset": "Сбросить",
|
||||
"Reset All": "Сбросить всё",
|
||||
"Reset All Curves": "Сбросить все кривые",
|
||||
"Reset All Keyboard Shortcuts": "Сбросить все сочетания клавиш",
|
||||
"Reset Before to Import State": "Сбросить «до» к состоянию при импорте",
|
||||
"Reset Control": "Сбросить элемент управления",
|
||||
"Reset Crop": "Сбросить кадрирование",
|
||||
@@ -946,6 +982,7 @@
|
||||
"Resize to": "Изменить размер до",
|
||||
"Resolution (ppi)": "Разрешение (ppi)",
|
||||
"Restore": "Восстановить",
|
||||
"Restore (no shortcut)": "Восстановить (без сочетания)",
|
||||
"Restore Library from Backup…": "Восстановить библиотеку из резервной копии…",
|
||||
"Restore Version": "Восстановить версию",
|
||||
"Retouch": "Ретушь",
|
||||
@@ -985,11 +1022,13 @@
|
||||
"Save as set": "Сохранить как набор",
|
||||
"Save as…": "Сохранить как…",
|
||||
"Save preset": "Сохранить пресет",
|
||||
"Saving Lightroom import index…": "Сохранение индекса импорта Lightroom…",
|
||||
"Scale": "Масштаб",
|
||||
"Scanning": "Сканирование",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
|
||||
"Screen": "Экран",
|
||||
"Search Photos": "Поиск фото",
|
||||
"Search commands or keys": "Поиск команд или клавиш",
|
||||
"Search…": "Поиск…",
|
||||
"Seasons": "Времена года",
|
||||
"Second Window": "Второе окно",
|
||||
@@ -1014,6 +1053,7 @@
|
||||
"Set Crop": "Задать кадрирование",
|
||||
"Set Develop Values": "Задать значения коррекции",
|
||||
"Set Flag": "Задать флаг",
|
||||
"Set Keyboard Shortcut": "Задать сочетание клавиш",
|
||||
"Set Mask Adjustments": "Задать настройки маски",
|
||||
"Set Point Curve": "Задать точечную кривую",
|
||||
"Set Profile": "Задать профиль",
|
||||
@@ -1055,6 +1095,7 @@
|
||||
"Show Pins": "Показать булавки",
|
||||
"Show Source": "Показать источник",
|
||||
"Show Thumbnails": "Показывать миниатюры",
|
||||
"Show commands without a shortcut": "Показывать команды без сочетаний",
|
||||
"Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода",
|
||||
"Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея",
|
||||
"Show file names": "Показывать имена файлов",
|
||||
@@ -1146,6 +1187,7 @@
|
||||
"Text": "Текст",
|
||||
"Text direction": "Направление текста",
|
||||
"Texture": "Текстура",
|
||||
"That key can't be used as a shortcut": "Эту клавишу нельзя использовать в сочетании",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Модель SAM 3 установлена: маски «Объект» и «Описать» готовы.",
|
||||
"The download didn't work:": "Загрузка не удалась:",
|
||||
"The original files, unchanged, each with an XMP sidecar holding its edits.": "Исходные файлы без изменений, у каждого — XMP-файл с его правками.",
|
||||
@@ -1177,6 +1219,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Включите «Включая вложенные папки» или выберите другую папку в «Локальном»",
|
||||
"Type": "Тип",
|
||||
"Uncheck All": "Снять все отметки",
|
||||
"Uncheck the files of this type": "Снять отметку с файлов этого типа",
|
||||
"Undo": "Отменить",
|
||||
"Unedited": "Без правок",
|
||||
"Unflag": "Снять флаг",
|
||||
@@ -1246,6 +1289,7 @@
|
||||
"Yellow Hue": "Тон жёлтого",
|
||||
"Yellow Luminance": "Яркость жёлтого",
|
||||
"Yellow Saturation": "Насыщенность жёлтого",
|
||||
"Yellow label": "Жёлтая метка",
|
||||
"Yes": "Да",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Это окно можно закрыть: загрузка продолжится и возобновится после обрыва.",
|
||||
"Your library couldn't be opened": "Не удалось открыть вашу библиотеку",
|
||||
|
||||
@@ -50,13 +50,16 @@
|
||||
"Proof Preview · {}": "校样预览 · {0}",
|
||||
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
|
||||
"Rated {}": "已评级 {0}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在读取 Lightroom 目录… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
|
||||
"Redo {}": "重做:{0}",
|
||||
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
|
||||
"Removed from {}": "已从 {} 移除",
|
||||
"Rename {n} Photos…": "重命名 {n} 张照片…",
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {0} 通道",
|
||||
"Restore {}": "恢复为 {}",
|
||||
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
|
||||
"Saved export preset “{}”": "已保存导出预设“{0}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
|
||||
@@ -114,6 +117,12 @@
|
||||
"Build Smart Previews ({scope})": "生成智能预览({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
|
||||
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已检查 Lightroom 目录:{photos} 张照片,{collections} 个收藏夹,{missing} 个缺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 导入完成:已添加 {imported} 张照片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 导入完成:已添加 {imported} 张照片,{failed} 张失败",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 导入完成:已添加 {imported} 张照片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 导入失败:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 检查失败:{e}{warning}",
|
||||
"Label Set: {name}": "标签集:{name}",
|
||||
"Quick Develop: {name}": "快速编辑:{name}",
|
||||
"Reset {name}": "重置{name}",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
|
||||
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
|
||||
"Adding": "正在添加",
|
||||
"Adding Lightroom photos…": "正在添加 Lightroom 照片…",
|
||||
"Advanced search…": "高级搜索…",
|
||||
"After": "修改后",
|
||||
"After 30 days": "30 天后",
|
||||
@@ -152,6 +153,7 @@
|
||||
"Blue Primary": "蓝色原色",
|
||||
"Blue Saturation": "蓝色饱和度",
|
||||
"Blue channel": "蓝色通道",
|
||||
"Blue label": "蓝色标签",
|
||||
"Blue/Yellow Fringe": "蓝/黄边缘",
|
||||
"Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。",
|
||||
"Boundary Warp": "边界变形",
|
||||
@@ -194,6 +196,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。",
|
||||
"Check All": "全部选中",
|
||||
"Check Output Path": "检查输出路径",
|
||||
"Check the files of this type": "勾选此类型的所有文件",
|
||||
"Checking names…": "正在检查名称…",
|
||||
"Choose Another Library…": "选择其他图库…",
|
||||
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
|
||||
@@ -215,8 +218,11 @@
|
||||
"Clear Reference": "清除参考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表单",
|
||||
"Clear rating": "清除星级",
|
||||
"Clear rating and advance": "清除星级并前进",
|
||||
"Cleared History": "已清除历史记录",
|
||||
"Click Zoom Ratio": "单击缩放比例",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "点击快捷键,然后按下新按键(Esc 取消)。",
|
||||
"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": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分",
|
||||
@@ -481,11 +487,13 @@
|
||||
"File Path": "文件路径",
|
||||
"File Size": "文件大小",
|
||||
"File Type": "文件类型",
|
||||
"File types:": "文件类型:",
|
||||
"Filename": "文件名",
|
||||
"Fill": "填满",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五星",
|
||||
"Fixed keys": "固定按键",
|
||||
"Flagged as Pick": "已标记为留用",
|
||||
"Flagged as Reject": "已标记为排除",
|
||||
"Focal Length": "焦距",
|
||||
@@ -495,6 +503,7 @@
|
||||
"GPU rendering": "GPU 渲染",
|
||||
"Generated demo photo": "生成的演示照片",
|
||||
"Grab bag": "名单",
|
||||
"Green label": "绿色标签",
|
||||
"Group name": "分组名称",
|
||||
"HDR Merge Preview": "HDR 合并预览",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合并预览",
|
||||
@@ -505,16 +514,21 @@
|
||||
"Has GPS": "有 GPS",
|
||||
"Home": "主文件夹",
|
||||
"Hours": "小时",
|
||||
"How to install the model": "如何安装模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "导入 Lightroom 目录",
|
||||
"Import Lightroom Catalog…": "导入 Lightroom 目录…",
|
||||
"Import as new": "作为新照片导入",
|
||||
"Imported": "已导入",
|
||||
"Importing Lightroom Catalog": "正在导入 Lightroom 目录",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠",
|
||||
"Info Overlay Off": "关闭信息叠加",
|
||||
"Info Overlay: Exposure": "信息叠加:曝光",
|
||||
"Info Overlay: File": "信息叠加:文件",
|
||||
"Insert at the cursor": "在光标处插入",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"Inspect Lightroom Catalog": "检查 Lightroom 目录",
|
||||
"Inspecting Lightroom Catalog": "正在检查 Lightroom 目录",
|
||||
"Invert Component": "反相组件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
|
||||
"Label": "标签",
|
||||
@@ -526,6 +540,9 @@
|
||||
"Library saved": "图库已保存",
|
||||
"Licence:": "许可证:",
|
||||
"LightCraft could not start": "LightCraft 无法启动",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目录",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 导入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 检查",
|
||||
"Line delta": "行数差",
|
||||
"Linear": "线性",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -552,6 +569,7 @@
|
||||
"Nested group": "嵌套组",
|
||||
"New time": "新时间",
|
||||
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
|
||||
"No matching commands": "没有匹配的命令",
|
||||
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
|
||||
"No named people in these photos": "这些照片中没有已命名的人物",
|
||||
"No named people yet": "还没有已命名的人物",
|
||||
@@ -574,6 +592,7 @@
|
||||
"Pictures": "图片",
|
||||
"Prepare AI Masks": "准备 AI 蒙版",
|
||||
"Presets and profiles": "预设和配置文件",
|
||||
"Press keys…": "请按键…",
|
||||
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
|
||||
"Proof copy created": "已创建校样副本",
|
||||
"Quit Anyway": "仍然退出",
|
||||
@@ -581,12 +600,24 @@
|
||||
"RAW decoding": "RAW 解码",
|
||||
"Radial": "径向",
|
||||
"Random": "随机",
|
||||
"Rate ★": "评为 ★",
|
||||
"Rate ★ and advance": "评为 ★ 并前进",
|
||||
"Rate ★★": "评为 ★★",
|
||||
"Rate ★★ and advance": "评为 ★★ 并前进",
|
||||
"Rate ★★★": "评为 ★★★",
|
||||
"Rate ★★★ and advance": "评为 ★★★ 并前进",
|
||||
"Rate ★★★★": "评为 ★★★★",
|
||||
"Rate ★★★★ and advance": "评为 ★★★★ 并前进",
|
||||
"Rate ★★★★★": "评为 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "评为 ★★★★★ 并前进",
|
||||
"Rating cleared": "已清除星级",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "阅读 SAM 许可证",
|
||||
"Reading Lightroom catalog…": "正在读取 Lightroom 目录…",
|
||||
"Real name": "真实姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的关键字",
|
||||
"Red label": "红色标签",
|
||||
"Refine": "细化",
|
||||
"Refine AI Mask Detail": "细化 AI 蒙版细节",
|
||||
"Refine Edges": "细化边缘",
|
||||
@@ -598,9 +629,12 @@
|
||||
"Remove Face Box": "移除人脸框",
|
||||
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
|
||||
"Remove from Target Album": "从目标相册中移除",
|
||||
"Remove shortcut": "移除快捷键",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
|
||||
"Rename Component": "重命名组件",
|
||||
"Reorder Masks": "重新排序蒙版",
|
||||
"Reset All": "全部重置",
|
||||
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
|
||||
"Reshuffle": "重新随机排序",
|
||||
"File name": "文件名",
|
||||
"Fill Edges": "填充边缘",
|
||||
@@ -1146,6 +1180,7 @@
|
||||
"Resize to": "调整大小为",
|
||||
"Resolution (ppi)": "分辨率 (ppi)",
|
||||
"Restore": "恢复",
|
||||
"Restore (no shortcut)": "恢复(无快捷键)",
|
||||
"Restore Library from Backup…": "从备份恢复图库…",
|
||||
"Restore Version": "恢复版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
|
||||
@@ -1192,6 +1227,7 @@
|
||||
"Save as set": "保存为集",
|
||||
"Save as…": "另存为…",
|
||||
"Save preset": "保存预设",
|
||||
"Saving Lightroom import index…": "正在保存 Lightroom 导入索引…",
|
||||
"Scale": "缩放",
|
||||
"Scan failed": "扫描失败",
|
||||
"Scanning": "正在扫描",
|
||||
@@ -1199,6 +1235,7 @@
|
||||
"Screen": "屏幕",
|
||||
"Search": "搜索",
|
||||
"Search Photos": "搜索照片",
|
||||
"Search commands or keys": "搜索命令或按键",
|
||||
"Search…": "搜索…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二个窗口",
|
||||
@@ -1229,6 +1266,7 @@
|
||||
"Set Develop Values": "设置编辑参数",
|
||||
"Set Flag": "设置旗标",
|
||||
"Set Image Zoom and Pan": "设置图像缩放和平移",
|
||||
"Set Keyboard Shortcut": "设置键盘快捷键",
|
||||
"Set Mask Adjustments": "设置蒙版调整",
|
||||
"Set Point Curve": "设置点曲线",
|
||||
"Set Profile": "设置配置文件",
|
||||
@@ -1278,6 +1316,7 @@
|
||||
"Show Pins": "显示图钉",
|
||||
"Show Source": "显示来源",
|
||||
"Show Thumbnails": "显示缩略图",
|
||||
"Show commands without a shortcut": "显示没有快捷键的命令",
|
||||
"Show destination gamut warning": "显示目标色域警告",
|
||||
"Show display gamut warning": "显示显示器色域警告",
|
||||
"Show file names": "显示文件名",
|
||||
@@ -1381,6 +1420,7 @@
|
||||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "纹理",
|
||||
"That key can't be used as a shortcut": "该按键不能用作快捷键",
|
||||
"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)",
|
||||
@@ -1390,7 +1430,6 @@
|
||||
"Theme": "主题",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
|
||||
"Thirds": "三分法",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
|
||||
"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": "阈值",
|
||||
@@ -1418,6 +1457,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹",
|
||||
"Type": "类型",
|
||||
"Uncheck All": "全部取消选中",
|
||||
"Uncheck the files of this type": "取消勾选此类型的所有文件",
|
||||
"Undo": "撤销",
|
||||
"Unedited": "未编辑",
|
||||
"Unedited raws": "未编辑的 RAW",
|
||||
@@ -1494,6 +1534,7 @@
|
||||
"Yellow Hue": "黄色色相",
|
||||
"Yellow Luminance": "黄色明度",
|
||||
"Yellow Saturation": "黄色饱和度",
|
||||
"Yellow label": "黄色标签",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
|
||||
"Your Name": "你的名字",
|
||||
@@ -1595,5 +1636,8 @@
|
||||
"Expand": "展开",
|
||||
"Remove Disk from Library": "从图库中移除磁盘",
|
||||
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。",
|
||||
"How to install the model": "如何安装模型",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
|
||||
}
|
||||
@@ -42,6 +42,12 @@
|
||||
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已檢查 Lightroom 目錄:{photos} 張相片,{collections} 個收藏集,{missing} 個遺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 匯入完成:已加入 {imported} 張相片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 匯入完成:已加入 {imported} 張相片,{failed} 張失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 匯入完成:已加入 {imported} 張相片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 匯入失敗:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 檢查失敗:{e}{warning}",
|
||||
"Label Set: {name}": "標籤集:{name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
@@ -59,15 +65,18 @@
|
||||
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
|
||||
"Quick Develop: {name}": "快速顯像:{name}",
|
||||
"Rated {}": "評分:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在讀取 Lightroom 目錄… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
|
||||
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
|
||||
"Redo {}": "重做 {}",
|
||||
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
|
||||
"Removed from {}": "已從 {} 移除",
|
||||
"Rename {n} Photos…": "重新命名 {n} 張照片…",
|
||||
"Rename “{name}”": "重新命名「{name}」",
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Restore {}": "還原為 {}",
|
||||
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
|
||||
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
|
||||
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
|
||||
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
|
||||
"Adding": "正在新增",
|
||||
"Adding Lightroom photos…": "正在加入 Lightroom 相片…",
|
||||
"Advanced search…": "進階搜尋…",
|
||||
"After": "編輯後",
|
||||
"After 30 days": "30 天後",
|
||||
@@ -152,6 +153,7 @@
|
||||
"Blue Primary": "藍色原色",
|
||||
"Blue Saturation": "藍色飽和度",
|
||||
"Blue channel": "藍色通道",
|
||||
"Blue label": "藍色標籤",
|
||||
"Blue/Yellow Fringe": "藍/黃色邊緣",
|
||||
"Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
|
||||
"Boundary Warp": "邊界變形",
|
||||
@@ -194,6 +196,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。",
|
||||
"Check All": "全部勾選",
|
||||
"Check Output Path": "檢驗輸出路徑",
|
||||
"Check the files of this type": "勾選此類型的所有檔案",
|
||||
"Checking names…": "正在檢查名稱…",
|
||||
"Choose Another Library…": "選擇其他照片庫…",
|
||||
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
|
||||
@@ -215,8 +218,11 @@
|
||||
"Clear Reference": "清除參考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表單",
|
||||
"Clear rating": "清除評分",
|
||||
"Clear rating and advance": "清除評分並前進",
|
||||
"Cleared History": "已清除歷史紀錄",
|
||||
"Click Zoom Ratio": "點擊縮放比例",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "按一下快速鍵,然後按下新的按鍵(Esc 取消)。",
|
||||
"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": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分",
|
||||
@@ -481,11 +487,13 @@
|
||||
"File Path": "檔案路徑",
|
||||
"File Size": "檔案大小",
|
||||
"File Type": "檔案類型",
|
||||
"File types:": "檔案類型:",
|
||||
"Filename": "檔名",
|
||||
"Fill": "填滿",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五顆星",
|
||||
"Fixed keys": "固定按鍵",
|
||||
"Flagged as Pick": "已標記為精選",
|
||||
"Flagged as Reject": "已標記為排除",
|
||||
"Focal Length": "焦距",
|
||||
@@ -495,6 +503,7 @@
|
||||
"GPU rendering": "GPU 算繪",
|
||||
"Generated demo photo": "產生的示範照片",
|
||||
"Grab bag": "名單",
|
||||
"Green label": "綠色標籤",
|
||||
"Group name": "群組名稱",
|
||||
"HDR Merge Preview": "HDR 合併預覽",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
|
||||
@@ -505,16 +514,21 @@
|
||||
"Has GPS": "有 GPS",
|
||||
"Home": "個人資料夾",
|
||||
"Hours": "小時",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "匯入 Lightroom 目錄",
|
||||
"Import Lightroom Catalog…": "匯入 Lightroom 目錄…",
|
||||
"Import as new": "以新照片匯入",
|
||||
"Imported": "已匯入",
|
||||
"Importing Lightroom Catalog": "正在匯入 Lightroom 目錄",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊",
|
||||
"Info Overlay Off": "關閉資訊疊加",
|
||||
"Info Overlay: Exposure": "資訊疊加:曝光",
|
||||
"Info Overlay: File": "資訊疊加:檔案",
|
||||
"Insert at the cursor": "在游標處插入",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"Inspect Lightroom Catalog": "檢查 Lightroom 目錄",
|
||||
"Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄",
|
||||
"Invert Component": "反轉元件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
|
||||
"Label": "標籤",
|
||||
@@ -526,6 +540,9 @@
|
||||
"Library saved": "照片庫已儲存",
|
||||
"Licence:": "授權:",
|
||||
"LightCraft could not start": "LightCraft 無法啟動",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目錄",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 匯入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 檢查",
|
||||
"Line delta": "行數差",
|
||||
"Linear": "線性",
|
||||
"Lines +/−": "行數 +/−",
|
||||
@@ -552,6 +569,7 @@
|
||||
"Nested group": "巢狀群組",
|
||||
"New time": "新時間",
|
||||
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
|
||||
"No matching commands": "沒有符合的命令",
|
||||
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
|
||||
"No named people in these photos": "這些照片中沒有已命名的人物",
|
||||
"No named people yet": "尚無已命名的人物",
|
||||
@@ -574,6 +592,7 @@
|
||||
"Pictures": "圖片",
|
||||
"Prepare AI Masks": "準備 AI 遮色片",
|
||||
"Presets and profiles": "預設集與描述檔",
|
||||
"Press keys…": "請按鍵…",
|
||||
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
|
||||
"Proof copy created": "已建立校樣副本",
|
||||
"Quit Anyway": "仍要結束",
|
||||
@@ -581,12 +600,24 @@
|
||||
"RAW decoding": "RAW 解碼",
|
||||
"Radial": "放射狀",
|
||||
"Random": "隨機",
|
||||
"Rate ★": "評為 ★",
|
||||
"Rate ★ and advance": "評為 ★ 並前進",
|
||||
"Rate ★★": "評為 ★★",
|
||||
"Rate ★★ and advance": "評為 ★★ 並前進",
|
||||
"Rate ★★★": "評為 ★★★",
|
||||
"Rate ★★★ and advance": "評為 ★★★ 並前進",
|
||||
"Rate ★★★★": "評為 ★★★★",
|
||||
"Rate ★★★★ and advance": "評為 ★★★★ 並前進",
|
||||
"Rate ★★★★★": "評為 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評為 ★★★★★ 並前進",
|
||||
"Rating cleared": "已清除評分",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "閱讀 SAM 授權",
|
||||
"Reading Lightroom catalog…": "正在讀取 Lightroom 目錄…",
|
||||
"Real name": "真實姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的關鍵字",
|
||||
"Red label": "紅色標籤",
|
||||
"Refine": "調整",
|
||||
"Refine AI Mask Detail": "調整 AI 遮色片細節",
|
||||
"Refine Edges": "調整邊緣",
|
||||
@@ -598,9 +629,12 @@
|
||||
"Remove Face Box": "移除臉部框",
|
||||
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
|
||||
"Remove from Target Album": "從目標相簿移除",
|
||||
"Remove shortcut": "移除快速鍵",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
|
||||
"Rename Component": "重新命名元件",
|
||||
"Reorder Masks": "重新排序遮色片",
|
||||
"Reset All": "全部重設",
|
||||
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
|
||||
"Reshuffle": "重新隨機排序",
|
||||
"File name": "檔案名稱",
|
||||
"Fill Edges": "填補邊緣",
|
||||
@@ -1146,6 +1180,7 @@
|
||||
"Resize to": "調整尺寸為",
|
||||
"Resolution (ppi)": "解析度(ppi)",
|
||||
"Restore": "還原",
|
||||
"Restore (no shortcut)": "還原(無快速鍵)",
|
||||
"Restore Library from Backup…": "從備份還原照片庫…",
|
||||
"Restore Version": "還原版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
|
||||
@@ -1192,6 +1227,7 @@
|
||||
"Save as set": "儲存為關鍵字集",
|
||||
"Save as…": "另存新檔…",
|
||||
"Save preset": "儲存預設集",
|
||||
"Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…",
|
||||
"Scale": "縮放",
|
||||
"Scan failed": "掃描失敗",
|
||||
"Scanning": "正在掃描",
|
||||
@@ -1199,6 +1235,7 @@
|
||||
"Screen": "螢幕",
|
||||
"Search": "搜尋",
|
||||
"Search Photos": "搜尋照片",
|
||||
"Search commands or keys": "搜尋命令或按鍵",
|
||||
"Search…": "搜尋…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二視窗",
|
||||
@@ -1229,6 +1266,7 @@
|
||||
"Set Develop Values": "設定顯像參數",
|
||||
"Set Flag": "設定旗標",
|
||||
"Set Image Zoom and Pan": "設定影像縮放與平移",
|
||||
"Set Keyboard Shortcut": "設定鍵盤快速鍵",
|
||||
"Set Mask Adjustments": "設定遮色片調整",
|
||||
"Set Point Curve": "設定點曲線",
|
||||
"Set Profile": "設定描述檔",
|
||||
@@ -1278,6 +1316,7 @@
|
||||
"Show Pins": "顯示控制點",
|
||||
"Show Source": "顯示來源",
|
||||
"Show Thumbnails": "顯示縮圖",
|
||||
"Show commands without a shortcut": "顯示沒有快速鍵的命令",
|
||||
"Show destination gamut warning": "顯示目標色域警告",
|
||||
"Show display gamut warning": "顯示螢幕色域警告",
|
||||
"Show file names": "顯示檔案名稱",
|
||||
@@ -1381,6 +1420,7 @@
|
||||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "紋理",
|
||||
"That key can't be used as a shortcut": "該按鍵無法用作快速鍵",
|
||||
"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)",
|
||||
@@ -1390,7 +1430,6 @@
|
||||
"Theme": "主題",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
|
||||
"Thirds": "三分法",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
|
||||
"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": "臨界值",
|
||||
@@ -1418,6 +1457,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
|
||||
"Type": "類型",
|
||||
"Uncheck All": "全部取消勾選",
|
||||
"Uncheck the files of this type": "取消勾選此類型的所有檔案",
|
||||
"Undo": "復原",
|
||||
"Unedited": "未編輯",
|
||||
"Unedited raws": "未編輯的 RAW",
|
||||
@@ -1494,6 +1534,7 @@
|
||||
"Yellow Hue": "黃色色相",
|
||||
"Yellow Luminance": "黃色明度",
|
||||
"Yellow Saturation": "黃色飽和度",
|
||||
"Yellow label": "黃色標籤",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
|
||||
"Your Name": "你的名字",
|
||||
@@ -1595,5 +1636,8 @@
|
||||
"Expand": "展開",
|
||||
"Remove Disk from Library": "從照片庫移除磁碟",
|
||||
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
|
||||
}
|
||||
@@ -54,8 +54,21 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
||||
}
|
||||
|
||||
pub fn all_commands(app: &LightcraftApp) -> Value {
|
||||
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
let mut v: Vec<Value> = app
|
||||
.session
|
||||
.commands()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
||||
let sc = crate::shortcuts::binding(keymap, id, *sc);
|
||||
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
||||
}
|
||||
Value::Array(v)
|
||||
|
||||
@@ -1635,8 +1635,9 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (src, dest) = (base.join("card"), base.join("out"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
|
||||
// more files than a batch holds (see `batch_size`)
|
||||
for i in 0..20u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
|
||||
}
|
||||
@@ -1651,7 +1652,7 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.candidates.len(), 10);
|
||||
assert_eq!(opts.candidates.len(), 20);
|
||||
for id in ["button:importCopy", "button:importDest"] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), t);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
@@ -1670,8 +1671,8 @@ mod tests {
|
||||
assert!(h.app.import.is_none(), "finished");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
|
||||
let want: Vec<String> = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ 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");
|
||||
Es, "es", "Español", "Latn", include_str!("../locales/es.json");
|
||||
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
|
||||
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
|
||||
}
|
||||
@@ -449,6 +450,9 @@ mod tests {
|
||||
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] {
|
||||
assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}");
|
||||
}
|
||||
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] {
|
||||
assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}");
|
||||
}
|
||||
assert_eq!(Locale::parse_tag("xx"), None);
|
||||
}
|
||||
|
||||
@@ -571,6 +575,7 @@ mod tests {
|
||||
("app.language.simplifiedChinese", Locale::ZhHans),
|
||||
("app.language.japanese", Locale::Ja),
|
||||
("app.language.portuguese", Locale::PtBr),
|
||||
("app.language.spanish", Locale::Es),
|
||||
("app.language.german", Locale::De),
|
||||
("app.language.russian", Locale::Ru),
|
||||
];
|
||||
|
||||
@@ -18,8 +18,10 @@ use crate::render::Slot;
|
||||
use crate::theme::Tokens;
|
||||
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;
|
||||
/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the
|
||||
/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
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];
|
||||
@@ -87,6 +89,22 @@ pub struct ImportDialog {
|
||||
pub last_clicked: Option<usize>,
|
||||
}
|
||||
|
||||
/// A candidate's file type, as the review groups them: its format, or its extension when the
|
||||
/// probe named none (`JPEG` and `JPG` are one type).
|
||||
fn file_type(c: &ImportCandidate) -> String {
|
||||
let f = if c.format.trim().is_empty() {
|
||||
std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else {
|
||||
c.format.clone()
|
||||
};
|
||||
match f.trim().to_uppercase().as_str() {
|
||||
"JPEG" => "JPG".into(),
|
||||
"TIFF" => "TIF".into(),
|
||||
"" => "?".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
pub fn new(candidates: Vec<ImportCandidate>) -> Self {
|
||||
let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect();
|
||||
@@ -95,6 +113,29 @@ impl ImportDialog {
|
||||
pub fn importable(&self, i: usize) -> bool {
|
||||
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()))
|
||||
}
|
||||
/// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each.
|
||||
pub fn file_types(&self) -> Vec<(String, usize)> {
|
||||
let mut types: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for c in &self.candidates {
|
||||
*types.entry(file_type(c)).or_default() += 1;
|
||||
}
|
||||
types.into_iter().collect()
|
||||
}
|
||||
/// Any file of type `ty` is checked.
|
||||
pub fn type_checked(&self, ty: &str) -> bool {
|
||||
self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty)
|
||||
}
|
||||
/// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not
|
||||
/// the JPEGs kept beside them (issue #344).
|
||||
pub fn check_type(&mut self, ty: &str, on: bool) {
|
||||
for i in 0..self.candidates.len() {
|
||||
let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty);
|
||||
let importable = self.importable(i);
|
||||
if same && let Some(c) = self.checked.get_mut(i) {
|
||||
*c = on && importable;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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)
|
||||
@@ -273,7 +314,7 @@ impl ImportRun {
|
||||
let ctx = ctx.clone();
|
||||
let work = move || {
|
||||
let files = job.expand(&queue);
|
||||
for chunk in files.chunks(BATCH) {
|
||||
for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
@@ -819,6 +860,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// with more than one file type: a toggle per type checks or unchecks all of its files
|
||||
let types = d.file_types();
|
||||
if types.len() > 1 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim));
|
||||
for (ty, count) in &types {
|
||||
let on = d.type_checked(ty);
|
||||
let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on)
|
||||
.on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" }));
|
||||
if r.clicked() {
|
||||
d.check_type(ty, !on);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// candidate grid
|
||||
let cell = 116.0;
|
||||
let avail = ui.available_width();
|
||||
@@ -1390,6 +1446,32 @@ mod tests {
|
||||
ImportDialog::new(candidates)
|
||||
}
|
||||
|
||||
/// A toggle per file type: the raws only, without the JPEGs beside them (issue #344).
|
||||
#[test]
|
||||
fn file_types_check_and_uncheck_together() {
|
||||
let mut candidates: Vec<ImportCandidate> =
|
||||
["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect();
|
||||
candidates[2].format = "ARW".into();
|
||||
candidates[3].format = "JPEG".into();
|
||||
candidates[2].duplicate = Some("content".into());
|
||||
let mut d = ImportDialog::new(candidates);
|
||||
assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]);
|
||||
assert!(d.type_checked("JPG"));
|
||||
d.check_type("JPG", false);
|
||||
assert_eq!(d.checked, [true, false, false, false, true]);
|
||||
assert!(!d.type_checked("JPG"));
|
||||
d.check_type("PNG", false);
|
||||
d.check_type("ARW", true);
|
||||
// the duplicate stays unchecked: it isn't importable
|
||||
assert_eq!(d.checked, [true, false, false, false, false]);
|
||||
d.check_type("JPG", true);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]);
|
||||
// an unknown type changes nothing
|
||||
d.check_type("CR3", false);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_toggles_one_candidate() {
|
||||
let mut d = dialog();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod headless;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod import;
|
||||
pub mod lightroom_import;
|
||||
pub mod links;
|
||||
pub mod menubar;
|
||||
pub mod menus;
|
||||
@@ -33,12 +34,16 @@ mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_keymap;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_masking_layout;
|
||||
#[cfg(test)]
|
||||
mod tests_offline;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
@@ -104,6 +109,8 @@ pub struct Services {
|
||||
pub reveal: Option<RevealFn>,
|
||||
/// Choose a folder (Settings → General → Open Library…; desktop only).
|
||||
pub pick_folder: Option<PickFolder>,
|
||||
/// Open a Lightroom Classic `.lrcat` catalog for read-only import.
|
||||
pub pick_lightroom_catalog: Option<PickFolder>,
|
||||
/// Open a web link in the browser (Help menu, About, Discord button).
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
@@ -146,6 +153,9 @@ pub struct LightcraftApp {
|
||||
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
|
||||
/// leaves them alone so nothing fires twice.
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
|
||||
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
|
||||
pub recording_shortcut: Option<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
@@ -191,6 +201,10 @@ pub struct LightcraftApp {
|
||||
pub import: Option<import::ImportTask>,
|
||||
/// A folder scan in progress (feeds the import review).
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// A Lightroom catalog inspect/import in progress.
|
||||
pub lightroom: Option<lightroom_import::LightroomTask>,
|
||||
/// Last terminal Lightroom result, exposed by the command's status/wait response.
|
||||
pub lightroom_last: Option<Value>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// Background file-system work of other commands (Find Missing Photos, auto import…).
|
||||
@@ -225,6 +239,7 @@ impl LightcraftApp {
|
||||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
recording_shortcut: None,
|
||||
headless_host: false,
|
||||
notices: vec![],
|
||||
quit_prompt: None,
|
||||
@@ -250,6 +265,8 @@ impl LightcraftApp {
|
||||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
scan: None,
|
||||
lightroom: None,
|
||||
lightroom_last: None,
|
||||
export: None,
|
||||
tasks: Default::default(),
|
||||
last_export_result: None,
|
||||
@@ -649,6 +666,7 @@ impl LightcraftApp {
|
||||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
lightroom_import::tick(self, ctx);
|
||||
tasks::poll(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.save_status(ctx);
|
||||
@@ -670,7 +688,7 @@ impl LightcraftApp {
|
||||
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
|
||||
const LABEL: &str = "Auto Import";
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && !self.tasks.is_running(LABEL) {
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) {
|
||||
self.ui.auto_import_at = now;
|
||||
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
|
||||
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
|
||||
@@ -711,10 +729,12 @@ impl LightcraftApp {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
|
||||
if !photos.is_empty()
|
||||
&& let Err(e) = import::start_paths(self, photos)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
if !photos.is_empty() {
|
||||
if self.lightroom.is_some() {
|
||||
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
|
||||
} else if let Err(e) = import::start_paths(self, photos) {
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +871,7 @@ impl LightcraftApp {
|
||||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
lightroom_import::progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Background Lightroom catalog inspection and import.
|
||||
//!
|
||||
//! Reading a catalog, probing its originals and staging its archive can take minutes on a
|
||||
//! removable or network drive. This task keeps that work off the owner thread, commits prepared
|
||||
//! data only on the owner thread, then finishes the archive index on a worker.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use egui::Align2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Inspect,
|
||||
Import,
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Inspected(Result<Value, String>),
|
||||
Prepared(Result<Box<lightcraft_engine::lightroom_job::PreparedLightroom>, String>),
|
||||
Finalized(Result<(), String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
Reading,
|
||||
Committing,
|
||||
Finalizing,
|
||||
}
|
||||
|
||||
/// A Lightroom task in flight. The receiver is polled from the egui owner thread.
|
||||
pub struct LightroomTask {
|
||||
kind: Kind,
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
rx: mpsc::Receiver<Message>,
|
||||
phase: Phase,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl LightroomTask {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn status(&self) -> Value {
|
||||
let kind = match self.kind {
|
||||
Kind::Inspect => "inspect",
|
||||
Kind::Import => "import",
|
||||
};
|
||||
let phase = match self.phase {
|
||||
Phase::Reading => "reading",
|
||||
Phase::Committing => "committing",
|
||||
Phase::Finalizing => "finalizing",
|
||||
};
|
||||
json!({
|
||||
"running": true,
|
||||
"kind": kind,
|
||||
"path": self.path,
|
||||
"phase": phase,
|
||||
"done": self.done.load(Ordering::Relaxed),
|
||||
"total": self.total.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightroomTask {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn unsupported_wasm() -> Result<Value, String> {
|
||||
Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into())
|
||||
}
|
||||
|
||||
/// Whether a Lightroom task currently owns the catalog transition.
|
||||
pub fn is_running(app: &LightcraftApp) -> bool {
|
||||
app.lightroom.is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn busy(app: &LightcraftApp) -> Result<(), String> {
|
||||
if app.lightroom.is_some() {
|
||||
return Err("a Lightroom catalog task is already running".into());
|
||||
}
|
||||
if app.import.is_some() || app.scan.is_some() {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_inspect(
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-inspect".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom inspect", || {
|
||||
lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done)
|
||||
})
|
||||
.and_then(|r| r.map(inspect_report));
|
||||
let _ = tx.send(Message::Inspected(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom inspect: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_prepare(
|
||||
mut job: lightcraft_engine::lightroom_job::LightroomJob,
|
||||
cancel: Arc<AtomicBool>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-import".into())
|
||||
.spawn(move || {
|
||||
let result =
|
||||
lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map(Box::new).map_err(|e| e.to_string()));
|
||||
if let Err(mpsc::SendError(Message::Prepared(Ok(prepared)))) = tx.send(Message::Prepared(result)) {
|
||||
// The owner dropped the task (for example while closing): staged copies/moves
|
||||
// must be put back even though no UI receiver remains to accept the result.
|
||||
prepared.rollback();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom import: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_finalize(
|
||||
finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-finalize".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom archive finalization", || finalization.finish())
|
||||
.and_then(|r| r.map_err(|e| e.to_string()));
|
||||
let _ = tx.send(Message::Finalized(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom archive finalization: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn spawn_finalize(
|
||||
_finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
_tx: mpsc::Sender<Message>,
|
||||
_ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
unsupported_wasm().map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value {
|
||||
json!({
|
||||
"photos": data.photos.len(),
|
||||
"collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(),
|
||||
"missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(),
|
||||
"warnings": data.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) {
|
||||
let message = if let Some(e) = value.get("error").and_then(Value::as_str) {
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}"));
|
||||
if matches!(kind, Kind::Inspect) {
|
||||
crate::i18n::tr_format!("Lightroom inspection failed: {e}{warning}", e = e, warning = warning)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import failed: {e}{warning}", e = e, warning = warning)
|
||||
}
|
||||
} else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) {
|
||||
crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection cancelled" } else { "Lightroom import cancelled" }).into()
|
||||
} else if matches!(kind, Kind::Inspect) {
|
||||
let photos = value["photos"].as_u64().unwrap_or(0);
|
||||
let collections = value["collections"].as_u64().unwrap_or(0);
|
||||
let missing = value["missing"].as_array().map_or(0, Vec::len);
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing",
|
||||
photos = photos,
|
||||
collections = collections,
|
||||
missing = missing
|
||||
)
|
||||
} else {
|
||||
let report = value.get("report").unwrap_or(&value);
|
||||
let imported = report["imported"].as_u64().unwrap_or(0);
|
||||
let failed = report["failed"].as_array().map_or(0, Vec::len);
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str);
|
||||
if let Some(warning) = warning {
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}",
|
||||
imported = imported,
|
||||
warning = warning
|
||||
)
|
||||
} else if failed == 0 {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added", imported = imported)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed)
|
||||
}
|
||||
};
|
||||
app.lightroom_last = Some(value);
|
||||
app.lightroom = None;
|
||||
app.toast_for(ctx, message, 6.0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "inspect", "path": path}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = job.total_atomic();
|
||||
let done = job.done_atomic();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_prepare(job, cancel.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "import", "path": path}))
|
||||
}
|
||||
|
||||
/// Run Lightroom UI commands before the generic engine dispatcher.
|
||||
pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context) -> Result<Value, String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (app, id, p, ctx);
|
||||
return unsupported_wasm();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = p.get("path").and_then(Value::as_str).map(PathBuf::from);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
||||
let status = p.get("status").and_then(Value::as_bool).unwrap_or(false) || path.is_none();
|
||||
if status {
|
||||
if p.get("cancel").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(task) = app.lightroom.as_mut()
|
||||
{
|
||||
task.cancel();
|
||||
}
|
||||
if wait && app.lightroom.is_some() {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
return Ok(app.lightroom.as_ref().map(LightroomTask::status).or_else(|| app.lightroom_last.clone()).unwrap_or(Value::Null));
|
||||
}
|
||||
let Some(path) = path else { return Err("missing `path`".into()) };
|
||||
let result = if id == "library.inspectLightroom" {
|
||||
start_inspect(app, path, ctx)
|
||||
} else {
|
||||
start_import(app, path, p.get("updateExisting").and_then(Value::as_bool).unwrap_or(false), ctx)
|
||||
}?;
|
||||
if wait {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll owner-thread work, commit a prepared import, and finish its archive index.
|
||||
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut task) = app.lightroom.take() else { return };
|
||||
let message = match task.rx.try_recv() {
|
||||
Ok(m) => m,
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
app.lightroom = Some(task);
|
||||
return;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match message {
|
||||
Message::Inspected(result) => match result {
|
||||
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report),
|
||||
Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) => terminal(app, ctx, task.kind, json!({"error": e})),
|
||||
},
|
||||
Message::Prepared(result) => {
|
||||
let prepared = match result {
|
||||
Ok(p) => p,
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => {
|
||||
terminal(app, ctx, task.kind, json!({"cancelled": true}));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": e}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) {
|
||||
prepared.rollback();
|
||||
terminal(
|
||||
app,
|
||||
ctx,
|
||||
task.kind,
|
||||
if task.cancel.load(Ordering::Relaxed) {
|
||||
json!({"cancelled": true})
|
||||
} else {
|
||||
json!({"error": "Lightroom import belongs to a different open library"})
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
task.phase = Phase::Committing;
|
||||
let mut finalization = None;
|
||||
let mut report = None;
|
||||
let committed = app.session.execute_fn("library.importLightroom", |s| {
|
||||
let completion = lightcraft_engine::lightroom_job::commit_prepared(s, *prepared)?;
|
||||
report = Some(completion.report.clone());
|
||||
finalization = Some(completion.finalization);
|
||||
Ok(completion.report)
|
||||
});
|
||||
let report = report.unwrap_or(Value::Null);
|
||||
task.phase = Phase::Finalizing;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let commit_error = committed.as_ref().err().map(ToString::to_string);
|
||||
let Some(finalization) = finalization else {
|
||||
terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));
|
||||
return;
|
||||
};
|
||||
if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {
|
||||
let mut result = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = commit_error {
|
||||
result["error"] = json!(error);
|
||||
}
|
||||
terminal(app, ctx, task.kind, result);
|
||||
return;
|
||||
}
|
||||
task.rx = rx;
|
||||
task.cancelled = false;
|
||||
let mut pending = json!({"running": true, "kind": "import", "report": report});
|
||||
if let Some(error) = commit_error {
|
||||
pending["error"] = json!(error);
|
||||
}
|
||||
app.lightroom_last = Some(pending);
|
||||
app.lightroom = Some(task);
|
||||
}
|
||||
Message::Finalized(result) => {
|
||||
let pending = app.lightroom_last.take().unwrap_or(Value::Null);
|
||||
let report = pending.get("report").cloned().unwrap_or(Value::Null);
|
||||
match result {
|
||||
Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report),
|
||||
Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})),
|
||||
Err(e) => {
|
||||
let mut value = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = pending.get("error") {
|
||||
value["error"] = error.clone();
|
||||
}
|
||||
terminal(app, ctx, task.kind, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Wait for a task in control/headless mode while still applying owner-thread completion.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait_for(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> Result<Value, String> {
|
||||
let start = std::time::Instant::now();
|
||||
while app.lightroom.is_some() {
|
||||
tick(app, ctx);
|
||||
if start.elapsed() >= timeout {
|
||||
return Err("timed out waiting for Lightroom task".into());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
Ok(app.lightroom_last.clone().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::time::Duration) -> Result<Value, String> {
|
||||
unsupported_wasm()
|
||||
}
|
||||
|
||||
/// The progress window for inspect/import, with cancellation.
|
||||
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.lightroom.as_ref() else { return };
|
||||
let total = task.total.load(Ordering::Relaxed);
|
||||
let done = task.done.load(Ordering::Relaxed);
|
||||
let title = match task.kind {
|
||||
Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"),
|
||||
Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"),
|
||||
};
|
||||
let text = match task.phase {
|
||||
Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(),
|
||||
Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total),
|
||||
Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(),
|
||||
Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(),
|
||||
};
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0));
|
||||
let r =
|
||||
ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
register(ui.ctx(), "button:lightroomCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
},
|
||||
);
|
||||
if cancel {
|
||||
if let Some(task) = app.lightroom.as_mut() {
|
||||
task.cancel();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if app.lightroom.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::inspect_report;
|
||||
use lightcraft_engine::lightroom_catalog::CatalogImport;
|
||||
|
||||
#[test]
|
||||
fn inspect_report_keeps_empty_catalog_shape() {
|
||||
let report = inspect_report(CatalogImport {
|
||||
source: "synthetic.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: vec!["warning".into()],
|
||||
});
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["collections"], 0);
|
||||
assert_eq!(report["missing"], serde_json::json!([]));
|
||||
assert_eq!(report["warnings"], serde_json::json!(["warning"]));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
|
||||
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
|
||||
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
|
||||
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
|
||||
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
|
||||
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
|
||||
];
|
||||
@@ -37,6 +38,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
|
||||
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
|
||||
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
|
||||
"app.language.spanish" => Some(crate::i18n::Locale::Es),
|
||||
"app.language.german" => Some(crate::i18n::Locale::De),
|
||||
"app.language.russian" => Some(crate::i18n::Locale::Ru),
|
||||
_ => None,
|
||||
@@ -148,6 +150,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
|
||||
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
|
||||
("file.addFolder", "Import from Folder…", None, "File"),
|
||||
("file.importLightroom", "Import Lightroom Catalog…", None, "File"),
|
||||
("file.addFromDevice", "Import from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("file.backupLibrary", "Back Up Library…", None, "File"),
|
||||
@@ -173,6 +176,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.github", "LightCraft on GitHub", None, "Help"),
|
||||
("app.artcraft", "ArtCraft Website", None, "Help"),
|
||||
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
|
||||
("app.setShortcut", "Set Keyboard Shortcut", None, ""),
|
||||
("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""),
|
||||
("app.export", "Export Now", None, ""),
|
||||
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
|
||||
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
|
||||
@@ -240,6 +245,10 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
||||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
|
||||
let ctx = egui::Context::default();
|
||||
return Some(crate::lightroom_import::command(app, id, p, &ctx));
|
||||
}
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
// Immediately, not on the next frame: the reply and anything else run this frame
|
||||
@@ -976,12 +985,23 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::Shortcuts);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.setShortcut" => crate::shortcuts::set_shortcut(app, p),
|
||||
"app.resetShortcuts" => {
|
||||
app.ui.settings.keymap.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"library.browse" if !cfg!(target_arch = "wasm32") => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before browsing folders".into()));
|
||||
}
|
||||
// listed and read in the background (see `import::browse`)
|
||||
let path = p.get("path").and_then(Value::as_str)?;
|
||||
crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool))
|
||||
}
|
||||
"file.addPhotos" => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before adding photos".into()));
|
||||
}
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
|
||||
@@ -992,6 +1012,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
// review first: the import dialog lists what was found
|
||||
crate::import::open(app, paths)
|
||||
}
|
||||
"file.importLightroom" => {
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f()));
|
||||
match path {
|
||||
Some(path) => app.run("library.importLightroom", json!({"path":path})),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.ui.quit = true;
|
||||
Ok(Value::Null)
|
||||
@@ -1319,7 +1347,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
|
||||
@@ -1347,7 +1375,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
menu: m.split('>').map(str::to_string).collect(),
|
||||
shortcut: sc.map(str::to_string),
|
||||
shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string),
|
||||
enabled: ui_enabled(app, id),
|
||||
})
|
||||
.collect();
|
||||
@@ -1357,7 +1385,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
menu: c.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: c.shortcut.map(str::to_string),
|
||||
shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string),
|
||||
enabled: c.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -780,50 +780,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app);
|
||||
ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label }));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for sc in ["0–5", "Shift+0–5"] {
|
||||
ui.label(crate::i18n::tr("Set Rating"));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new("photo.rate").color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(crate::i18n::tr("Set Color Label"));
|
||||
ui.label("6–9");
|
||||
ui.label(egui::RichText::new("photo.label").color(t.text_dim));
|
||||
ui.end_row();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(crate::i18n::tr(c.label));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new(c.id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for (sc, id, _) in crate::shortcuts::ALIASES {
|
||||
let label = crate::menus::ui_commands()
|
||||
.find(|c| c.0 == *id)
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
.unwrap_or(id);
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t),
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -882,6 +839,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
crate::widgets::register(ctx, "dialog:window", w.response.rect);
|
||||
}
|
||||
// (while the shortcuts editor records a key, it takes Esc itself to cancel)
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Help ▸ Keyboard Shortcuts (⌘/): every command's shortcut, editable.
|
||||
//!
|
||||
//! Click a shortcut, then press the new keys (Esc cancels); × removes it, ↺ restores the declared
|
||||
//! one. A key another command had moves to the new command. Changes go through
|
||||
//! [`crate::shortcuts::assign`] into [`crate::state::AppSettings::keymap`] (saved in `ui.json`),
|
||||
//! the same path as the `app.setShortcut` command.
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::shortcuts::{self, Bindable};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
const SEARCH: &str = "shortcuts-search";
|
||||
const SHOW_ALL: &str = "shortcuts-show-all";
|
||||
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
capture(app, ui.ctx());
|
||||
ui.set_min_width(560.0);
|
||||
let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL));
|
||||
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0));
|
||||
register(ui.ctx(), "field:shortcutsSearch", r.rect);
|
||||
let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut"));
|
||||
register(ui.ctx(), "check:shortcuts.showAll", r.rect);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All")));
|
||||
register(ui.ctx(), "button:shortcutsResetAll", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.resetShortcuts", serde_json::json!({}));
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(search_id, search.clone());
|
||||
d.insert_temp(all_id, show_all);
|
||||
});
|
||||
ui.label(RichText::new(crate::i18n::tr("Click a shortcut, then press the new keys (Esc cancels).")).size(11.0).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
|
||||
let needle = search.trim().to_lowercase();
|
||||
let mut rows: Vec<(&'static Bindable, Option<String>)> = shortcuts::bindable()
|
||||
.iter()
|
||||
.map(|b| (b, shortcuts::binding(&app.ui.settings.keymap, b.id, b.default).map(str::to_string)))
|
||||
.filter(|(b, sc)| show_all || sc.is_some() || app.recording_shortcut.as_deref() == Some(b.id))
|
||||
.filter(|(b, sc)| {
|
||||
needle.is_empty()
|
||||
|| crate::i18n::tr(b.label).to_lowercase().contains(&needle)
|
||||
|| b.id.to_lowercase().contains(&needle)
|
||||
|| sc.as_deref().is_some_and(|sc| sc.to_lowercase().contains(&needle) || menu_text(sc, mac).to_lowercase().contains(&needle))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase());
|
||||
|
||||
// a fixed height, so the dialog doesn't jump around while the search narrows the list
|
||||
egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (b, sc) in &rows {
|
||||
row(app, ui, t, b, sc.as_deref(), mac);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.label(RichText::new(crate::i18n::tr("No matching commands")).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
fixed_keys(app, ui, t, mac, &needle);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS).
|
||||
fn menu_text(sc: &str, mac: bool) -> String {
|
||||
crate::menubar::shortcut_text(sc, mac)
|
||||
}
|
||||
|
||||
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) {
|
||||
let changed = app.ui.settings.keymap.contains_key(b.id);
|
||||
let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text);
|
||||
if changed {
|
||||
name = name.strong();
|
||||
}
|
||||
// keys whose command changes in the Library grids (see `shortcuts::handle`)
|
||||
let hover = match b.id {
|
||||
"panel.presets" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: Flag as Pick and advance")),
|
||||
"view.softProof" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: expand or collapse the stack")),
|
||||
_ => b.id.to_string(),
|
||||
};
|
||||
ui.label(name).on_hover_text(hover);
|
||||
let recording = app.recording_shortcut.as_deref() == Some(b.id);
|
||||
let text = if recording {
|
||||
RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE)
|
||||
} else {
|
||||
match sc {
|
||||
Some(sc) => RichText::new(menu_text(sc, mac)).color(t.text),
|
||||
None => RichText::new("—").color(t.text_dim),
|
||||
}
|
||||
};
|
||||
let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording));
|
||||
register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) };
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut"));
|
||||
register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null}));
|
||||
}
|
||||
let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default {
|
||||
Some(d) => crate::i18n::tr_format!("Restore {}", menu_text(d, mac)),
|
||||
None => crate::i18n::tr("Restore (no shortcut)").to_string(),
|
||||
});
|
||||
register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true}));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(b.id).size(10.5).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Keys that aren't a command's own shortcut (ratings, labels, secondary keys): listed, not editable.
|
||||
/// One the user gave to a command shows struck through.
|
||||
fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, needle: &str) {
|
||||
let mut fixed: Vec<(String, &str)> = Vec::new();
|
||||
for (sc, id, _) in shortcuts::ALIASES {
|
||||
let label = shortcuts::find_bindable(id).map_or(*id, |b| b.label);
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
for n in 0..=5 {
|
||||
fixed.push((n.to_string(), ["Clear rating", "Rate ★", "Rate ★★", "Rate ★★★", "Rate ★★★★", "Rate ★★★★★"][n]));
|
||||
fixed.push((
|
||||
format!("Shift+{n}"),
|
||||
[
|
||||
"Clear rating and advance",
|
||||
"Rate ★ and advance",
|
||||
"Rate ★★ and advance",
|
||||
"Rate ★★★ and advance",
|
||||
"Rate ★★★★ and advance",
|
||||
"Rate ★★★★★ and advance",
|
||||
][n],
|
||||
));
|
||||
}
|
||||
for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] {
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
let fixed: Vec<_> = fixed
|
||||
.into_iter()
|
||||
.filter(|(sc, label)| {
|
||||
needle.is_empty() || crate::i18n::tr(label).to_lowercase().contains(needle) || menu_text(sc, mac).to_lowercase().contains(needle)
|
||||
})
|
||||
.collect();
|
||||
if fixed.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text));
|
||||
let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect();
|
||||
egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, label) in fixed {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
||||
let mut text = RichText::new(menu_text(&sc, mac)).color(t.text_dim);
|
||||
if shortcuts::parse(&sc).is_some_and(|k| taken.contains(&k)) {
|
||||
text = text.strikethrough();
|
||||
}
|
||||
ui.label(text);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `app.setShortcut` and say what changed (a key taken from another command, or why not).
|
||||
fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value) {
|
||||
match app.run("app.setShortcut", params) {
|
||||
Ok(r) => {
|
||||
let lost: Vec<String> = r["removedFrom"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string())
|
||||
.collect();
|
||||
if !lost.is_empty() {
|
||||
app.toast_for(ctx, crate::i18n::tr_format!("Removed from {}", lost.join(", ")), 3.0);
|
||||
}
|
||||
}
|
||||
Err(e) => app.toast_for(ctx, e, 3.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// While a row is recording: the first key press becomes its shortcut, Esc cancels.
|
||||
fn capture(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.recording_shortcut.clone() else { return };
|
||||
let pressed = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
// ⌘, ⇧, ⌥ and ⌃ on their own arrive as keys too: keep waiting for the key they modify
|
||||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !shortcuts::is_modifier(*key) => Some((*key, *modifiers)),
|
||||
// the windowing layer turns ⌘C / ⌘X / ⌘V (with or without other modifiers) into these
|
||||
egui::Event::Copy => Some((egui::Key::C, i.modifiers)),
|
||||
egui::Event::Cut => Some((egui::Key::X, i.modifiers)),
|
||||
egui::Event::Paste(_) => Some((egui::Key::V, i.modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let Some((key, modifiers)) = pressed else { return };
|
||||
// the key press is ours: nothing else (a focused button's Space, Tab navigation) acts on it
|
||||
ctx.input_mut(|i| {
|
||||
i.events
|
||||
.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)));
|
||||
});
|
||||
app.recording_shortcut = None;
|
||||
if key == egui::Key::Escape && modifiers.is_none() {
|
||||
return;
|
||||
}
|
||||
match shortcuts::format(modifiers, key) {
|
||||
Some(sc) => apply(app, ctx, serde_json::json!({"id": id, "shortcut": sc})),
|
||||
None => app.toast_for(ctx, crate::i18n::tr("That key can't be used as a shortcut"), 3.0),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The Masking panel: create masks, list them, edit components and local adjustments.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
|
||||
use serde_json::json;
|
||||
@@ -10,7 +10,7 @@ use super::right::header;
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{divider, icon_button, register, slider, text_button};
|
||||
use crate::widgets::{divider, icon_button, one_line, register, slider, text_button};
|
||||
|
||||
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
|
||||
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
|
||||
@@ -61,12 +61,29 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
|
||||
|
||||
const TILE: f32 = 52.0;
|
||||
const TILE_GAP: f32 = 6.0;
|
||||
/// Room left and right of a tile's label.
|
||||
const TILE_PAD: f32 = 3.0;
|
||||
|
||||
/// Columns and tile width of the Create New Mask grid in `width`: four tiles of up to 52 pt (at
|
||||
/// least 48, so the labels fit), else three (narrowed if even those don't fit).
|
||||
fn tile_layout(width: f32) -> (usize, f32) {
|
||||
let tile = |n: f32| ((width - (n - 1.0) * TILE_GAP) / n).floor().min(TILE);
|
||||
if tile(4.0) >= 48.0 { (4, tile(4.0)) } else { (3, tile(3.0).max(24.0)) }
|
||||
/// Columns, tile width and label size of the Create New Mask grid in `width`, `label(size)` being
|
||||
/// the widest label at a font size: four tiles of up to 52 pt (at least 48), else three, else two —
|
||||
/// the first that fits the labels at 10.5 pt, or at 9.5 pt before a column is dropped. A tile grows
|
||||
/// past 52 pt for a long (translated) label. In a panel too narrow even for that, two tiles whose
|
||||
/// labels are cut short.
|
||||
fn tile_layout(width: f32, label: impl Fn(f32) -> f32) -> (usize, f32, f32) {
|
||||
let room = |n: usize| ((width - (n as f32 - 1.0) * TILE_GAP) / n as f32).floor();
|
||||
for n in [4, 3, 2] {
|
||||
let w = room(n);
|
||||
if n == 4 && w < 48.0 {
|
||||
continue;
|
||||
}
|
||||
for size in [10.5, 9.5] {
|
||||
let need = (label(size) + 2.0 * TILE_PAD).ceil();
|
||||
if need <= w {
|
||||
return (n, w.min(TILE.max(need)), size);
|
||||
}
|
||||
}
|
||||
}
|
||||
(2, room(2).max(24.0), 9.5)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
@@ -88,15 +105,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
("luminanceRange", "Luminance", Icon::Sliders),
|
||||
("colorRange", "Color", Icon::Picker),
|
||||
];
|
||||
// four 52 pt tiles a row when they fit, else as many as fit (at least three, shrunk)
|
||||
let (cols, tile) = tile_layout(ui.available_width());
|
||||
// four 52 pt tiles a row when they and their labels fit, else fewer (wider for a long label)
|
||||
let widest = |size: f32| {
|
||||
let font = t.font(size);
|
||||
tiles
|
||||
.iter()
|
||||
.map(|(_, l, _)| ui.painter().layout_no_wrap(crate::i18n::tr(l).to_string(), font.clone(), t.text_dim).size().x)
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let (cols, tile, size) = tile_layout(ui.available_width(), widest);
|
||||
egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| {
|
||||
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());
|
||||
register(ui.ctx(), format!("maskNew:{kind}"), r);
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
|
||||
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
|
||||
ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim);
|
||||
// (cut short only in a panel too narrow for the label: the full name on hover)
|
||||
let label = crate::i18n::tr(label);
|
||||
let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD);
|
||||
let elided = g.elided;
|
||||
let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0;
|
||||
register(ui.ctx(), format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size()));
|
||||
ui.painter().galley(at, g, t.text_dim);
|
||||
let resp = if elided { resp.on_hover_text(label) } else { resp };
|
||||
if resp.clicked() {
|
||||
match *kind {
|
||||
"colorRange" => {
|
||||
@@ -136,7 +167,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
};
|
||||
if let Some(status) = status {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim));
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
// the model download, while its dialog is closed
|
||||
@@ -207,13 +238,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
);
|
||||
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
|
||||
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 32.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&m.name,
|
||||
t.font(13.0),
|
||||
if m.visible { t.text } else { t.text_disabled },
|
||||
);
|
||||
// a long name is cut short (with …) before the eye, the full name on hover
|
||||
let color = if m.visible { t.text } else { t.text_disabled };
|
||||
let g = one_line(ui.painter(), &m.name, t.font(13.0), color, r.width() - 32.0 - 32.0);
|
||||
let elided = g.elided;
|
||||
let at = pos2(r.left() + 32.0, r.center().y - g.size().y / 2.0);
|
||||
register(ui.ctx(), format!("maskName:{}", m.id), Rect::from_min_size(at, g.size()));
|
||||
ui.painter().galley(at, g, color);
|
||||
let resp = if elided { resp.on_hover_text(&m.name) } else { resp };
|
||||
// show / hide on hover (and always while hidden)
|
||||
// (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered)
|
||||
if ui.rect_contains_pointer(r) || !m.visible {
|
||||
@@ -706,11 +738,15 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
};
|
||||
ui.label(crate::i18n::tr(prompt));
|
||||
let mut submit = false;
|
||||
ui.horizontal(|ui| {
|
||||
// the (translated) Select button is placed first, at the right; the field takes what is left
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut text)
|
||||
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
|
||||
.desired_width(ui.available_width() - 64.0),
|
||||
.desired_width(ui.available_width()),
|
||||
);
|
||||
register(ui.ctx(), "maskDescribe", r.rect);
|
||||
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
|
||||
@@ -719,9 +755,6 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
submit = true;
|
||||
}
|
||||
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.describe = None;
|
||||
@@ -813,3 +846,32 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
|
||||
/// room by dropping a column rather than drawing it past its tile.
|
||||
#[test]
|
||||
fn tile_labels_get_the_room_they_need() {
|
||||
let short = |size: f32| size * 4.0; // "Luminance"-ish: 42 pt at 10.5
|
||||
let long = |size: f32| size * 5.6; // "Background"/"Hintergrund": 59 pt at 10.5, 53 at 9.5
|
||||
assert_eq!(tile_layout(224.0, short), (4, 51.0, 10.5));
|
||||
// too wide for four at either size: three tiles, wider than 52 so the label fits
|
||||
let (n, w, size) = tile_layout(224.0, long);
|
||||
assert_eq!((n, size), (3, 10.5));
|
||||
assert!(w >= long(10.5) + 2.0 * TILE_PAD && w <= (224.0 - 2.0 * TILE_GAP) / 3.0, "{w}");
|
||||
// a smaller label keeps a column: 9.5 pt fits where 10.5 doesn't
|
||||
let mid = |size: f32| size * 4.6; // 48.3 at 10.5, 43.7 at 9.5
|
||||
assert_eq!(tile_layout(224.0, mid), (4, 51.0, 9.5));
|
||||
// a wide panel: tiles stay 52 pt (or as wide as the label)
|
||||
assert_eq!(tile_layout(474.0, short).0, 4);
|
||||
assert_eq!(tile_layout(474.0, short).1, TILE);
|
||||
// far too narrow for any label: two tiles (labels cut short), never a negative width
|
||||
let (n, w, _) = tile_layout(60.0, long);
|
||||
assert_eq!(n, 2);
|
||||
assert!(w >= 24.0);
|
||||
assert_eq!(tile_layout(f32::NAN, long).0, 2);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod dialogs;
|
||||
pub mod edit;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod keymap;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
|
||||
@@ -543,6 +543,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
|
||||
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
|
||||
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
|
||||
}
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => x.to_string(),
|
||||
None => match app.services.pick_folder.as_mut() {
|
||||
@@ -555,6 +558,7 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
|
||||
};
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
app.lightroom_last = None;
|
||||
app.renderer.forget_all();
|
||||
app.ui.compare = None;
|
||||
app.ui.settings.library_path = path.clone();
|
||||
|
||||
+291
-39
@@ -1,10 +1,195 @@
|
||||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands.
|
||||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings, apply the user's keymap (Help ▸
|
||||
//! Keyboard Shortcuts, `app.setShortcut`) and dispatch UI and engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
|
||||
/// The user's changes to the keymap: command id → shortcut (`""` = no shortcut). Saved with the
|
||||
/// app settings (`ui.json`); commands not listed keep their declared shortcut.
|
||||
pub type Keymap = BTreeMap<String, String>;
|
||||
|
||||
/// Shortcuts the app menu owns (Settings…, Quit), which no command can take.
|
||||
pub const RESERVED: &[&str] = &["Cmd+,", "Cmd+Q"];
|
||||
|
||||
/// A command that can have a shortcut.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Bindable {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// The declared shortcut. An engine command whose key a UI command wraps (e.g. `W` opens the
|
||||
/// White Balance Selector rather than sampling without a point) has none: the UI one owns it.
|
||||
pub default: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Every command that can have a shortcut: UI commands, then engine commands (one entry per id).
|
||||
pub fn bindable() -> &'static [Bindable] {
|
||||
static ALL: OnceLock<Vec<Bindable>> = OnceLock::new();
|
||||
ALL.get_or_init(|| {
|
||||
let mut v: Vec<Bindable> = Vec::new();
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if !v.iter().any(|b| b.id == *id) {
|
||||
v.push(Bindable { id, label, default: *sc });
|
||||
}
|
||||
}
|
||||
let ui_keys: Vec<(Modifiers, Key)> = v.iter().filter_map(|b| b.default.and_then(parse)).collect();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if v.iter().any(|b| b.id == c.id) {
|
||||
continue;
|
||||
}
|
||||
let default = c.shortcut.filter(|sc| parse(sc).is_some_and(|k| !ui_keys.contains(&k)));
|
||||
v.push(Bindable { id: c.id, label: c.label, default });
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_bindable(id: &str) -> Option<&'static Bindable> {
|
||||
bindable().iter().find(|b| b.id == id)
|
||||
}
|
||||
|
||||
/// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry
|
||||
/// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is
|
||||
/// ignored.
|
||||
pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> {
|
||||
match keymap.get(id) {
|
||||
Some(sc) if sc.is_empty() => None,
|
||||
Some(sc) if parse(sc).is_some() => Some(sc.as_str()),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective shortcut of command `id` (`None` for unknown commands).
|
||||
pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> {
|
||||
find_bindable(id).and_then(|b| binding(keymap, id, b.default))
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
|
||||
/// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted).
|
||||
pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> {
|
||||
let Some(key) = parse(sc) else { return vec![] };
|
||||
bindable()
|
||||
.iter()
|
||||
.filter(|b| b.id != id && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == b.id) || (*y == id && *x == b.id)))
|
||||
.filter(|b| binding(keymap, b.id, b.default).and_then(parse) == Some(key))
|
||||
.map(|b| b.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Give `id` the shortcut `sc` (`None` = no shortcut), taking it away from commands that had the
|
||||
/// same key. Returns the commands that lost it.
|
||||
pub fn assign(keymap: &mut Keymap, id: &str, sc: Option<&str>) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
let sc = match sc.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let key = parse(s).ok_or_else(|| format!("not a shortcut: {s}"))?;
|
||||
// store the canonical spelling so equal keys compare equal (menus, native accelerators)
|
||||
let canonical = format(key.0, key.1).unwrap_or_else(|| s.to_string());
|
||||
if RESERVED.iter().any(|r| parse(r) == Some(key)) {
|
||||
return Err(format!("{canonical} is reserved for the app menu"));
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
};
|
||||
let lost = sc.as_deref().map(|s| conflicts(keymap, id, s)).unwrap_or_default();
|
||||
for other in &lost {
|
||||
set(keymap, other, None);
|
||||
}
|
||||
set(keymap, b.id, sc.as_deref());
|
||||
Ok(lost)
|
||||
}
|
||||
|
||||
/// Store `sc` for `id`, dropping the entry when it equals the declared shortcut.
|
||||
fn set(keymap: &mut Keymap, id: &str, sc: Option<&str>) {
|
||||
let default = find_bindable(id).and_then(|b| b.default);
|
||||
let same = match (sc, default) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => parse(a) == parse(b),
|
||||
_ => false,
|
||||
};
|
||||
if same {
|
||||
keymap.remove(id);
|
||||
} else {
|
||||
keymap.insert(id.to_string(), sc.unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the declared shortcut of `id`, taking it away from a command the user gave it to.
|
||||
pub fn reset(keymap: &mut Keymap, id: &str) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
assign(keymap, id, b.default)
|
||||
}
|
||||
|
||||
/// `app.setShortcut {id, shortcut?, reset?}`: `shortcut` null or `""` removes it.
|
||||
pub fn set_shortcut(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or("missing id")?;
|
||||
let keymap = &mut app.ui.settings.keymap;
|
||||
let lost = if p.get("reset").and_then(Value::as_bool).unwrap_or(false) {
|
||||
reset(keymap, id)?
|
||||
} else {
|
||||
match p.get("shortcut") {
|
||||
None => return Err("missing shortcut (null removes it)".into()),
|
||||
Some(Value::Null) => assign(keymap, id, None)?,
|
||||
Some(Value::String(s)) => assign(keymap, id, Some(s))?,
|
||||
Some(_) => return Err("shortcut must be a string or null".into()),
|
||||
}
|
||||
};
|
||||
Ok(json!({"id": id, "shortcut": shortcut_of(&app.ui.settings.keymap, id), "removedFrom": lost}))
|
||||
}
|
||||
|
||||
/// ⌘ / ⇧ / ⌥ / ⌃ themselves: they modify a shortcut's key, they can't be it.
|
||||
pub fn is_modifier(k: Key) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight
|
||||
)
|
||||
}
|
||||
|
||||
/// The shortcut text for a key press (`Cmd+Shift+K`), or `None` for keys a shortcut can't name.
|
||||
/// `Cmd` is ⌘ on macOS and Ctrl elsewhere; `Ctrl` is the macOS Control key.
|
||||
pub fn format(m: Modifiers, k: Key) -> Option<String> {
|
||||
if is_modifier(k) {
|
||||
return None;
|
||||
}
|
||||
let key = match k {
|
||||
Key::Backspace | Key::Delete => "Delete",
|
||||
Key::Slash => "/",
|
||||
Key::Backslash => "\\",
|
||||
Key::Equals => "=",
|
||||
Key::Minus => "-",
|
||||
Key::OpenBracket => "[",
|
||||
Key::CloseBracket => "]",
|
||||
Key::Quote => "'",
|
||||
Key::Comma => ",",
|
||||
k => k.name(),
|
||||
};
|
||||
let mut s = String::new();
|
||||
if m.command || m.mac_cmd {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
// off macOS Ctrl *is* Cmd; on macOS it is the separate Control key
|
||||
if m.ctrl && (m.mac_cmd || !m.command) {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
s.push_str(key);
|
||||
let back = parse(&s)?;
|
||||
let want = if k == Key::Delete { Key::Backspace } else { k };
|
||||
(back.1 == want).then_some(s)
|
||||
}
|
||||
|
||||
/// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`.
|
||||
/// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our
|
||||
/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts.
|
||||
@@ -48,21 +233,26 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
||||
// "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`.
|
||||
"Delete" => key = Some(Key::Backspace),
|
||||
k => {
|
||||
key = Key::from_name(k).or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
// an unknown part (`Hyper`, a typo) makes the whole shortcut invalid
|
||||
key = Some(
|
||||
Key::from_name(k)
|
||||
.or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|k| !is_modifier(*k))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,36 +282,33 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// don't steal keys from text fields
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
// don't steal keys from text fields or from the keymap editor recording a shortcut
|
||||
if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut fire: Vec<String> = Vec::new();
|
||||
// shortcuts the native menu bar handles (it consumes those key presses itself)
|
||||
let native = |sc: &str| app.native_shortcuts.contains(sc);
|
||||
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
// keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them
|
||||
let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect();
|
||||
ctx.input(|i| {
|
||||
let mut ui_keys = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some((m, k)) = sc.and_then(parse) {
|
||||
ui_keys.push((m, k));
|
||||
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
|
||||
fire.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
// A UI command bound to the same key wraps the engine command (e.g. `W` opens the
|
||||
// White Balance Selector tool rather than sampling without a point): the UI one wins.
|
||||
if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse)
|
||||
&& !ui_keys.contains(&(m, k))
|
||||
for b in bindable() {
|
||||
if let Some(sc) = binding(keymap, b.id, b.default)
|
||||
&& let Some((m, k)) = parse(sc)
|
||||
&& !native(sc)
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
fire.push(c.id.to_string());
|
||||
fire.push(b.id.to_string());
|
||||
}
|
||||
}
|
||||
for (sc, id, params) in ALIASES {
|
||||
if let Some((m, k)) = parse(sc).filter(|_| !native(sc))
|
||||
&& !taken.contains(&(m, k))
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
|
||||
@@ -129,15 +316,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
// rating 0-5, colour labels 6-9 (with Shift: and advance)
|
||||
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) {
|
||||
fire.push(format!("rate:{n}:0"));
|
||||
}
|
||||
if matches(i, Modifiers::SHIFT, *key) {
|
||||
if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) {
|
||||
fire.push(format!("rate:{n}:1"));
|
||||
}
|
||||
}
|
||||
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) {
|
||||
fire.push(format!("label:{label}"));
|
||||
}
|
||||
}
|
||||
@@ -554,8 +741,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
#[test]
|
||||
fn key_presses_format_to_shortcuts_that_parse_back() {
|
||||
for k in Key::ALL {
|
||||
for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] {
|
||||
if let Some(sc) = format(m, *k) {
|
||||
let want = if *k == Key::Delete { Key::Backspace } else { *k };
|
||||
assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_a_key_moves_it_and_reset_restores_it() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
// D belongs to Detail: Survey takes it, Detail loses it
|
||||
let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap();
|
||||
assert_eq!(lost, vec!["view.detail"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), None);
|
||||
// restoring Detail takes D back
|
||||
let lost = reset(&mut keymap, "view.detail").unwrap();
|
||||
assert_eq!(lost, vec!["view.survey"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
reset(&mut keymap, "view.survey").unwrap();
|
||||
assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}");
|
||||
// a command without a declared shortcut can get one, and lose it again
|
||||
assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err();
|
||||
assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap();
|
||||
assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1"));
|
||||
assign(&mut keymap, "view.photoGrid", None).unwrap();
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_assignments_are_errors() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err());
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
/// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic.
|
||||
#[test]
|
||||
fn junk_keymap_entries_mean_no_shortcut() {
|
||||
let mut keymap = Keymap::new();
|
||||
keymap.insert("view.survey".into(), "Hyper+☃".into());
|
||||
keymap.insert("no.suchCommand".into(), "K".into());
|
||||
keymap.insert("view.detail".into(), "Cmd+SuperLeft".into());
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey"));
|
||||
assert_eq!(parse("Hyper+K"), None);
|
||||
}
|
||||
|
||||
/// Contextual partners keep sharing their key when one is reassigned.
|
||||
#[test]
|
||||
fn contextual_partners_are_not_conflicts() {
|
||||
let keymap = Keymap::new();
|
||||
assert!(conflicts(&keymap, "photo.reject", "X").is_empty());
|
||||
}
|
||||
|
||||
/// No key fires two different actions (a UI command may shadow the engine command it wraps).
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,8 @@ pub struct AppSettings {
|
||||
pub film_badges: bool,
|
||||
/// Grid: when to show the rating / flag / edited badges.
|
||||
pub grid_badges: GridBadges,
|
||||
/// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none.
|
||||
pub keymap: crate::shortcuts::Keymap,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -167,6 +169,7 @@ impl Default for AppSettings {
|
||||
film_names: true,
|
||||
film_badges: true,
|
||||
grid_badges: GridBadges::Auto,
|
||||
keymap: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires
|
||||
//! its command and not the old one's, the editor records a key press without running it, Esc
|
||||
//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::{Dialog, ViewMode};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Headless, k: &str, shift: bool) {
|
||||
let r = h.request("ui.key", json!({"key": k, "shift": shift}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_shortcut_fires_and_the_old_key_does_not() {
|
||||
let mut h = demo();
|
||||
let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap();
|
||||
assert_eq!(r["shortcut"], "Shift+K", "{r}");
|
||||
// the old key (N) no longer opens Survey…
|
||||
key(&mut h, "N", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
// …the new one does, and menus show it
|
||||
key(&mut h, "K", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap();
|
||||
assert_eq!(entry.shortcut.as_deref(), Some("Shift+K"));
|
||||
// the keymap is saved with the UI state
|
||||
let saved = serde_json::to_string(&h.app.ui).unwrap();
|
||||
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap();
|
||||
assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_records_a_key_and_esc_cancels_without_closing() {
|
||||
let mut h = demo();
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
// the list is longer than the dialog: narrow it so the row is on screen
|
||||
click(&mut h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
// record Shift+J for Compare: the key press is taken, not run
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare"));
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
// Esc while recording cancels; the dialog stays open and nothing changes
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
key(&mut h, "Escape", false);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
// the next Esc closes it; the new key works
|
||||
key(&mut h, "Escape", false);
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Compare);
|
||||
// Reset All restores the declared keys
|
||||
h.app.run("app.resetShortcuts", json!({})).unwrap();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_given_to_a_command_overrides_a_fixed_key() {
|
||||
let mut h = demo();
|
||||
let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
// 3 (rate ★★★) now opens Survey instead
|
||||
h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap();
|
||||
key(&mut h, "3", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
assert_eq!(before, after, "the rating key must not fire too");
|
||||
}
|
||||
|
||||
/// Open the editor, filtered to `search`, and start recording for `id`.
|
||||
fn record(h: &mut Headless, search: &str, id: &str) {
|
||||
if h.app.ui.dialog.is_none() {
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
click(h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
click(h, &format!("button:shortcut-{id}"));
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some(id));
|
||||
}
|
||||
|
||||
/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut
|
||||
/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored.
|
||||
#[test]
|
||||
fn modifiers_wait_for_their_key_and_clipboard_keys_record() {
|
||||
let mut h = demo();
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone");
|
||||
let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K"));
|
||||
// ⌘⌥C: the windowing layer sends a Copy event with the modifiers held
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND };
|
||||
h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.app.synthetic.push(egui::Event::Copy);
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C"));
|
||||
// a saved modifier-only shortcut (from the bug) means no shortcut
|
||||
assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Check painted text as well as widget rectangles: custom painters can overflow a valid widget.
|
||||
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale};
|
||||
|
||||
const LONG_NAME: &str = "The person standing beside the very long fence across the background";
|
||||
|
||||
fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::Shape::Text(text) => out.push(text.clone()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel(width: f32, scale: f32, locale: Locale, describe: bool) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.run("mask.add", json!({"kind": "subject", "name": LONG_NAME})).unwrap();
|
||||
app.ui.describe = describe.then(|| ("new".into(), LONG_NAME.repeat(4)));
|
||||
app.ui.detail_due = Some((100.0, 1));
|
||||
let photo = app.session.active().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::i18n::set_language(locale);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx);
|
||||
let mut texts = Vec::new();
|
||||
let mut widgets = Vec::new();
|
||||
for frame in 0..3 {
|
||||
let raw = HeadlessView::raw_input(vec2(width, 1600.0), scale, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
crate::panels::masking::show(&mut app, ui, photo);
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
texts.clear();
|
||||
for shape in &out.shapes {
|
||||
text_shapes(&shape.shape, &mut texts);
|
||||
}
|
||||
widgets = crate::widgets::take_registry(&ctx);
|
||||
}
|
||||
if describe {
|
||||
assert_eq!(app.ui.describe.as_ref().unwrap().1, LONG_NAME.repeat(4), "layout must not shorten the actual prompt");
|
||||
}
|
||||
crate::i18n::set_language(Locale::En);
|
||||
(texts, widgets)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_tile_labels_fit_without_eliding_at_supported_panel_widths() {
|
||||
for locale in [Locale::En, Locale::De, Locale::PtBr] {
|
||||
for width in [250.0, 270.0, 330.0, 520.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (texts, widgets) = panel(width, scale, locale, false);
|
||||
for (kind, label) in [("background", "Background"), ("luminanceRange", "Luminance"), ("prompt", "Describe")] {
|
||||
crate::i18n::set_language(locale);
|
||||
let label = crate::i18n::tr(label).to_string();
|
||||
crate::i18n::set_language(Locale::En);
|
||||
let tile = widgets.iter().find(|(id, _)| id == &format!("maskNew:{kind}")).unwrap().1;
|
||||
let text = texts.iter().find(|t| t.galley.job.text == label && t.pos.y < tile.bottom()).unwrap();
|
||||
assert!(!text.galley.elided, "{locale:?} {width} {label} should be readable in full");
|
||||
assert!(
|
||||
tile.shrink2(vec2(2.0, 0.0)).contains_rect(text.visual_bounding_rect()),
|
||||
"{locale:?} {width} {scale}: {label} exceeds {tile:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_long_names_and_describe_controls_stay_inside_the_panel() {
|
||||
for locale in [Locale::En, Locale::De, Locale::PtBr] {
|
||||
for width in [250.0, 270.0, 520.0] {
|
||||
let (texts, widgets) = panel(width, 2.0, locale, true);
|
||||
let row = widgets.iter().find(|(id, _)| id == "mask:1").unwrap().1;
|
||||
let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap();
|
||||
assert!(name.galley.elided, "long mask names should be elided");
|
||||
assert!(
|
||||
Rect::from_min_max(pos2(row.left() + 32.0, row.top()), pos2(row.right() - 28.0, row.bottom()))
|
||||
.contains_rect(name.visual_bounding_rect())
|
||||
);
|
||||
let field = widgets.iter().find(|(id, _)| id == "maskDescribe").unwrap().1;
|
||||
let button = widgets.iter().find(|(id, _)| id == "button:maskDescribeGo").unwrap().1;
|
||||
assert!(field.right() <= button.left(), "the prompt must not overlap Select");
|
||||
assert!(field.left() >= 24.0 && button.right() <= width - 22.0, "{locale:?} {width}: {field:?} {button:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,27 @@ fn masking_contents_fit_the_right_panel_at_any_width() {
|
||||
T,
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let long = "The person standing beside the very long fence across the background";
|
||||
let r = h.request("engine.execute", json!({"command": "mask.rename", "params": {"id": 1, "name": long}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for width in [RIGHT_WIDTH.min, RIGHT_WIDTH.default, 330.0, RIGHT_WIDTH.max] {
|
||||
// a long Describe prompt being typed: the field and its Select button stay in the panel
|
||||
h.app.ui.describe = Some(("new".into(), long.repeat(2)));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_inside_right_panel(&h, &format!("width {width}, describing"));
|
||||
let (field, go) = (widget(&h, "maskDescribe"), widget(&h, "button:maskDescribeGo"));
|
||||
assert!(field.right() <= go.left() && go.right() <= widget(&h, "panel:right_panel").right(), "width {width}: {field:?} {go:?}");
|
||||
h.app.ui.describe = None;
|
||||
// the painted text, not just the widgets: tile labels inside their tiles, a long mask
|
||||
// name cut short before the eye
|
||||
h.step();
|
||||
for (id, r) in h.app.widgets.iter().filter(|(id, _)| id.starts_with("maskNewLabel:")) {
|
||||
let tile = widget(&h, &id.replace("maskNewLabel:", "maskNew:"));
|
||||
assert!(tile.contains_rect(*r), "width {width}: {id} {r:?} outside its tile {tile:?}");
|
||||
}
|
||||
let (name, row) = (widget(&h, "maskName:1"), widget(&h, "mask:1"));
|
||||
assert!(name.right() <= row.right() - 27.0, "width {width}: the name {name:?} runs under the eye of {row:?}");
|
||||
for (selected, tool) in [(false, ""), (true, ""), (true, "brush")] {
|
||||
let mask = if selected { json!(2) } else { serde_json::Value::Null };
|
||||
h.request("engine.execute", json!({"command": "mask.select", "params": {"id": mask}}), T);
|
||||
|
||||
@@ -593,6 +593,15 @@ mod access_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's
|
||||
/// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its
|
||||
/// buttons), which a plain `Painter::text` would draw past.
|
||||
pub(crate) fn one_line(painter: &egui::Painter, text: &str, font: egui::FontId, color: Color32, max: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(max.max(1.0));
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
/// `text` shortened to fit `max` (as `width` measures it): leading folders drop first
|
||||
/// (`Users/me/Pictures/Lightroom` → `…/Pictures/Lightroom`) so the end of a path, which says the
|
||||
/// most, stays; a single name still too long loses its end (`2024-06-12 Tri…`: folders tend to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF starting look
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look
|
||||
|
||||
ARW, NEF, RW2 and RAF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
@@ -62,6 +62,14 @@ RAF inputs also work with `lightcraft-cli calibrate`. Tests cover real X-Trans/1
|
||||
|
||||
These are camera-JPEG look estimates, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon, Panasonic and Fujifilm model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
|
||||
|
||||
### Canon CR3
|
||||
|
||||
CR3 uses the same colour gates and relative WB. Its sensor and JPEG proxies are first oriented identically. Canon can crop its JPEG differently from the sensor; no lens distortion correction is applied to the sensor proxy, so a strongly distortion-corrected JPEG can fail the gates. A fixed 96-pixel proxy measures this remaining framing from luminance-gradient directions: scale is limited to 0.9–1.1 and translation to two proxy pixels. At least 192 edges are required; two thirds train the framing and the remaining third must independently reach direction agreement ≥0.90 with an improvement ≥0.04. At least 80% of each edge set must remain usable. Weak, flat, unrelated or already aligned references keep the sensor framing. This alignment affects training correspondences only; the RAW render still uses the actual sensor pixels.
|
||||
|
||||
On the public CC0 EOS R100 / RF-S 18–45 mm sample, the measured framing scale was 0.968. Held-out edge agreement rose from 0.7291 to 0.9194, and the unchanged colour gates accepted the fit (held-out RMS 0.10625 → 0.07704). The corpus test asserts acceptance after decoding the sensor RAW, and synthetic tests cover nonlinear camera tone, known crop/translation and unrelated references. The matrix, tone and hue/saturation table are estimated from the file; no third-party camera matrix or decoder calibration source is copied.
|
||||
|
||||
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon and Canon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
|
||||
|
||||
## Camera profiles (pooled per model)
|
||||
|
||||
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# Canon CR3 decoding
|
||||
|
||||
The CR3 adapter reads the CRX Bayer track, `CMP1` compression descriptor,
|
||||
`CDI1`/`IAD1` sensor geometry and timed `CTMD` maker notes. White balance,
|
||||
black/white levels and the default crop come from the file. A Dual Pixel delta
|
||||
track is never substituted for the primary Bayer track.
|
||||
|
||||
The decoder is independent Rust code shared by desktop, CLI and WebAssembly;
|
||||
it has no operating-system imaging dependency or external decoder. Its sources
|
||||
are the public prose descriptions and patents cited in
|
||||
`crates/raw/src/vendor/crx.rs`. Unsupported coding variants retain the existing
|
||||
embedded-JPEG fallback and its visible warning when a JPEG is present. So does
|
||||
any other CR3 decode error (a corrupt file, or a body whose CRX stream the
|
||||
decoder misreads): every CR3 opened from its embedded JPEG before this decoder,
|
||||
and only three bodies are verified, so a CR3 never fails to import or load
|
||||
where a preview exists. HEVC-only preview tracks are recognized but are not
|
||||
decoded.
|
||||
|
||||
## Verified sensor decoding
|
||||
|
||||
CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
|
||||
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
|
||||
quantization values 4–43 are independently verified. Version `0x200` C-RAW
|
||||
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
|
||||
its adaptive quantization map. Other unverified coding variants return explicit
|
||||
unsupported errors. Header probing validates the same coding variants and
|
||||
tile/plane layout as full decoding.
|
||||
|
||||
`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a
|
||||
black-box reference, before cropping or colour processing. Public CC0 files
|
||||
are downloaded by `cargo xtask corpus --download` and kept outside version
|
||||
control. The exact inputs and reference hashes are:
|
||||
|
||||
| File | Input SHA-256 | Sensor FNV-1a (u16 little-endian) |
|
||||
|---|---|---|
|
||||
| Canon M50 RAW, raw.pixls.us 4657 | `1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058` | `62261f0ba81cfcd2` |
|
||||
| Canon M50 C-RAW, raw.pixls.us 2663 | `15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c` | `9aacbfe66f505e1b` |
|
||||
| Canon R100 RAW, raw.pixls.us 7896 | `6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521` | `cbe5299ab9c52630` |
|
||||
| Canon R100 C-RAW, raw.pixls.us 7897 | `0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942` | `341c706b37c38bbf` |
|
||||
| Canon R8 RAW, raw.pixls.us 6585 | `7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c` | `63c1c6d8d1eb2312` |
|
||||
| Canon R8 C-RAW, raw.pixls.us 6587 | `df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812` | `f836a663a795a375` |
|
||||
|
||||
Reference pixels were observed from the DNGLab 0.8.0 macOS arm64 release
|
||||
binary; no decoder source was read or copied, and it is not shipped or linked.
|
||||
The release ZIP SHA-256 was
|
||||
`ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e`.
|
||||
All six sensor comparisons pass. R8 HEVC preview tracks are distinguished from
|
||||
the primary Bayer RAW; the HEVC preview itself is not decoded. Missing corpus
|
||||
files are reported as skipped by the tests.
|
||||
|
||||
The version `0x200` QP predictor and entropy framing were checked independently.
|
||||
Controlled input mutations establish floor averaging of paired QP rows before
|
||||
the nonlinear integer table, then multiplication, division by eight, addition
|
||||
of the subband base and a minimum step of one. QP values 131–167 are verified;
|
||||
unmeasured values and an odd QP-map height are rejected. Partial-subband and
|
||||
rounded-plane coding, vertical C-RAW tiling and adaptive low-frequency gains
|
||||
remain unsupported. The decoder bounds image dimensions, sample count,
|
||||
tile count, marker headers and temporary allocations. The full sensor buffer is
|
||||
allocated only after the first plane decodes, and plane buffers grow row by row,
|
||||
so a small crafted file fails before reserving memory for the image it claims.
|
||||
|
||||
## Colour limits
|
||||
|
||||
CR3 carries as-shot white balance but no measured camera matrix is imported
|
||||
here. The existing guarded embedded-JPEG fitting path now includes CR3, with
|
||||
both proxies oriented identically before gathering training pairs. A bounded
|
||||
crop/translation registration of the JPEG is confirmed on held-out edge
|
||||
directions before fitting colour; the existing held-out colour acceptance gates
|
||||
are unchanged. No lens distortion correction is applied (LightCraft has no lens
|
||||
profiles of its own yet), so lenses whose camera JPEG is strongly
|
||||
distortion-corrected may fail the gates and keep the neutral matrix. Render
|
||||
cache version 13 invalidates earlier thumbnails. All developed pixels still come
|
||||
from the RAW sensor; a rejected colour fit retains the documented neutral
|
||||
matrix fallback. See `camera-preview-colour.md` for the limits of this estimate.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Lightroom Classic catalog import
|
||||
|
||||
File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python, DNG conversion
|
||||
and a C SQLite runtime are not required. The source database is read-only, including committed
|
||||
`-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits.
|
||||
|
||||
Equivalent commands:
|
||||
|
||||
```text
|
||||
lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path="Lightroom Catalog.lrcat"
|
||||
lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat"
|
||||
```
|
||||
|
||||
Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata,
|
||||
hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop
|
||||
settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft
|
||||
edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import
|
||||
identity prevents reimport from duplicating virtual copies and collections. One undo step reverses
|
||||
catalog changes; any saved recovery archives remain available.
|
||||
|
||||
Before changing records, the importer attempts to save source image records, decoded XMP,
|
||||
verbatim develop settings, history, snapshots and collection content to compressed
|
||||
`Interop/lightroom-import-N.lca` recovery archives in the LightCraft library. Archiving is
|
||||
best-effort: a size limit or write failure produces a warning and does not prevent photo import.
|
||||
Unchanged source data reuses its existing archive. Opaque SQL BLOBs are skipped. Each archive is
|
||||
capped at 32 MiB (uncompressed and on disk), with at most eight archives and 128 MiB total
|
||||
retained. Oldest managed archives are retired; legacy `.json` archives remain untouched.
|
||||
`Interop/lightroom-index.json` records imported identities. Source catalogs and originals are
|
||||
never overwritten.
|
||||
|
||||
Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per
|
||||
table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format
|
||||
specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB.
|
||||
Optional history, keyword and collection tables that exceed limits are skipped with warnings;
|
||||
required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed
|
||||
WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional.
|
||||
|
||||
Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported
|
||||
mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI
|
||||
models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped
|
||||
fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots
|
||||
and original smart-collection rules. Smart collections become regular albums with current
|
||||
membership. Archived history and snapshots are source data, not native LightCraft history yet.
|
||||
Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings;
|
||||
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
|
||||
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
|
||||
|
||||
Native desktop inspection/import runs on a cancellable background worker with progress. Only
|
||||
prepared catalog operations commit on the owner thread; archive index writing stays on a worker.
|
||||
Late results from a closed or switched library are rejected, including reopening the same path.
|
||||
Ordinary edits during preparation remain intact. CLI commands complete synchronously using the
|
||||
same preparation/commit pipeline; browser builds reject native catalog import.
|
||||
|
||||
Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib-compressed JSON.
|
||||
Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths,
|
||||
settings, decoded XMP, keywords, history, snapshots, collection definitions and membership.
|
||||
|
||||
The importer uses shared Rust code on macOS, Windows and Linux. If originals move between
|
||||
computers or volumes, use LightCraft's missing-photo relinking tools to update their paths.
|
||||
@@ -0,0 +1,47 @@
|
||||
# LightCraft en español
|
||||
|
||||
Elegir **Editar → Idioma → Español** o **Ajustes → General → Idioma**. El cambio se aplica al
|
||||
momento y se conserva para el próximo inicio (`language: "es"` en `ui.json`). `LIGHTCRAFT_LANGUAGE`
|
||||
acepta cualquier variante del español (`es`, `es-ES`, `es_MX.UTF-8`, `es-419`…).
|
||||
|
||||
El catálogo en español cubre los menús, los métodos abreviados de teclado, la biblioteca, el
|
||||
revelado, el recorte, las máscaras, la importación y la exportación, los ajustes, los ajustes
|
||||
preestablecidos y perfiles integrados, el historial, las fechas y los mensajes de progreso. Como en
|
||||
los demás idiomas, los errores técnicos de los niveles inferiores siguen en inglés, y los textos
|
||||
que aún no están en el catálogo se muestran en inglés.
|
||||
|
||||
Los nombres de archivo, los identificadores de comandos, las plantillas y los álbumes, ajustes
|
||||
preestablecidos y metadatos con nombres propios no se traducen. La fuente de la interfaz, Inter,
|
||||
incluye todos los caracteres del español; no hacen falta fuentes adicionales.
|
||||
|
||||
## Terminología
|
||||
|
||||
Se usan infinitivos o formas impersonales en las instrucciones («Hacer clic…», «Elegir…»), sin
|
||||
tratamiento de usted. Algunos términos que conviene mantener coherentes:
|
||||
|
||||
| Inglés | Español |
|
||||
|---|---|
|
||||
| Highlights / Shadows | Iluminaciones / Sombras |
|
||||
| Dehaze | Eliminar neblina |
|
||||
| Vibrance | Intensidad |
|
||||
| Tint | Matiz |
|
||||
| Hue (HSL) | Tono |
|
||||
| Color Grading | Gradación de color |
|
||||
| Preset | Ajuste preestablecido |
|
||||
| Pick / Reject | Seleccionada / Rechazada |
|
||||
| Upright | Vertical |
|
||||
| Lens Corrections | Correcciones de lente |
|
||||
|
||||
Las correcciones de hablantes nativos son bienvenidas: abrir un issue o un pull request con el
|
||||
texto inglés, la traducción actual y la propuesta.
|
||||
|
||||
## Mantenimiento y comprobación
|
||||
|
||||
Textos fijos: `crates/ui-egui/locales/es.json`. Mensajes con valores:
|
||||
`crates/ui-egui/locales/es-formats.json`. Las claves en inglés no cambian; el compilador de Rust
|
||||
comprueba los marcadores al compilar. Las terminaciones de plural del inglés se ocultan con `{:.0}`.
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
|
||||
LIGHTCRAFT_LANGUAGE=es lightcraft-cli snapshot --demo -o espanol.png --size 1600x1000
|
||||
```
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
The interface ships in the language it is written in (English) plus every language in the table in
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
|
||||
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md),
|
||||
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
|
||||
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
+17
-17
@@ -26,8 +26,8 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
|
||||
@@ -42,18 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
| Lightroom Classic extras | 32 | 10 | 39 | 9 | — | 22/23 (96%) |
|
||||
| **Total** | 390 | 34 | 86 | 37 | 194/200 (97%) | 140/150 (93%) |
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.7% of 150 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -66,6 +63,8 @@ Take the first one nobody is working on.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed ORF, Nikon
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): CR3 lossless Bayer and version 0x100/0x200 C-RAW now decodes (M50/R100/R8 full sensor exact). Remaining unverified CRX variants, compressed RAF / ORF, Nikon
|
||||
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
|
||||
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
|
||||
3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2
|
||||
@@ -125,7 +124,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import |
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) |
|
||||
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
|
||||
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
|
||||
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) |
|
||||
@@ -136,8 +135,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
|
||||
@@ -288,7 +287,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF and Canon CR3 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
@@ -482,7 +481,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
|
||||
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | |
|
||||
| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | |
|
||||
| LR-PREF-DEVICE | Device settings | P2 | ⬜ | | |
|
||||
@@ -518,8 +517,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
@@ -729,6 +728,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here |
|
||||
|
||||
+17
-2
@@ -9,17 +9,30 @@
|
||||
- `Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.
|
||||
- Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).
|
||||
|
||||
### Keyboard shortcuts
|
||||
- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and
|
||||
press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that
|
||||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off
|
||||
are unchanged; photos imported before this change get it when imported again.
|
||||
- Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS
|
||||
M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.
|
||||
- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,
|
||||
preserving RAW editing & full-resolution export instead of using embedded JPEG previews.
|
||||
- Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the
|
||||
starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.
|
||||
|
||||
### Lightroom Classic catalogs
|
||||
- File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.
|
||||
Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;
|
||||
existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported
|
||||
fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.
|
||||
|
||||
### Presets and profiles
|
||||
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
|
||||
bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.
|
||||
@@ -45,10 +58,12 @@
|
||||
(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.
|
||||
- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks
|
||||
or unchecks a range of photos (issue #338).
|
||||
- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):
|
||||
import only the raws and leave the JPEGs beside them (issue #344).
|
||||
|
||||
### Languages
|
||||
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
|
||||
Traditional), Japanese and Brazilian Portuguese.
|
||||
- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,
|
||||
Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.
|
||||
|
||||
### Editing
|
||||
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)
|
||||
|
||||
@@ -324,6 +324,11 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
|
||||
("cr2-canon-5d3.cr2", "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2"),
|
||||
("cr2-canon-80d.cr2", "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2"),
|
||||
("cr3-canon-m50-craw.cr3", "https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3"),
|
||||
("cr3-canon-m50-raw.cr3", "https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3"),
|
||||
("cr3-canon-r100-raw.cr3", "https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3"),
|
||||
("cr3-canon-r100-craw.cr3", "https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3"),
|
||||
("cr3-canon-r8-raw.cr3", "https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"),
|
||||
("cr3-canon-r8-craw.cr3", "https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"),
|
||||
(
|
||||
"dng-adobe-canon-5d3-linear-lj92.dng",
|
||||
"https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG",
|
||||
|
||||
Reference in new issue
Block a user