Compare commits

..
Author SHA1 Message Date
Claude 1b5ba5eba7 Merge PR #397 2026-10-08 22:34:54 +00:00
Claude 8d79bf082e Batch fix: the header test's RLE PSD fixture uses PackBits runs of at most 128 bytes
The fixture wrote each row as one literal run of w bytes, which PackBits
can't express past 128 (a 160-pixel row's header byte read as a repeat
run). The old decoder clipped such rows silently; with the PSD fix in
this batch an overrun is an error, as it should be, and the fixture now
splits rows into valid runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:23:51 +00:00
Claude d316d97d80 Merge branch 'fix-psd-rle-alloc' into integ-b9
# Conflicts:
#	crates/codecs/src/psd.rs
2026-10-08 22:21:56 +00:00
Claude 2ab6e973c6 Merge branch 'fix-367-header-probe' into integ-b9 2026-10-08 22:21:37 +00:00
BFlat c395789026 Merge pull request #398 from storytold/claude/integrate-batch-8
Integrate #391 and #393 (undecodable raws import as preview only), with a work budget for the JPEG scan
2026-10-09 05:21:21 +07:00
Claude 6aa599f869 Fix: PSD RLE decode bounds its allocations by the input
A tiny hostile PSD could abort the process on out-of-memory in
`psd::decode` (allocation failure doesn't unwind, so catch_unwind
can't save it):

1. The RLE row byte-count table (channels x rows entries, from the
   header) was reserved with Vec::with_capacity before checking that
   the file holds it: 56 channels x 2^30 rows asked for ~480 GB from a
   ~40-byte file. The table is now taken as a slice of the input with
   checked size math and read lazily, so nothing is allocated from it.

2. RLE rows were zero-padded / run-expanded to the declared width with
   no tie to the input size, so all-zero row counts on a 1 Gpx (the
   DecodeOptions::max_pixels limit, already enforced by check_size)
   32-bit image expanded to 4 GiB per channel. A PackBits row expands
   at most 64x (2-byte repeat run -> 128 bytes), so each decoded row
   must now declare at least ceil(row_bytes / 128) * 2 encoded bytes,
   and the decoded channels' data must be present in the file, before
   any plane is allocated. Decoded planes are thus bounded by 64x the
   file size as well as by max_pixels. PackBits now decodes into a
   pre-sized, zeroed row and a run that would write past the declared
   width is an error instead of being silently clipped.

Also: size math (width x height x bpc, samples) is checked; image
buffers are allocated with try_reserve_exact (TooLarge on failure);
only the colour (+ alpha) planes are decoded, extra channels are
skipped; the PSB layer-section length no longer truncates on 32-bit
targets; interleaving uses iterators instead of indexing.

Valid PSDs (raw and RLE, 8/16/32-bit) decode identically; regression
tests cover a huge RLE header with a truncated table (aborted with a
481 GB allocation before), all-empty RLE rows, counts larger than the
file, a repeat run past the row width (was Ok before) and a 16-bit
gray RLE file with an extra channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:19:42 +00:00
Claude f6f5e6f1b7 Part of #367: probes read image headers instead of decoding the pixels
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.

lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).

Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.

Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).

Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:09:30 +00:00
radialmonster 7094ba1b6e LR-IMP-FORMATS: CR3 C-RAW samples that overshoot the bit depth are clamped
C-RAW is quantised wavelet coding: next to clipped highlights its
reconstruction overshoots the sensor range by a few quantisation steps.
On 17 CC0 CR3 files (SX70 HS, G5 X Mark II, M6 Mark II, EOS R, RP, 90D,
M200, R5 Mark II, R6 Mark II, R6 Mark III, R8) samples reached 16384 to
16419 at 14 bits, never below 0, and decoding failed with "decoded
sample outside its bit depth". Lossless files of the same bodies clip at
exactly 16383. Wavelet samples are now clamped to the CMP1 bit depth;
lossless samples outside it are still corrupt data.

The other 93 CR3 files have no sample outside the range and render
pixel-identically. Render cache version 13 -> 14.
2026-10-08 18:04:01 -04:00
Claude cb0e0cb2e5 Follow-up to #393: the embedded-JPEG scan shares a work budget
scan_for_jpeg walks every JPEG-like start in a container it can't parse;
each walk runs through the marker segments that follow (up to 4096) and
their entropy data. A crafted CRW/MRW/X3F (or unsupported TIFF raw) of
fake starts one after another makes every walk cover most of the file:
quadratic, about a minute for 40 MB, on the import's probe thread. The
walks now report how far they read and share a budget of eight times the
file's size (at least 1 MiB); real files' candidates fail within a few
bytes or are the preview itself. Test: 200,000 fake starts, 0.15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:03:26 +00:00
Claude 0fb3351c91 Merge PR #393 2026-10-08 22:00:34 +00:00
Claude b9254242a2 Merge PR #391 2026-10-08 22:00:10 +00:00
BFlat ab9e8d372c Merge pull request #394 from storytold/claude/integrate-batch-7
Integrate part of #374 (bounded folder walks, launch imports off the UI thread) and #385, #386, #387
2026-10-09 04:43:24 +07:00
Claude b11e5399c6 Merge PR #387 2026-10-08 21:26:34 +00:00
Claude 83058f717e Merge PR #386 2026-10-08 21:26:34 +00:00
Claude 7962b66a06 Merge PR #385 2026-10-08 21:26:33 +00:00
Claude c140f63372 Merge branch 'fix-374-startup-drive-walk' into integ-b7 2026-10-08 21:19:42 +00:00
Claude 790a341776 Part of #374: launch imports run off the UI thread; folder walks are bounded and never take a drive root or home implicitly
Root cause (in 0.2.1 and still on main): files and folders named on the
command line were imported from the eframe app-creator closure with
`app.run("library.import", …)`, which runs the engine command synchronously:
`import::expand` walked the folder recursively (one `is_dir()` open per entry,
no depth or count bound, following links) and probed every file before the
first frame could be drawn. An argument that resolves to a drive root (`.` or
`..` with a launcher's working folder at `C:\`, `/` or `\` on Windows) or to
the home folder therefore walked the whole drive with the window stuck as a
black "not responding" box. The library-problem banner's pending import had
the same synchronous call. Every other recursive walk (Local's Include
Subfolders, Find Missing Photos, preset and LUT folders, MCP) was also
unbounded, which is the "Local folders with subfolders: not responding"
pattern of filmcraft#23, and a link loop recursed until the stack overflowed.

- engine `walk::files_in`: one bounded, iterative walker (32 folder levels,
  1,000,000 entries; `Walked::truncated` says it stopped), folder or file
  taken from the directory listing (no open per entry), links and junctions
  to a folder followed once. `import::expand` (+ `expand_within`), preset and
  LUT folder expansion and MCP `expand_paths` use it.
- engine `import::launch_path`: an empty launch argument is ignored; a
  relative one is resolved against the working folder; a drive or file-system
  root becomes its memory card's DCIM folder or is refused, and so is the home
  folder (a notice says why and to use File > Import Photos).
- desktop: launch arguments go through it and are imported with
  `import::start_paths` (the import worker), like dropped files; so does the
  library-problem banner's pending import.

Not verified on Windows (no machine here). The report says PhotoCraft 0.5.0
and `--memory` hang the same way with no arguments; nothing in LightCraft
walks the disk at startup without arguments (strace of a headless demo run
with HOME=/ and cwd /: no recursive listing), so that part may be outside
this code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 21:19:42 +00:00
BFlat 67b7bc8bc6 Merge pull request #389 from storytold/claude/integrate-batch-6
Integrate #373 (keymap editor), #218 (Lightroom catalog import), #367 (parallel import copies), #344 (import file types)
2026-10-09 04:19:01 +07:00
Claude 41e67e1edd Batch fix: the CLI snapshot test settles before its screenshots
Under load (the full workspace suite) the photo grid was captured before
its thumbnails rendered, darker than the export dialog dimmed over them,
and the dimming comparison failed; it passes alone. Each screenshot now
follows ui.settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:56:35 +00:00
Claude 5d437489b9 Merge branch 'fix-344-import-file-types' into integ-b6 2026-10-08 20:53:11 +00:00
Claude 1e6f0e28e1 Batch fix: the Lightroom catalog dialog filter goes through add_filter_nocase
#218's catalog picker called rfd's add_filter, which #362's guard test
(no_dialog_bypasses_the_helper) forbids: on Linux the filter would hide
CATALOG.LRCAT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:53:11 +00:00
Claude 4af709995f Fix #344: the import review checks or unchecks a file type at once
With several file types among the candidates, the Import Photos review
shows a toggle per type (ARW · 120, JPG · 120): unchecking JPG leaves
the JPEGs kept beside the raws out of the import, checking a type checks
its importable files (duplicates stay unchecked). JPEG/JPG and TIFF/TIF
are one type; a file whose probe named no format goes by its extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:52:35 +00:00
Claude 9b0d0fd05f Parity summary regenerated (cargo xtask parity --write)
Main's summary block had a duplicated pair of total lines left from an
earlier merge; the regenerated one counts #373 and #218's rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:50:04 +00:00
Claude b1d8ba4284 Merge branch 'fix-367-import-parallel' into integ-b6 2026-10-08 20:48:31 +00:00
Claude d8efa6a147 Spanish for the keymap editor (#373) and the Lightroom catalog import (#218)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:48:31 +00:00
Claude d4c0a7a3d4 Merge remote-tracking branch 'origin/main' into integ-b6
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
2026-10-08 20:48:15 +00:00
BFlat 68e0827633 Merge pull request #383 from storytold/claude/test-376-masking-layout
Part of #372: painted-text layout tests from #376
2026-10-09 03:47:22 +07:00
ClaudeandElliot Graham cad024fbff Part of #372: painted-text layout tests from #376
The tests from @QuirkyTurtle94's #376, a duplicate fix for #372 opened
while #379 was in review: they read the painted text shapes (not the
registered rects) at 1x and 2x scale, in English, German and Brazilian
Portuguese, at panel widths 250-520, and check that tile labels fit
unelided inside their tiles, that a long mask name is elided before the
eye, and that a long Describe prompt keeps Select inside the panel.
They pass unchanged against main's implementation.

Co-Authored-By: Elliot Graham <elliotgraham94@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:47:07 +00:00
Claude 86deb5b004 Fix #367: import copies and verifies files on several threads
Import -> Copy (and Move, and Copy as DNG) handled one file after another
on the import worker: copy, fsync, read back and hash, optionally decode
and re-encode the raw as DNG. Every other CPU-heavy step of an import was
already parallel (the probes, up to 8 threads), so with the probes cached
by the review dialog's scan the transfer phase ran on one core.

ImportJob::prepare_files now plans each file in order, as before
(duplicates by path and content, sidecars, rename sequence numbers,
folders, Kept notes) and collects the copies/moves, which then run on up
to 8 threads (std::thread::scope; sequential on wasm32). Outcomes keep
their places, so results, order, names and failure messages are those of
a one-by-one import:
- a file whose content matches a transfer still pending, or whose name
  could collide with one (same folder, same name family: IMG.jpg,
  IMG-1.jpg, img.dng), waits until that one has finished;
- cancel stops before the next file; transfers not started leave no item;
- a panic in one transfer fails that file instead of the batch;
- Copy as DNG holds a memory-gate permit (6x the file size) while it runs.
Copy verification and crash safety are unchanged (same copy_new / place).
The desktop's import worker prepares batches of 2 x workers files (8..16,
lightcraft_engine::import::batch_size) instead of 8, so a batch's slowest
file holds back fewer threads; the browser build keeps 8.

Measured (4-core shared VM, 200 procedural 3.7 MB JPEGs = 754 MB, warm
page cache, throwaway benchmark not committed):
- probe: ~80 ms CPU per file is the JPEG decode (DCT-scaled to 64 px),
  1.2 ms the content hash; the dialog's scan: 4.4 s, 3.6 cores busy.
- Copy, probes cached: copy+fsync+verify 1.59 s at 0.43 cores before,
  0.94 s at 0.81 cores after. Copy, not cached: 6.45 s -> 5.51 s.
- Add, probes cached: 2 ms prepare; nothing to parallelize.
- commit of a batch on the UI thread (journal append + fsync): ~4 ms,
  flat for libraries of 0, 5k, 20k and 50k photos (~0.7 s for 1300).
Copy as DNG could not be measured (no raw corpus here); it is the most
CPU-heavy per-file step and was sequential.

Test: copies_run_side_by_side_with_the_same_outcome (copies under the
test fault Slow run on more than one thread; names, order and in-batch
duplicates match a one-by-one copy; the outcome assertions also pass on
the old code, the thread assertion fails there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:47:04 +00:00
BFlat a673b2bb7a Merge pull request #382 from storytold/claude/i18n-371-spanish
Fix #371: Spanish interface
2026-10-09 03:41:48 +07:00
Claude 305ff71474 Part of #371: Spanish for the SAM 3 install dialog (#366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
(cherry picked from commit 73b22c8524ef17b209ff2fffbb16c248524e2e96)
2026-10-08 20:41:31 +00:00
Claude a0e49629d3 Merge remote-tracking branch 'origin/main' into i18n-371-spanish 2026-10-08 20:41:31 +00:00
BFlat c8f697a128 Merge pull request #381 from storytold/claude/fix-366-sam3-install-help
Fix #366: the SAM 3 dialog without a download location shows how to install by hand
2026-10-09 03:41:25 +07:00
BFlat 232cbace63 Merge pull request #379 from storytold/claude/fix-372-mask-labels
Fix #372: masking tile labels and long mask names stay inside their boxes
2026-10-09 03:41:22 +07:00
radialmonster 1fa3af8740 LRC-LIB-LIBFOLDERS: the sidebar rename test builds its folder ids with forward slashes on Windows
The folder tree writes paths with forward slashes and its widget ids
carry them; the test put the temp folder in the id with backslashes.
Found running cargo xtask ci on Windows.
2026-10-08 16:41:12 -04:00
radialmonster 8e4d7545ff M11.4: the X-T20 corpus RAF is fitted since the away-from-edges retry; update the test and the doc that said it was rejected
Found running cargo xtask ci with the corpus on Windows. The two changes
landed on the same day: the Fujifilm test (#282) expected the compressed
X-T20 file to keep the neutral fallback because its fit failed the gates
on all pixels (held-out RMS 0.114 against 0.10). The retry away from
edges (#263, issue #232) fits it (0.056), so the fit is accepted at both
sizes. Expect true.
2026-10-08 16:41:12 -04:00
radialmonster 1028ed6b88 LRC-LIB-LIBFOLDERS: a chosen folder that follows a move keeps its names' case and the platform's separators
follow_library_folder rebuilt the chosen subfolder from the lower-cased
folder_key on Windows and joined it with '/', so after a move the folder
shown in the sidebar came back as c:\archive\trip/day1 with its names in
lower case. Add folder_rest (compares by folder_key, returns the names as
spelled) and join them with the platform's separator. Tests compare
folders by identity where they only mean the same folder, and a new test
fails on Windows without the fix.
2026-10-08 16:41:11 -04:00
Claude efe7cad73c Follow-up to #218: Lightroom catalog import translated
The progress, result and menu strings of the Lightroom catalog import in
the ja, pt-br, zh-hans, zh-hant, de and ru catalogs. The formats catalogs
held English copies of the seven new format messages; they are
translated, and German and Russian (which the PR predates) get them too,
so the formats build check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:36:22 +00:00
Claude a86defd1b9 Merge PR #218 2026-10-08 20:35:39 +00:00
radialmonster 881bc85dac LR-IMP-FORMATS: import CRW, MRW, X3F, KDC, MOS, ERF and IIQ files from folders as preview only
Folder import, drag and drop and MCP folder expansion skipped these extensions, so
the preview-only fallback for these containers was only reachable by naming a file.
Adds exactly the containers that now import as preview only to import::EXTENSIONS and
the headless PHOTO_EXTENSIONS. 3FR, FFF, DCR and STI still fail and stay out.
2026-10-08 16:32:17 -04:00
radialmonster dbe73f0f9c LR-IMP-FORMATS: raws we cannot decode import as preview only when any usable JPEG is embedded
Widens the preview-only fallback. A recognised raw whose decode fails now falls back
to its embedded JPEG for any decode error (unsupported, corrupt, limit, TIFF
structure), not only an unsupported variant, and the preview search covers more
containers:
- Canon CRW, Minolta MRW and Sigma X3F are recognised by their magic (RawFormat
  Crw, Mrw, X3f) instead of failing as "RawOther files are not supported yet".
- A TIFF whose IFD0 has no ImageWidth/ImageLength but a JPEG pointer or SubIFDs
  (Kodak KDC) is a raw container, not a malformed image. TIFF compression 99
  (Leaf MOS) counts as a raw compression.
- When no tag points to a preview, a recognised raw we cannot decode is scanned
  for the largest complete three-component DCT JPEG. A preview whose first byte
  was overwritten (Minolta, Epson) is restored. A one-component JPEG is skipped
  (a PowerShot CRW stores its mosaic that way).
Files with no JPEG at all still fail with "without an embedded preview".
2026-10-08 16:32:17 -04:00
radialmonster 0b8c7a237a LR-IMP-FORMATS: TIFF raws with a thumbnail-only IFD0 import as preview only, not as a tiny image
Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs keep their raw data in
a private block. IFD0 is only a 160-640 px RGB copy and no IFD is marked as raw, so
they opened as an ordinary TIFF of that size (a 150 MP file rendered 296x220) with
no preview-only label.

probe() now recognises such a file from its structure (OtherTiff): a picture at
least 4x larger than IFD0 in both directions is declared by a SubIFDs child, or by
Exif PixelXDimension/PixelYDimension together with enough bytes outside every IFD's
strips and tiles to hold it at one bit per pixel. decode() reports the sizes as the
reason. A raw we cannot decode now falls back to its embedded JPEG, else to the TIFF
reader's view of its first image, so it imports preview-only with that reason.
RENDER_CACHE_VERSION 12: these files render differently.
2026-10-08 16:31:49 -04:00
Claude 43aa795539 Follow-up to #373: translated keymap editor, contextual keys noted
- The keymap editor's strings, the fixed-key labels and the two new
  command labels in the ja, pt-br, zh-hans, zh-hant, de and ru catalogs;
  'Restore {}' and 'Removed from {}' go through tr_format!.
- The Presets and Soft Proofing rows say what their key does in the
  Library grids (Flag as Pick and advance / expand or collapse the
  stack), which main's static Shortcuts list showed as a separate label.
- The macOS menu publishes no shortcuts while a key is being recorded
  (merged with main's owned_by_menu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:24:13 +00:00
Claude 38cdd49274 Merge PR #373 2026-10-08 20:23:12 +00:00
Claude 0b587a0a8a Fix #371: Spanish interface
A Spanish (es) catalog and formats catalog covering the same messages as
the Brazilian Portuguese, Japanese and Simplified Chinese catalogs (1595
messages, 180 formats), an Edit > Language > Español command
(app.language.spanish), es tag tests (es, es-ES, es_MX.UTF-8, es-419),
docs/localization-es.md with the terminology used, and the language
lists in docs/localization.md, parity and What's New.

Instructions use infinitives or impersonal forms (no usted). Plural
endings follow the existing convention: the English "s" passes through
where Spanish adds one, else {:.0} swallows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:20:17 +00:00
Claude 60a98354d9 Fix #366: the SAM 3 dialog without a download location shows how to install by hand
With no mirror configured the dialog was titled "Download the SAM 3
Model?", only said to put "the files" in the folder (see
docs/ai-masks.md) and offered Close. It is now titled "Install the SAM 3
Model", names the three files (model.safetensors, vocab.json,
merges.txt from facebook/sam3), and has a Show in Finder / Explorer /
File Manager button that creates and shows the model folder, plus a link
to the installation guide. Object and Describe work as soon as the files
are there.

The download location itself (a hosted mirror) is still a maintainer
decision: DEFAULT_MIRRORS stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:18:47 +00:00
Claude f16f413942 Fix #372: masking tile labels and long mask names stay inside their boxes
The Create New Mask tiles painted their (untranslated) labels with an
unconstrained Painter::text into tiles capped at 52 pt, so Background and
Luminance ran past their tiles, and translations would more so. The grid
now measures the translated labels: four 52 pt tiles when they fit (at
10.5 pt, or 9.5 pt before dropping a column), else three or two, a tile
growing past 52 pt for a long label; only in a panel too narrow for even
that is a label cut short (full text on hover).

A mask row's name is cut short with an ellipsis before the eye (full name
on hover). The Describe field places the translated Select button first
and gives the field what is left, instead of reserving a fixed 64 pt.
Status lines wrap explicitly.

The painted label and name rects are registered (maskNewLabel:*,
maskName:*), so the sidebar test checks the text, not just the widgets,
at four panel widths with a long name and a long prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:09:16 +00:00
BFlat 48cd9c5e3a Merge pull request #375 from storytold/claude/integrate-batch-4
Integrate #279 (Canon CR3 decoding) and #362 (file dialog filters)
2026-10-09 03:08:11 +07:00
parkerHurstandClaude Opus 5.5 e2b626b7f7 LR-PREF-SHORTCUTS: editable keyboard shortcuts in Help ▸ Keyboard Shortcuts (⌘/)
The ⌘/ dialog is now a keymap editor: click a shortcut and press the new
keys (Esc cancels), × removes it, ↺ restores the default, Reset All clears
every change. A key another command had moves to the new command, with a
toast. Changes go through the new app.setShortcut / app.resetShortcuts
commands into AppSettings.keymap (saved in ui.json) and show up in the
menus, the native macOS menu and the control channel's command list.

Modifier keys alone never record (holding ⌘ waits for the key it
modifies), ⌘C/⌘X/⌘V are recorded from the copy/cut/paste events, and
saved entries that don't parse fall back to the default shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:54:50 -05:00
Adrian D'souza 095c7dce87 fix sqlite test payload extension 2026-10-09 00:57:19 +05:30
Adrian D'souza b4a1166f4b PR218: document optional-data and recovery warnings 2026-10-09 00:17:24 +05:30
Adrian D'souzaandCodex 5413c8bb3a PR218: make archive scale test exercise staging
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:13:49 +05:30
Adrian D'souzaandCodex 07d9678209 PR218: cover optional payload cap warning
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:39 +05:30
Adrian D'souzaandCodex 3c76902bbb PR218: cover unsupported schema entries
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:06 +05:30
Adrian D'souzaandCodex 7cf3719c63 PR218: localize complete import messages & scale prepared path
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:11:29 +05:30
Adrian D'souzaandCodex d16ab1f238 PR218: check SQLite local payload span
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:52 +05:30
Adrian D'souzaandCodex bbcfdf085b PR218: harden catalog import review fixes
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 1e6035d9db test: encode SQLite fixture serial types & overflow offsets correctly
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 37cfc46e0d PR218: place prepared-import regression tests after production items
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex b256b8b635 PR218: bound egui worker message size & simplify cancellation
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex e8c817ddea PR218: satisfy current CI clippy without changing import behavior
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex d367d83ee6 LRC-LIB-CATALOG-IMPORT: exercise actual concurrent develop edit
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 6a05f865ee LRC-LIB-CATALOG-IMPORT: refresh rebased parity summary
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 866d02e577 LRC-LIB-CATALOG-IMPORT: address SQLite, worker & archive review
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Orthic LabsandCodex 4a427894ed LRC-LIB-CATALOG-IMPORT: import Lightroom Classic catalogs
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
86 changed files with 9833 additions and 434 deletions

No files matched your search

Generated
+1
View File
@@ -2892,6 +2892,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
"rayon",
+12
View File
@@ -191,6 +191,18 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
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.
+6 -2
View File
@@ -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();
+1
View File
@@ -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,
+60 -1
View File
@@ -283,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())
})),
@@ -462,6 +469,31 @@ ENVIRONMENT:
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
";
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
let cwd = std::env::current_dir().ok();
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
launch_imports_in(args, cwd.as_deref(), home.as_deref())
}
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
use lightcraft_engine::import::{LaunchPath, launch_path};
let (mut files, mut notices) = (Vec::new(), Vec::new());
for a in args {
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
Some(LaunchPath::Import(p)) => files.push(p),
Some(LaunchPath::Refused { path, why }) => {
log::warn!("not importing {path} given at launch: {why}");
notices
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
}
None => {}
}
}
(files, notices)
}
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
fn log_dir() -> Option<std::path::PathBuf> {
@@ -534,6 +566,9 @@ fn main() -> eframe::Result {
None => logger.no_file(),
}
}
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
// root, and a whole drive or home folder is not walked (issue #374)
let (files, launch_notices) = launch_imports(&files);
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
// the saved language from the start, so a failed start is reported in it too
if let Some(ui) = &prefs {
@@ -586,6 +621,7 @@ fn main() -> eframe::Result {
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
@@ -608,7 +644,12 @@ fn main() -> eframe::Result {
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
let _ = app.run("library.import", serde_json::json!({"paths": files}));
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
@@ -658,6 +699,24 @@ mod tests {
d
}
/// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
/// not imported; empty ones are ignored; the rest are imported as absolute paths.
#[test]
fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
let home = dir("launch");
std::fs::create_dir_all(home.join("Trip")).unwrap();
let args: Vec<String> = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
assert_eq!(notices.len(), 2, "{notices:?}");
assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
// the working folder is a drive root (a launcher's choice): `.` is never the whole drive
let root = home.ancestors().last().unwrap().to_path_buf();
let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
let _ = std::fs::remove_dir_all(&home);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
+12 -5
View File
@@ -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),
+11
View File
@@ -525,6 +525,17 @@ pub fn folder_within(path: &str, root: &str) -> bool {
key_within(&folder_key(path), &folder_key(root))
}
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
/// the names keep their case, which the key lowers on Windows.
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
if !key_within(&folder_key(path), &folder_key(root)) {
return None;
}
let depth = split_path(root, false).parts.len();
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
}
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
/// separators) is its own identity, so it is read without building a key for it: the check runs
+1
View File
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
+80
View File
@@ -108,6 +108,57 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
decode_with_fallback(bytes, opts, None)
}
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
/// type or component count, or when its markers don't run to an end-of-image marker after the
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.sof, 0xC0..=0xC3) {
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
}
if !matches!(m.components, 1 | 3 | 4) {
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
}
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
}
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
Ok((m.width, m.height, orientation))
}
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
/// markers that end it.
fn reaches_eoi(b: &[u8]) -> bool {
let mut p = 2usize;
loop {
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
p = p.saturating_add(skip);
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
p = match marker {
0xD9 => return true,
0xFF => p.saturating_add(1),
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
_ => {
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
return false;
};
if len < 2 {
return false;
}
p.saturating_add(2).saturating_add(len)
}
};
}
}
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
@@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
/// is most of [`header`]'s time).
fn find_ff(b: &[u8]) -> Option<usize> {
const ONES: u64 = 0x0101_0101_0101_0101;
let (words, tail) = b.as_chunks::<8>();
for (i, w) in words.iter().enumerate() {
// a zero byte in !w is an 0xFF byte in w
let x = !u64::from_ne_bytes(*w);
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
}
}
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
}
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
/// fitted with the source aspect ratio.
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
@@ -297,6 +363,20 @@ mod tests {
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
}
#[test]
fn find_ff_matches_a_plain_search() {
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
if let Some(i) = at {
b[i] = 0xFF;
}
for start in [0, 1, 5, 9, 290, 299, 300] {
let s = &b[start..];
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
}
}
}
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());
+48
View File
@@ -6,6 +6,8 @@
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
@@ -156,6 +158,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
}
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Header {
pub format: Format,
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
pub width: u32,
pub height: u32,
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
pub orientation: u16,
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
/// third-party parser is caught on unwinding targets).
pub fn read_header(bytes: &[u8]) -> Result<Header> {
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
}
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
let (width, height, orientation) = match format {
Format::Jpeg => jpeg::header(bytes)?,
Format::Png => png_codec::header(bytes)?,
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
(d.source_width, d.source_height, d.orientation)
}
};
Ok(Header { format, width, height, orientation })
}
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
+69
View File
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, meta, (w, h), opts)
}
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(err("bad signature"));
}
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
return Err(err("missing IHDR"));
}
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
if w > i32::MAX as u32 || h > i32::MAX as u32 {
return Err(err("dimensions out of range"));
}
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let depth_ok = match color {
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
3 => matches!(depth, 1 | 2 | 4 | 8),
2 | 4 | 6 => matches!(depth, 8 | 16),
_ => false,
};
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
return Err(err("invalid IHDR"));
}
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
while let Some(len) = be32(p) {
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
if idat && kind != b"IDAT" {
// a chunk follows the image data: the IDAT run is complete
after_idat = true;
}
if kind == b"IEND" {
break;
}
let start = p.saturating_add(8);
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
let Some(data) = data else {
if after_idat {
break; // a damaged chunk after the image data: a decode ignores it too
}
return Err(err("truncated"));
};
match kind {
b"PLTE" => palette = true,
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
b"IDAT" => {
if color == 3 && !palette {
return Err(err("indexed image without a palette"));
}
idat = true;
}
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
_ => {}
}
p = start.saturating_add(data.len()).saturating_add(4);
}
if !idat {
return Err(err("no image data"));
}
if !after_idat {
return Err(err("truncated image data"));
}
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
+265 -72
View File
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let (icc, exif, xmp) = resources(res);
// Layer and mask information: only the layer count sign matters (merged transparency).
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
let lm = r.take(lm_len)?;
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
let off = if psb { 8 } else { 4 };
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
let plane = width * height * bpc;
let row_bytes = width * bpc;
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
match compression {
0 => {
for _ in 0..channels {
planes.push(r.take(plane)?.to_vec());
}
}
1 => {
let counts_len = channels * height;
let mut counts = Vec::with_capacity(counts_len);
for _ in 0..counts_len {
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
for c in 0..channels {
let mut out = Vec::with_capacity(plane);
for y in 0..height {
let src = r.take(counts[c * height + y])?;
unpackbits(src, row_bytes, &mut out);
}
planes.push(out);
}
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
}
let (model, n_color) = match mode {
1 | 8 => (Model::Gray, 1),
2 => (Model::Rgb, 1),
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
return Err(err("too few channels"));
}
let alpha = merged_transparency && channels > n_color && mode != 2;
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
// stored after them and never touched.
let used = n_color + alpha as usize;
let n = width * height;
let too_large = || Error::TooLarge(width as u64, height as u64);
let n = width.checked_mul(height).ok_or_else(too_large)?;
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
match compression {
0 => {
// Raw planes are borrowed from the input, so their size is bounded by the file.
for _ in 0..used {
planes.push(r.take(plane)?.to_vec());
}
}
1 => {
// Row byte-count table: one entry per row per channel (all channels, since the data
// follows it). Taken from the input before anything is allocated from it.
let entry = if psb { 4 } else { 2 };
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
let table = r.take(table_len)?;
let count = |i: usize| -> usize {
let at = i.saturating_mul(entry);
match table.get(at..at.saturating_add(entry)) {
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
_ => 0,
}
};
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
// at least this many encoded bytes. Requiring it (and the data being present) before
// allocating bounds the decoded planes by the file size: a tiny file can no longer
// declare gigabytes of zero-padded or repeat-run rows.
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
let mut data_len = 0usize;
for i in 0..used.saturating_mul(height) {
let c = count(i);
if c < min_row {
return Err(err("RLE row too short for its width"));
}
data_len = data_len.saturating_add(c);
}
if data_len > bytes.len().saturating_sub(r.p) {
return Err(err("truncated"));
}
for c in 0..used {
let mut out = zeroed::<u8>(plane, too_large)?;
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
let src = r.take(count(c * height + y))?;
unpackbits(src, row)?;
}
planes.push(out);
}
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
}
let samples = n.checked_mul(used).ok_or_else(too_large)?;
// Interleave to device samples.
let buf = if mode == 2 {
// Indexed → 8-bit RGB via the 768-byte palette.
if color_mode_data.len() < 768 || depth != 8 {
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
return Err(err("bad indexed palette"));
}
let mut v = Vec::with_capacity(n * 3);
for &i in &planes[0] {
};
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
for (o, &i) in v.chunks_exact_mut(3).zip(idx) {
let i = i as usize;
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
o.copy_from_slice(&[pal[i], pal[256 + i], pal[512 + i]]);
}
Buf::U8(v)
} else {
let cmyk = model == Model::Cmyk;
match bpc {
1 => {
let mut v = vec![0u8; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = p[i];
let mut v = zeroed::<u8>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
*o = if cmyk && c < 4 { 255 - x } else { x };
}
}
Buf::U8(v)
}
2 => {
let mut v = vec![0u16; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
let mut v = zeroed::<u16>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(2)) {
let x = u16::from_be_bytes([s[0], s[1]]);
*o = if cmyk && c < 4 { 65535 - x } else { x };
}
}
Buf::U16(v)
}
_ => {
let mut v = vec![0f32; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
let mut v = zeroed::<f32>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(4)) {
let x = f32::from_be_bytes([s[0], s[1], s[2], s[3]]);
*o = if cmyk && c < 4 { 1.0 - x } else { x };
}
}
Buf::F32(v)
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
let target = out.len() + row_bytes;
while out.len() < target && !src.is_empty() {
let h = src[0] as i8;
src = &src[1..];
if h >= 0 {
let n = (h as usize + 1).min(src.len()).min(target - out.len());
out.extend_from_slice(&src[..n]);
src = &src[(h as usize + 1).min(src.len())..];
} else if h != -128 {
let n = ((1 - h as isize) as usize).min(target - out.len());
if let Some(&b) = src.first() {
out.extend(std::iter::repeat_n(b, n));
src = &src[1..];
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
/// depth, colour mode or compression, or merged image data that runs past the end of the file
/// (truncated). The run-length data itself is not unpacked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut r = Rd { b: bytes, p: 0 };
if r.take(4)? != b"8BPS" {
return Err(err("bad signature"));
}
let psb = match r.u16()? {
1 => false,
2 => true,
_ => return Err(err("bad version")),
};
r.take(6)?;
let channels = r.u16()? as usize;
let height = r.u32()? as usize;
let width = r.u32()? as usize;
let depth = r.u16()?;
let mode = r.u16()?;
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
if channels == 0 || channels > 56 {
return Err(err("bad channel count"));
}
let cm_len = r.u32()? as usize;
let color_mode_data = r.take(cm_len)?;
let res_len = r.u32()? as usize;
let (_, exif, _) = resources(r.take(res_len)?);
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
r.take(lm_len)?;
let compression = r.u16()?;
let bpc = match depth {
8 => 1usize,
16 => 2,
32 => 4,
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
let n_color = match mode {
1 | 8 | 7 | 2 => 1,
3 => 3,
4 => 4,
9 => return Err(Error::Unsupported(F, "Lab PSD")),
_ => return Err(err("unknown colour mode")),
};
if channels < n_color {
return Err(err("too few channels"));
}
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
return Err(err("bad indexed palette"));
}
let rest = bytes.len().saturating_sub(r.p);
let need = match compression {
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
1 => {
// the row byte counts, then the rows they count
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
let mut total = table;
for _ in 0..rows {
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
Some(total)
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
};
if need.is_none_or(|n| n > rest) {
return Err(err("truncated"));
}
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
Ok((width as u32, height as u32, orientation))
}
/// A zero-filled buffer whose allocation failure is an error, not an abort.
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| too_large())?;
v.resize(len, T::default());
Ok(v)
}
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
let mut at = 0;
while at < row.len() {
let Some((&h, rest)) = src.split_first() else { break };
src = rest;
let h = h as i8;
if h >= 0 {
let n = h as usize + 1;
let lit = src.get(..n).unwrap_or(src);
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
dst.copy_from_slice(lit);
at += lit.len();
src = src.get(n..).unwrap_or(&[]);
} else if h != -128 {
let n = (1 - h as isize) as usize;
let Some((&b, rest)) = src.split_first() else { break };
src = rest;
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
at += n;
}
}
out.resize(target, 0);
Ok(())
}
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
@@ -234,11 +349,89 @@ mod tests {
#[test]
fn packbits() {
let mut out = Vec::new();
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
let mut out = Vec::new();
unpackbits(&[0x05, 1], 4, &mut out);
assert_eq!(out, vec![1, 0, 0, 0]);
let mut out = [0u8; 6];
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
// Short run data is zero-padded.
let mut out = [0u8; 4];
unpackbits(&[0x01, 1], &mut out).unwrap();
assert_eq!(out, [1, 0, 0, 0]);
// Truncated literal run: copies what is there.
let mut out = [0u8; 4];
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
assert_eq!(out, [7, 8, 0, 0]);
}
#[test]
fn packbits_overrun_is_an_error() {
// Repeat run of 128 into a 4-byte row.
let mut out = [0u8; 4];
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
// Literal run of 6 into a 4-byte row.
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
}
/// PSD header + empty colour-mode/resources/layer sections + compression id.
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
let mut b = b"8BPS".to_vec();
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&channels.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&depth.to_be_bytes());
b.extend_from_slice(&mode.to_be_bytes());
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
b.extend_from_slice(&compression.to_be_bytes());
b
}
#[test]
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
b.extend_from_slice(&[0, 0, 0, 0]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
}
#[test]
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
b.extend_from_slice(&[0; 6]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
}
#[test]
fn rle_repeat_run_past_row_width_is_an_error() {
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
let mut b = header(1, 4, 1, 8, 1, 1);
b.extend_from_slice(&2u16.to_be_bytes());
b.extend_from_slice(&[0x81, 0x55]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
}
#[test]
fn rle_gray16_and_extra_channels_decode() {
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
let mut b = header(2, 2, 2, 16, 1, 1);
for _ in 0..4 {
b.extend_from_slice(&2u16.to_be_bytes());
}
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
let d = decode(&b, &DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (2, 2));
let p = d.image.get(1, 1);
let q = d.image.get(0, 0);
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
}
+63 -21
View File
@@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
limits.ifd_value_size = 32 << 20;
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, opts)?;
let ct = d.colortype().map_err(err)?;
@@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
let compression = d.get_tag_u32(Tag::Compression).ok();
let (model, n_color, n_samples, bits) = match ct {
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
other => return Err(Error::Unsupported(F, unsupported_name(other))),
};
if bits < 8 && planar && n_samples > 1 {
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
}
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
let premultiplied = has_alpha && extra.first() == Some(&1);
@@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, meta, (w, h), opts)
}
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
limits.ifd_value_size = 32 << 20;
limits
}
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
/// bits per sample).
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
let l = match ct {
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
other => return Err(Error::Unsupported(F, unsupported_name(other))),
};
if l.3 < 8 && planar && l.2 > 1 {
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
}
Ok(l)
}
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
/// start past the end of the file (truncated). The compressed data itself is not checked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let ct = d.colortype().map_err(err)?;
let compression = d.get_tag_u32(Tag::Compression).ok();
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
layout(ct, compression, planar)?;
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
return Err(Error::Unsupported(F, "compression method"));
}
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
};
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
.ok_or_else(|| err("no image data"))?;
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
let len = bytes.len() as u64;
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
return Err(err("truncated: image data past the end of the file"));
}
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
Ok((w, h, orientation))
}
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
use tiff::decoder::ifd::Value;
+27
View File
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
}
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
/// itself is not checked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut p = 12usize;
loop {
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
let end = p.saturating_add(8).saturating_add(size);
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
if end > bytes.len() {
return Err(err("truncated image data"));
}
break;
}
p = end.saturating_add(size & 1);
}
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
d.set_memory_limit(bytes.len());
let (w, h) = d.dimensions();
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let exif = d.exif_metadata().ok().flatten();
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
}
+2
View File
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
if let Some(f) = sniff(bytes) {
let _ = decode_unguarded(bytes, f, &small_opts());
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
let _ = read_header_unguarded(bytes, f);
}
let _ = decode(bytes, small_opts());
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
}
+366
View File
@@ -0,0 +1,366 @@
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
fn pixels(w: usize, h: usize) -> Rgba8 {
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
}
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
}
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
use tiff::encoder::{TiffEncoder, colortype};
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let mut cur = std::io::Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut cur).unwrap();
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
im.write_data(&rgb).unwrap();
cur.into_inner()
}
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(b"8BPS");
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&3u16.to_be_bytes());
b.extend_from_slice(&(h as u32).to_be_bytes());
b.extend_from_slice(&(w as u32).to_be_bytes());
b.extend_from_slice(&8u16.to_be_bytes());
b.extend_from_slice(&3u16.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
let mut res = Vec::new();
if let Some(o) = orientation {
let exif = minimal_exif(o);
res.extend_from_slice(b"8BIM");
res.extend_from_slice(&1058u16.to_be_bytes());
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
res.extend_from_slice(&exif);
if exif.len() % 2 == 1 {
res.push(0);
}
}
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
b.extend_from_slice(&res);
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
b.extend_from_slice(&(rle as u16).to_be_bytes());
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
if rle {
// every row as literal runs of at most 128 bytes (PackBits' longest)
let row_len = w + w.div_ceil(128);
for _ in 0..3 * h {
b.extend_from_slice(&(row_len as u16).to_be_bytes());
}
for c in 0..3 {
let p: Vec<u8> = plane(c).collect();
for row in p.chunks(w) {
for run in row.chunks(128) {
b.push(run.len() as u8 - 1);
b.extend_from_slice(run);
}
}
}
} else {
for c in 0..3 {
b.extend(plane(c));
}
}
b
}
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
let img = pixels(w, h);
let e = EncodeImage::rgba8(&img);
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
let mut out = Vec::new();
for o in [None, Some(1), Some(6), Some(8)] {
let exif = o.map(minimal_exif);
let tag = format!("o{}", o.unwrap_or(0));
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
out.push((format!("psd {tag}"), psd(w, h, o, false)));
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
}
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
let mut bmp = Vec::new();
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
out.push(("bmp".into(), bmp));
out
}
/// What the import probe learnt before it read headers: a small decode.
fn decoded(bytes: &[u8]) -> Result<Header> {
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
}
#[test]
fn header_matches_decode() {
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
for (name, bytes) in fixtures(w, h) {
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
}
}
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
}
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
/// is checked for truncation, which the header readers detect.
#[test]
fn truncated_files_agree_with_decode() {
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
for (name, bytes) in fixtures(160, 96) {
let n = bytes.len();
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
cuts.extend(n.saturating_sub(64)..n);
for cut in cuts {
let part = &bytes[..cut];
if let Ok(d) = decoded(part) {
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
}
}
for cut in [n / 4, n / 2, n * 9 / 10] {
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
}
}
}
#[test]
fn truncation_details() {
let f = fixtures(160, 96);
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
let jpeg = get("jpeg 420 o6");
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
let mut trailing = jpeg.clone();
trailing.extend_from_slice(&[0; 100]);
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
let png = get("png rgba o6");
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
}
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
#[test]
fn png_exif_after_idat() {
let img = pixels(9, 4);
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
let exif = minimal_exif(6);
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(b"eXIf");
chunk.extend_from_slice(&exif);
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
let iend = plain.len() - 12;
let mut b = plain[..iend].to_vec();
b.extend_from_slice(&chunk);
b.extend_from_slice(&plain[iend..]);
assert_eq!(read_header(&b).unwrap().orientation, 6);
}
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.push(comps);
for c in 0..comps {
b.extend_from_slice(&[c + 1, 0x11, 0]);
}
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
b.extend_from_slice(&[0xFF, 0xD9]);
b
}
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
b
}
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
b.extend_from_slice(&len.to_be_bytes());
b.extend_from_slice(kind);
b.extend_from_slice(data);
b.extend_from_slice(&[0; 4]);
}
#[test]
fn hostile_headers_are_errors() {
// a well-formed synthetic JPEG header is read
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
let jpeg_bad = [
jpeg_segments(0xC0, 0, 21, 3), // zero width
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
jpeg_segments(0xC0, 33, 21, 2), // two components
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
vec![0xFF, 0xD8, 0xFF, 0xD9],
];
for (i, b) in jpeg_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "JPEG case {i}");
}
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
no_sos.truncate(2 + 2 + 17);
no_sos.extend_from_slice(&[0xFF, 0xD9]);
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
let mut ok = png_ihdr(5, 3, 8, 2);
png_chunk(&mut ok, b"IDAT", 1, &[0]);
png_chunk(&mut ok, b"IEND", 0, &[]);
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
let mut b = ihdr;
for (k, l, d) in chunks {
png_chunk(&mut b, k, *l, d);
}
b
};
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
let png_bad = [
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
];
for (i, b) in png_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "PNG case {i}");
}
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
// cut short after the IFD
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
for (i, b) in tiff_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "TIFF case {i}");
}
// a strip offset pointing past the end of the file
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
let n = t(&tiff, ifd) as usize;
let mut far = tiff.clone();
for e in 0..n {
let p = ifd + 2 + e * 12;
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
}
}
assert_ne!(far, tiff, "the strip offset was found");
assert!(read_header(&far).is_err(), "strip past the end");
let webp_bad = [
b"RIFF\0\0\0\0WEBP".to_vec(),
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
];
for (i, b) in webp_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "WebP case {i}");
}
let good = psd(4, 3, Some(6), true);
assert_eq!(read_header(&good).unwrap().orientation, 6);
let set = |pos: usize, v: &[u8]| {
let mut b = good.clone();
b[pos..pos + v.len()].copy_from_slice(v);
b
};
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
let compression = 34 + res_len + 4;
let mut psd_bad = vec![
set(12, &0u16.to_be_bytes()), // no channels
set(12, &57u16.to_be_bytes()), // too many
set(12, &2u16.to_be_bytes()), // too few for RGB
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
set(18, &0u32.to_be_bytes()), // zero width
set(22, &1u16.to_be_bytes()), // 1-bit
set(22, &7u16.to_be_bytes()), // bad depth
set(24, &9u16.to_be_bytes()), // Lab
set(24, &77u16.to_be_bytes()), // unknown mode
set(24, &2u16.to_be_bytes()), // indexed without a palette
set(compression, &2u16.to_be_bytes()), // ZIP
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
];
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
let mut tall = good.clone();
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
psd_bad.push(tall);
psd_bad.push(good[..good.len() - 1].to_vec());
for (i, b) in psd_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "PSD case {i}");
}
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
assert!(read_header(b).is_err(), "{b:?}");
}
}
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
/// header agrees with it (deterministic seed).
#[test]
fn mutated_files_agree_with_decode() {
let mut s: u64 = 0x1234_5678_9abc_def1;
let mut rnd = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s
};
let (mut decodable, mut disagreements) = (0, Vec::new());
for (name, bytes) in fixtures(160, 96) {
for _ in 0..300 {
let mut b = bytes.clone();
for _ in 0..1 + rnd() % 3 {
let i = rnd() as usize % b.len();
b[i] = rnd() as u8;
}
if let Ok(d) = decoded(&b) {
decodable += 1;
let h = read_header(&b);
if h.as_ref().ok() != Some(&d) {
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
}
}
}
}
assert!(decodable > 1000, "{decodable}");
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+1
View File
@@ -40,3 +40,4 @@ sam = ["dep:lightcraft-segment"]
[dev-dependencies]
libc = { workspace = true }
lightcraft-tiff = { workspace = true }
+5 -3
View File
@@ -1179,15 +1179,17 @@ mod tests {
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes;
/// a poor reference keeps the fallback. Skips cleanly without these CC0 corpus files.
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
/// corpus files.
#[test]
fn corpus_raf_colour_and_white_balance() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", false)] {
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let header = crate::files::probe_bytes(name, &bytes).unwrap();
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
+3 -7
View File
@@ -268,13 +268,9 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
/// the folder itself.
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
let Some(c) = chosen.as_deref() else { return };
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
if fk.is_empty() {
return;
}
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
let rest = rest.trim_start_matches('/');
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
if let Some(rest) = lightcraft_catalog::query::folder_rest(c, from) {
// joined by the platform, so the result is spelled like `to` and like a path read from disk
*chosen = Some(rest.iter().fold(std::path::PathBuf::from(to), |p, name| p.join(name)).to_string_lossy().to_string());
}
}
+19
View File
@@ -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",
+5 -16
View File
@@ -41,22 +41,11 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let ep = e.path();
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if ep.is_dir() {
stack.push(ep);
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
&& let Ok(t) = std::fs::read_to_string(&ep)
{
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
if let Ok(t) = std::fs::read_to_string(&ep) {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
+1 -1
View File
@@ -191,7 +191,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
pub fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
return Some(p);
}
+140 -10
View File
@@ -92,8 +92,10 @@ fn ext_upper(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
}
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
let m = lightcraft_meta::extract(bytes);
@@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
if !fmt.can_decode() {
return Err(format!("{fmt:?} files are not supported yet"));
}
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
let o = Orientation::from_exif(d.orientation);
let (mut w, mut h) = (d.source_width, d.source_height);
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
let o = Orientation::from_exif(header.orientation);
let (mut w, mut h) = (header.width, header.height);
if o.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
@@ -182,11 +185,15 @@ 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.
/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF
/// structure): its preview may still be intact. Only a file that is not a raw reports an error.
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
use lightcraft_raw::RawError;
match e {
lightcraft_raw::RawError::Unsupported(why) => Ok(why),
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()),
RawError::NotRaw => Err(RawError::NotRaw.to_string()),
e => Ok(e.to_string()),
}
}
@@ -329,10 +336,24 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
}
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
return Some(std::borrow::Cow::Owned(jpeg));
}
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
(undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff))
.then_some(std::borrow::Cow::Borrowed(bytes))
}
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
match lightcraft_raw::embedded_preview_color_space(bytes) {
let jpeg = stand_in_image(bytes)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
@@ -346,7 +367,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let jpeg = stand_in_image(bytes)?;
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
let (mut w, mut h) = (d.source_width, d.source_height);
if preview_orientation(bytes, d.orientation).swaps_axes() {
@@ -424,6 +445,37 @@ mod tests {
use super::*;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
/// file or one that isn't an image.
#[test]
fn image_probe_reads_headers_like_the_decode_did() {
let (w, h) = (300u32, 200u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
let p = probe_bytes(name, bytes).unwrap();
// what the probe computed from a decode before
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
let swap = Orientation::from_exif(d.orientation).swaps_axes();
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
assert_eq!((p.width, p.height), want, "{name} {o:?}");
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
assert_eq!(p.kind, MediaKind::Image);
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
assert_eq!(p.file_size, bytes.len() as u64);
assert!(p.content_hash.is_some());
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
}
}
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
/// mostly cancel, edges remain.
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
@@ -704,6 +756,84 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif
/// declares a picture 25x its size.
fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] });
ifd0.set_child(
t::EXIF_IFD,
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])),
);
let mut b = TiffWriter::default().write(&[ifd0]).unwrap();
if shell {
b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100));
}
b
}
/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size
/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't
/// decode: preview only, with the reason, still showing that image.
#[test]
fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() {
let b = tiff_shell(24, 16, true);
let p = probe_bytes("IMG_0001.IIQ", &b).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16));
let why = p.preview_only.expect("marked preview only");
assert!(why.contains("600x400") && why.contains("24x16"), "{why}");
let (img, src) = load_bytes(&b, 12).unwrap();
assert_eq!((img.width, img.height, src.raw), (12, 8, false));
assert!(embedded_preview_srgb(&b, 12).is_some());
// the same pixels without the private block are a small ordinary image
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
#[test]
fn undecodable_container_with_a_preview_imports_as_preview_only() {
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
jpeg[0] = 0x02;
let mut f = b"\0MRM\0\x01\0\0".to_vec();
f.extend(std::iter::repeat_n(0x11u8, 300));
f.extend_from_slice(&jpeg);
f.extend(std::iter::repeat_n(0x22u8, 300));
let p = probe_bytes("A.MRW", &f).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30));
assert!(p.preview_only.is_some_and(|w| w.contains("Mrw")));
let (img, src) = load_bytes(&f, 20).unwrap();
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
// without a JPEG: a clear error
let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err();
assert!(e.contains("without an embedded preview"), "{e}");
}
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
/// CR3 keeps its own wording.
#[test]
fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() {
use lightcraft_raw::RawError;
let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01";
assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string()));
assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip")));
assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok());
assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok());
assert!(preview_reason(b"x", RawError::NotRaw).is_err());
assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 ")));
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+393 -73
View File
@@ -33,7 +33,8 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
"gif", "bmp", "heic", "avif",
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -317,32 +318,96 @@ pub fn is_supported(path: &Path) -> bool {
}
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
/// is never descended into.
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
/// walk that stops at a bound is logged.
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
if (hidden && !top) || skip.is_some_and(|s| p == s) {
return;
expand_within(paths, skip, crate::walk::Limits::default()).0
}
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
let mut out = Vec::new();
let mut truncated = false;
for p in paths {
let p = Path::new(p);
if skip.is_some_and(|s| p == s) {
continue;
}
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
walk(&c, skip, out, false);
let w = crate::walk::files_in(p, skip, limits, is_supported);
if w.truncated {
log::warn!(
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
p.display(),
w.entries,
limits.max_entries,
limits.max_depth
);
}
} else if top || is_supported(p) {
truncated |= w.truncated;
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
} else {
// explicitly named files are attempted even with an unknown extension (sniffed)
out.push(p.to_string_lossy().to_string());
}
}
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), skip, &mut out, true);
}
let mut seen = std::collections::HashSet::new();
out.retain(|p| seen.insert(p.clone()));
out
(out, truncated)
}
/// What a path given on the command line at launch becomes ([`launch_path`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LaunchPath {
/// Import this (absolute) file or folder, in the background.
Import(String),
/// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never
/// walked without being chosen in the app (issue #374).
Refused { path: String, why: String },
}
/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`);
/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is
/// resolved against `cwd` — the working folder a launcher picked, often a drive root or
/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive
/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or
/// refused, and so is the home folder `home`: either would walk most of the disk.
pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option<PathBuf>) -> Option<LaunchPath> {
if arg.trim().is_empty() {
return None;
}
let given = Path::new(arg);
let joined = if given.is_absolute() {
given.to_path_buf()
} else {
match cwd {
Some(c) => c.join(given),
None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }),
}
};
// `.` and `..` resolved by name (no file-system access)
let mut abs = PathBuf::new();
for c in joined.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
abs.pop();
}
other => abs.push(other.as_os_str()),
}
}
let shown = abs.to_string_lossy().to_string();
if abs.parent().is_none() {
return Some(match dcim(&abs) {
Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()),
None => LaunchPath::Refused { path: shown, why: "a whole drive".into() },
});
}
let key = lightcraft_catalog::query::folder_key;
if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) {
return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() });
}
Some(LaunchPath::Import(shown))
}
/// Probe files (headers + content hash) as an import would.
@@ -365,7 +430,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 +469,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 +704,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 +806,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 +858,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 +909,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 +925,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 +961,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 +1385,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(_))));
}
}
+23
View File
@@ -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"!")?;
}
+8
View File
@@ -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;
@@ -38,6 +42,7 @@ pub mod segment;
pub mod sidecar;
pub mod smart;
mod view;
pub mod walk;
use std::sync::Arc;
@@ -186,6 +191,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 +281,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(),
+6 -1
View File
@@ -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> {
+685
View File
@@ -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
+450
View File
@@ -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
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 13;
pub const RENDER_CACHE_VERSION: u64 = 14;
/// 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];
+21 -2
View File
@@ -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
+13 -19
View File
@@ -83,28 +83,22 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String
Ok(out)
}
/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
/// Expand files/folders (recursively, bounded: see [`crate::walk`]) into preset files
/// (`.lcpreset`, `.xmp`).
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
fn walk(p: &Path, out: &mut Vec<String>, top: bool) {
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
walk(&c, out, false);
}
}
} else {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
out.push(p.to_string_lossy().to_string());
}
}
}
let preset_file = |p: &Path| {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str())
};
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), &mut out, true);
let path = Path::new(p);
if path.is_dir() {
let w = crate::walk::files_in(path, None, crate::walk::Limits::default(), preset_file);
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
} else {
out.push(p.clone());
}
}
out
}
+23 -8
View File
@@ -189,6 +189,12 @@ fn the_folder_filter_takes_only_a_text_path() {
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
}
/// Folders are compared by identity: a path read back may be spelled with the platform's separator.
fn same_folder(actual: Option<&str>, expected: &str, why: &str) {
let key = lightcraft_catalog::query::folder_key;
assert_eq!(actual.map(key), Some(key(expected)), "{why}: {actual:?} vs {expected}");
}
#[test]
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
let dir = Scratch::new("rename");
@@ -200,10 +206,10 @@ fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
assert_eq!(s.visible().len(), 2);
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
same_folder(s.filter.library_folder.as_deref(), &renamed, "the choice follows the folder");
assert_eq!(s.visible().len(), 2, "and still shows its photos");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
same_folder(s.filter.library_folder.as_deref(), &trip, "the choice is the same folder");
assert_eq!(s.visible().len(), 2);
}
@@ -217,10 +223,10 @@ fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
add(&mut s, &format!("{day1}/b.jpg"));
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
same_folder(s.filter.library_folder.as_deref(), &moved, "the subfolder follows its parent");
assert_eq!(s.visible().len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
same_folder(s.filter.library_folder.as_deref(), &day1, "the choice is the same folder");
}
#[test]
@@ -229,12 +235,21 @@ fn following_a_folder_reads_both_spellings_the_same_way() {
let mut s = Session::new();
s.filter.library_folder = Some("/a/x/../b/sub".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
same_folder(s.filter.library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent");
s.filter.library_folder = Some("/elsewhere/b".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
}
#[test]
fn following_a_folder_keeps_the_case_of_the_subfolder_names() {
// Windows compares folders in lower case; the name the user sees must not change with it
let mut s = Session::new();
s.filter.library_folder = Some("/A/Trip/Day1".into());
crate::cmd::browse::follow_folder(&mut s, "/A/Trip", "/B");
assert_eq!(s.filter.library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap()));
}
#[test]
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
let mut s = session();
@@ -296,10 +311,10 @@ fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
add(&mut s, &format!("{day1}/b.jpg"));
source_folder(&mut s, &day1);
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
same_folder(s.library_folder.as_deref(), &format!("{renamed}/day1"), "the subfolder follows its parent");
assert_eq!(s.visible().len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
same_folder(s.library_folder.as_deref(), &day1, "the choice is the same folder");
assert_eq!(s.visible().len(), 1);
}
@@ -308,7 +323,7 @@ fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
let mut s = Session::new();
s.library_folder = Some("/a/x/../b/sub".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
same_folder(s.library_folder.as_deref(), "/a/c/sub", "the shown folder reads both spellings alike");
}
#[test]
+98
View File
@@ -1007,3 +1007,101 @@ fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64)
let (s, src, id) = trashed_photo(tag);
(s, src, [id], 0)
}
/// Issue #374: launch arguments never turn into a walk of a whole drive or home folder. An empty
/// argument is ignored; `.`, `..` and (on Windows) `/` are resolved against the working folder a
/// launcher picked, which is often a drive root; a root is replaced by its memory card's DCIM
/// folder or refused, and so is the home folder.
#[test]
fn launch_paths_never_become_a_drive_root_or_the_home_folder() {
use crate::import::{LaunchPath, launch_path};
let root = if cfg!(windows) { Path::new("C:\\") } else { Path::new("/") };
let home = if cfg!(windows) { Path::new("C:\\Users\\me") } else { Path::new("/home/me") };
let none = |_: &Path| None;
let refused = |r: Option<LaunchPath>| matches!(r, Some(LaunchPath::Refused { .. }));
for empty in ["", " "] {
assert_eq!(launch_path(empty, Some(root), Some(home), none), None, "{empty:?}");
}
// the working folder is the drive root: `.` (and `..`) name the whole drive
for arg in [".", "..", "./", ".\\..", "/"] {
let arg = if cfg!(windows) { arg.to_string() } else { arg.replace('\\', "/") };
assert!(refused(launch_path(&arg, Some(root), Some(home), none)), "{arg:?}");
}
assert!(refused(launch_path("..", Some(&home.join("Pictures")), Some(home), none)), "up to the home folder");
assert!(refused(launch_path(&home.to_string_lossy(), None, Some(home), none)));
assert!(refused(launch_path(".", None, Some(home), none)), "relative, no working folder");
// a memory card's root: its camera folder
let dcim = |r: &Path| Some(r.join("DCIM"));
assert_eq!(launch_path(".", Some(root), Some(home), dcim), Some(LaunchPath::Import(root.join("DCIM").to_string_lossy().to_string())));
// ordinary folders and files resolve to absolute paths
let pics = home.join("Pictures");
assert_eq!(launch_path("Pictures", Some(home), Some(home), none), Some(LaunchPath::Import(pics.to_string_lossy().to_string())));
assert_eq!(
launch_path(&pics.join("a.jpg").to_string_lossy(), None, Some(home), none),
Some(LaunchPath::Import(pics.join("a.jpg").to_string_lossy().to_string()))
);
}
/// Issue #374: a folder import is bounded — a tree deeper or larger than the limits is not walked
/// to the end (it stops and says so), and what it found is still returned.
#[test]
fn expand_stops_at_the_walk_limits() {
let d = temp_dir("bounded");
let mut deep = d.clone();
for i in 0..8 {
deep = deep.join(format!("d{i}"));
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join(format!("p{i}.jpg")), b"x").unwrap();
}
let top = [d.to_string_lossy().to_string()];
let (all, cut) = crate::import::expand_within(&top, None, crate::walk::Limits::default());
assert_eq!((all.len(), cut), (8, false));
let (some, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 3, max_entries: 1000 });
assert_eq!((some.len(), cut), (2, true), "{some:?}");
let (few, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 32, max_entries: 5 });
assert!(cut && few.len() < 8, "{few:?}");
let _ = std::fs::remove_dir_all(&d);
}
/// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder
/// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only.
#[test]
fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let src = temp_dir("preview-only-exts");
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
// CRW / MRW / X3F: their magic, then the JPEG. IIQ / KDC / MOS / ERF: a TIFF whose IFD0 only points at the JPEG.
let tiff = {
let off = 8 + 2 + 24 + 4;
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&jpeg);
f
};
let with_magic = |magic: &[u8]| [magic, &[0x11u8; 64][..], &jpeg].concat();
let files: [(&str, Vec<u8>); 7] = [
("a.CRW", with_magic(b"II\x1a\0\0\0HEAPCCDR")),
("b.mrw", with_magic(b"\0MRM\0\x01\0\0")),
("c.x3f", with_magic(b"FOVb\x02\0\x02\0")),
("d.iiq", [&tiff[..], b"d"].concat()),
("e.kdc", [&tiff[..], b"e"].concat()),
("f.mos", [&tiff[..], b"f"].concat()),
("g.erf", [&tiff[..], b"g"].concat()),
];
for (name, bytes) in &files {
std::fs::write(src.join(name), bytes).unwrap();
}
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 7, "{r}");
assert_eq!(ids(&r, "imported"), 7, "{r}");
assert_eq!(ids(&r, "failed"), 0, "{r}");
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
let _ = std::fs::remove_dir_all(&src);
}
+67
View File
@@ -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);
}
+183
View File
@@ -0,0 +1,183 @@
//! Bounded folder walks (issue #374): every recursive listing of the disk — import, Local's
//! Include Subfolders, Find Missing Photos, preset and LUT folders, MCP — goes through
//! [`files_in`], which stops at a depth and a number of directory entries instead of walking a
//! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and
//! decides what is a folder from the directory listing itself (no extra open per entry, which
//! is what makes a large walk slow on Windows).
//!
//! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks
//! at startup or on the UI thread.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// How far a walk may go.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Folder levels below the starting folder that are listed (its own entries are level 1).
pub max_depth: usize,
/// Directory entries looked at in all (files and folders, hidden ones included).
pub max_entries: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { max_depth: MAX_DEPTH, max_entries: MAX_ENTRIES }
}
}
/// Folder levels a walk descends at most; photo trees are a handful of levels deep.
pub const MAX_DEPTH: usize = 32;
/// Directory entries a walk looks at at most: far more than any photo folder holds, far fewer
/// than a whole system drive.
pub const MAX_ENTRIES: usize = 1_000_000;
/// What a walk found.
#[derive(Debug, Default, PartialEq)]
pub struct Walked {
/// The files `keep` accepted, in path order, folder by folder (depth first).
pub files: Vec<PathBuf>,
/// The walk stopped at a [`Limits`] bound: there may be more files.
pub truncated: bool,
/// Directory entries looked at.
pub entries: usize,
}
fn hidden(p: &Path) -> bool {
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
let mut out = Walked::default();
// canonical folders reached through a link, so a link loop is walked at most once
let mut linked: HashSet<PathBuf> = HashSet::new();
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
if !is_dir {
if keep(&path) {
out.files.push(path);
}
continue;
}
if skip.is_some_and(|s| path == s) {
continue;
}
if depth >= limits.max_depth {
out.truncated = true;
continue;
}
let Ok(rd) = std::fs::read_dir(&path) else { continue };
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
if out.entries >= limits.max_entries {
out.truncated = true;
break;
}
out.entries = out.entries.saturating_add(1);
let p = e.path();
if hidden(&p) {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let dir = if ft.is_symlink() {
// a link (or a Windows junction): a folder is entered once, a file is a file
match std::fs::metadata(&p) {
Ok(m) if m.is_dir() => {
let Ok(c) = std::fs::canonicalize(&p) else { continue };
if !linked.insert(c) {
continue;
}
true
}
_ => false,
}
} else {
ft.is_dir()
};
children.push((p, dir));
}
children.sort();
let below = depth.saturating_add(1);
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-walk-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
let d = tmp("order");
for f in ["b/2.jpg", "a/1.jpg", "a/x/0.jpg", "c.jpg", ".hidden/h.jpg", "a/.h.jpg", "lib/l.jpg", "a/n.txt"] {
let p = d.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
let lib = d.join("lib");
let w = files_in(&d, Some(&lib), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
let rel: Vec<String> = w.files.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
assert_eq!(rel, ["a/1.jpg", "a/x/0.jpg", "b/2.jpg", "c.jpg"]);
assert!(!w.truncated);
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #374: a walk never runs on without end — a tree deeper than the limit, or with more
/// entries than the limit, stops and says so.
#[test]
fn deep_and_wide_trees_stop_at_the_limits() {
let d = tmp("bounds");
let mut deep = d.join("deep");
for i in 0..12 {
deep = deep.join(format!("l{i}"));
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("bottom.jpg"), b"x").unwrap();
let wide = d.join("wide");
std::fs::create_dir_all(&wide).unwrap();
for i in 0..50 {
std::fs::write(wide.join(format!("{i:02}.jpg")), b"x").unwrap();
}
let all = |_: &Path| true;
let w = files_in(&d.join("deep"), None, Limits { max_depth: 5, max_entries: 1000 }, all);
assert!(w.files.is_empty() && w.truncated, "{w:?}");
let w = files_in(&d.join("deep"), None, Limits { max_depth: 13, max_entries: 1000 }, all);
assert_eq!((w.files.len(), w.truncated), (1, false), "{w:?}");
let w = files_in(&wide, None, Limits { max_depth: 5, max_entries: 20 }, all);
assert_eq!((w.files.len(), w.entries, w.truncated), (20, 20, true));
let w = files_in(&wide, None, Limits::default(), all);
assert_eq!((w.files.len(), w.truncated), (50, false));
let _ = std::fs::remove_dir_all(&d);
}
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
fn link_loops_end() {
let d = tmp("loop");
std::fs::create_dir_all(d.join("a/b")).unwrap();
std::fs::write(d.join("a/b/p.jpg"), b"x").unwrap();
std::os::unix::fs::symlink(&d, d.join("a/b/up")).unwrap();
std::os::unix::fs::symlink(d.join("a"), d.join("alias")).unwrap();
let w = files_in(&d, None, Limits::default(), |_| true);
assert!(!w.truncated, "{w:?}");
assert!(w.files.len() <= 2, "{w:?}");
assert!(w.files.iter().any(|p| p.ends_with("a/b/p.jpg")), "{w:?}");
let _ = std::fs::remove_dir_all(&d);
}
}
+9 -19
View File
@@ -13,7 +13,8 @@ use crate::backend::Backend;
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
"bmp", "avif",
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
];
/// Headless backend: a [`Session`] with filesystem hooks.
@@ -139,31 +140,20 @@ impl Backend for Headless {
}
}
/// Expand folders (recursively, sorted) into photo files and make paths absolute.
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
/// and make paths absolute.
pub fn expand_paths(paths: &[String]) -> Vec<String> {
fn walk(p: &Path, out: &mut Vec<String>) {
if p.is_dir() {
if let Ok(rd) = std::fs::read_dir(p) {
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) {
walk(&c, out);
}
}
}
} else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) {
out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string());
}
}
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
walk(path, &mut out);
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
out.extend(w.files.iter().map(|f| absolute(f)));
} else {
// Explicit files are kept even with unknown extensions (the probe decides).
out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string());
out.push(absolute(path));
}
}
out
+239 -5
View File
@@ -83,6 +83,12 @@ pub enum RawFormat {
Rw2,
Pef,
Srw,
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
Crw,
/// Minolta MRW (`\0MRM` block container).
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
OtherTiff,
}
@@ -119,6 +125,15 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if bytes.starts_with(b"IIU\0") {
return Some(RawFormat::Rw2);
}
if bytes.starts_with(b"\0MRM") {
return Some(RawFormat::Mrw);
}
if bytes.starts_with(b"FOVb") {
return Some(RawFormat::X3f);
}
if bytes.len() >= 14 && bytes.starts_with(b"II") && &bytes[6..14] == b"HEAPCCDR" {
return Some(RawFormat::Crw);
}
if bytes.len() >= 10 && bytes.starts_with(b"II*\0") && &bytes[8..10] == b"CR" {
return Some(RawFormat::Cr2);
}
@@ -130,10 +145,7 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
return Some(RawFormat::Dng);
}
let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase();
let has_cfa = t.all_ifds().iter().any(|i| {
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770)
});
let has_cfa = has_raw_ifd(&t);
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2);
}
@@ -149,12 +161,103 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if make.starts_with("SAMSUNG") {
return Some(RawFormat::Srw);
}
if has_cfa {
if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) {
return Some(RawFormat::OtherTiff);
}
None
}
/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a
/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw).
fn has_raw_ifd(t: &Tiff) -> bool {
t.all_ifds().iter().any(|i| {
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770 || c == 99)
})
}
/// IFD0 that is not an image at all: no `ImageWidth` / `ImageLength`, but a JPEG preview pointer or
/// `SubIFDs` (the layout of Kodak KDC files, whose pictures sit in private tags). A plain TIFF reader
/// rejects such a file as malformed even though it carries a preview.
fn is_preview_container(ifd0: &lightcraft_tiff::Ifd) -> bool {
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, JPEG_INTERCHANGE_FORMAT, SUB_IFDS};
!ifd0.contains(IMAGE_WIDTH) && !ifd0.contains(IMAGE_LENGTH) && (ifd0.contains(JPEG_INTERCHANGE_FORMAT) || ifd0.contains(SUB_IFDS))
}
/// A TIFF whose first image is only a reduced copy of a picture stored somewhere else.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ThumbnailShell {
/// Size of the first image (IFD0).
pub thumb: (u32, u32),
/// Size of the picture it stands in for.
pub full: (u32, u32),
}
/// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing
/// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The
/// structure still gives them away, with a generic rule that names no maker or model: the file
/// declares a picture at least 4x larger than IFD0 in both directions, and either
/// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or
/// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside
/// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept
/// its old Exif size has no such block).
///
/// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose
/// IFD0 is the full image.
fn thumbnail_shell(t: &Tiff, len: usize) -> Option<ThumbnailShell> {
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION};
let ifd0 = t.ifds.first()?;
let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?);
if w0 == 0 || h0 == 0 {
return None;
}
let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0);
let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full });
let mut best: Option<(u32, u32)> = None;
for s in &ifd0.sub_ifds {
if s.image().is_ok()
&& let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH))
&& much_bigger((w, h))
&& best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh))
{
best = Some((w, h));
}
}
if let Some(full) = best {
return shell(full);
}
let exif = t.exif()?;
let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?);
if !much_bigger(full) {
return None;
}
// bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once)
let mut seen = std::collections::BTreeMap::new();
for ifd in t.all_ifds() {
if let Ok(info) = ifd.image() {
for c in info.chunks(len as u64) {
seen.insert(c.offset, c.len);
}
}
}
let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l));
let spare = (len as u64).saturating_sub(covered);
(spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?;
shell(full)
}
/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`].
fn other_tiff_reason(bytes: &[u8]) -> String {
let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok();
match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) {
Some(s) => format!(
"{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)",
s.full.0, s.full.1, s.thumb.0, s.thumb.1
),
None => "OtherTiff files are not decoded yet".to_string(),
}
}
/// Decode a raw file.
pub fn decode(bytes: &[u8]) -> Result<RawImage> {
decode_with(bytes, Mode::Full)
@@ -195,6 +298,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
RawFormat::Pef => vendor::pef::decode(bytes, mode),
RawFormat::Orf => vendor::orf::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
}
}
@@ -623,6 +727,136 @@ mod tests {
assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0);
}
// --- TIFF shells around a private raw block (thumbnail-only IFD0) ---
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
/// An uncompressed 8-bit RGB image IFD.
fn rgb_ifd(w: u32, h: u32) -> IfdBuilder {
let mut ifd = IfdBuilder::new();
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] });
ifd
}
fn exif_size(w: u32, h: u32) -> IfdBuilder {
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h]))
}
fn write(chain: &[IfdBuilder]) -> Vec<u8> {
TiffWriter::default().write(chain).unwrap()
}
const SHELL_REASON: &str = "raw image in a private block";
/// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw).
#[test]
fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() {
let mut ifd0 = rgb_ifd(16, 12);
ifd0.add_sub_ifd(rgb_ifd(64, 48));
let bytes = write(&[ifd0]);
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
// a sub-IFD that is not much bigger (a pyramid level) does not count
let mut ifd0 = rgb_ifd(16, 12);
ifd0.add_sub_ifd(rgb_ifd(32, 24));
assert_eq!(probe(&write(&[ifd0])), None);
}
/// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it.
#[test]
fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() {
let mut ifd0 = rgb_ifd(16, 12);
ifd0.set_child(t::EXIF_IFD, exif_size(400, 300));
let mut bytes = write(&[ifd0.clone()]);
// no private block: a downsized copy that kept its old Exif size is an ordinary image
assert_eq!(probe(&bytes), None);
// 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is
bytes.extend(std::iter::repeat_n(7u8, 14_000));
assert_eq!(probe(&bytes), None);
bytes.extend(std::iter::repeat_n(7u8, 2_000));
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
// truncating it never panics
for n in (0..bytes.len()).step_by(37) {
let _ = probe(&bytes[..n]);
let _ = decode(&bytes[..n]);
}
}
/// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger.
#[test]
fn ordinary_tiffs_are_not_shells() {
let padded = |chain: &[IfdBuilder]| {
let mut b = write(chain);
b.extend(std::iter::repeat_n(0u8, 100_000));
b
};
let mut full = rgb_ifd(400, 300);
full.set_child(t::EXIF_IFD, exif_size(400, 300));
assert_eq!(probe(&padded(&[full])), None);
// the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it
let mut near = rgb_ifd(120, 90);
near.set_child(t::EXIF_IFD, exif_size(400, 300));
assert_eq!(probe(&padded(&[near])), None);
// a multi-page TIFF whose first page is small
assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None);
// no dimensions at all
assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None);
}
// --- containers that are recognised but not decoded, with a preview ---
#[test]
fn private_containers_are_recognised_by_their_magic() {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
assert!(!f.is_supported());
}
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
}
/// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw
/// container with a preview; a TIFF reader would call it malformed.
#[test]
fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() {
let jpeg: Vec<u8> = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat();
let off = 8 + 2 + 24 + 4;
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&jpeg);
assert_eq!(probe(&f), Some(RawFormat::OtherTiff));
assert!(matches!(decode(&f), Err(RawError::Unsupported(_))));
assert_eq!(embedded_preview(&f), Some(jpeg));
// no pointer and no sub-IFDs: just a broken TIFF
let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]);
assert_eq!(probe(&g), None);
}
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
#[test]
fn private_compression_99_is_a_raw() {
let mut ifd = rgb_ifd(16, 12);
ifd.set(t::COMPRESSION, Value::Short(vec![99]));
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
}
#[test]
fn probe_rejects_junk() {
assert_eq!(probe(b""), None);
+173 -2
View File
@@ -91,9 +91,13 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
}
if crate::probe(bytes) == Some(crate::RawFormat::Cr3) {
let format = crate::probe(bytes);
if format == Some(crate::RawFormat::Cr3) {
return cr3_preview(bytes).map(|j| trim_eoi(j).to_vec());
}
if matches!(format, Some(crate::RawFormat::Crw | crate::RawFormat::Mrw | crate::RawFormat::X3f)) {
return scan_for_jpeg(bytes);
}
let tiff = Tiff::parse(bytes).ok()?;
let mut found: Vec<&[u8]> = Vec::new();
for ifd in tiff.all_ifds() {
@@ -117,7 +121,112 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
if let Some(p) = crate::vendor::orf::preview(bytes) {
found.push(p);
}
found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec())
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
}
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
/// walking the marker segments and the entropy-coded data. `None` when it is not a complete, displayable
/// (baseline or progressive) JPEG, so a stray `D8 FF` in other data is rejected. `*reach` is left at
/// the furthest byte looked at (what the walk cost; see [`scan_for_jpeg`]).
fn jpeg_extent(b: &[u8], soi: usize, reach: &mut usize) -> Option<(usize, u8)> {
let mut i = soi + 2;
let r = walk_jpeg(b, &mut i);
*reach = i;
r
}
fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> {
let mut components = None;
for _ in 0..4096 {
if *b.get(*i)? != 0xff {
return None;
}
while *b.get(*i)? == 0xff {
*i += 1;
}
let m = *b.get(*i)?;
*i += 1;
match m {
0xd9 => return components.map(|c| (*i, c)),
0x01 | 0xd0..=0xd8 => continue,
_ => {}
}
let len = usize::from(u16::from_be_bytes([*b.get(*i)?, *b.get(*i + 1)?]));
if len < 2 {
return None;
}
match m {
0xc0..=0xc2 => {
// precision, height, width, component count
let h = u16::from_be_bytes([*b.get(*i + 3)?, *b.get(*i + 4)?]);
let w = u16::from_be_bytes([*b.get(*i + 5)?, *b.get(*i + 6)?]);
if h == 0 || w == 0 || components.is_some() {
return None;
}
components = Some(*b.get(*i + 7)?);
}
0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf => return None, // lossless, arithmetic, hierarchical
_ => {}
}
*i += len;
if m == 0xda {
// entropy-coded data up to the next marker that is not a stuffed 0xff or a restart
loop {
*i += b.get(*i..)?.iter().position(|&x| x == 0xff)?;
match *b.get(*i + 1)? {
0x00 | 0xd0..=0xd7 => *i += 2,
0xff => *i += 1,
_ => break,
}
}
}
}
None
}
/// Last resort for the containers whose structure is not walked (Canon CRW, Minolta MRW, Sigma X3F, TIFF raws
/// with private blocks such as Leaf MOS or Epson ERF): the largest complete colour DCT JPEG stored as a plain
/// byte run in the file. A one-component JPEG is skipped (a Canon PowerShot CRW stores its sensor mosaic as one).
///
/// Evidence: in the Minolta and Epson files the first byte of the stored preview is not `ff` (it is `00`, `02` or
/// `ee`) while the rest is a regular JPEG, so a run is found by its `d8 ff` and starts one byte earlier,
/// with that byte restored to `ff`.
///
/// A crafted file can hold millions of JPEG-like starts whose walks each run to its end: the walks
/// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates
/// fail within a few bytes, or are the preview itself).
fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
let mut best: Option<(usize, usize)> = None;
let mut at = 1;
let mut budget = data.len().saturating_mul(8).max(1 << 20);
while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) {
let d8 = at + off;
at = d8 + 1;
if data.get(d8 + 1) != Some(&0xff) || !matches!(data.get(d8 + 2), Some(0xc0..=0xc4 | 0xdb | 0xe0..=0xef | 0xfe)) {
continue;
}
let start = d8 - 1; // `at` starts at 1, so d8 >= 1
let mut reach = start;
let found = jpeg_extent(data, start, &mut reach);
budget = budget.saturating_sub(reach.saturating_sub(start));
if budget == 0 {
break;
}
if let Some((end, 3)) = found {
if best.is_none_or(|(s, e)| end - start > e - s) {
best = Some((start, end));
}
at = end;
}
}
let (start, end) = best?;
let mut jpeg = data.get(start..end)?.to_vec();
if let Some(first) = jpeg.first_mut() {
*first = 0xff;
}
Some(jpeg)
}
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
@@ -242,4 +351,66 @@ mod tests {
file.truncate(at as usize + 100);
assert_eq!(embedded_preview(&file).unwrap(), small);
}
/// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes.
fn scan_jpeg(nc: u8, n: usize) -> Vec<u8> {
let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00];
j.extend(std::iter::repeat_n(8u8, 64));
j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]);
for c in 0..nc {
j.extend_from_slice(&[c + 1, 0x11, 0]);
}
j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]);
for c in 0..nc {
j.extend_from_slice(&[c + 1, 0]);
}
j.extend_from_slice(&[0, 63, 0]);
j.extend(std::iter::repeat_n(0x55u8, n));
j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]);
j
}
/// A crafted container full of JPEG-like starts (each walk running on through the ones after it)
/// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute).
#[test]
fn a_scan_through_many_fake_jpegs_stays_linear() {
let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02];
unit.extend(std::iter::repeat_n(0x55u8, 190));
let mut f = b"\0MRM\0\x01\0\0".to_vec();
for _ in 0..200_000 {
f.extend_from_slice(&unit);
}
let t = std::time::Instant::now();
assert_eq!(scan_for_jpeg(&f), None);
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
}
/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning.
#[test]
fn scanned_containers_give_the_largest_colour_jpeg() {
let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000));
let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"];
for head in heads {
let mut f = head.to_vec();
for part in [&small, &mosaic, &big] {
f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
}
}
// a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored
let mut f = b"\0MRM\0\x01\0\0".to_vec();
f.push(0x02);
f.extend_from_slice(&big[1..]);
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]));
// only a one-component JPEG, or none: nothing
let mut f = b"FOVb\x02\0\x02\0".to_vec();
f.extend_from_slice(&mosaic);
assert_eq!(embedded_preview(&f), None);
}
}
+59 -3
View File
@@ -103,21 +103,33 @@ pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result<Vec<u16>>
output.resize(count, 0);
}
for (i, &value) in decoded.iter().enumerate() {
let sensor_value =
value.checked_add(midpoint).filter(|&v| v >= 0 && v <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
let sensor_value = sensor_sample(value, midpoint, maximum, config.levels != 0)?;
let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2);
let slot = y
.checked_mul(width)
.and_then(|n| n.checked_add(x))
.and_then(|n| output.get_mut(n))
.ok_or_else(|| corrupt("tile writes outside image"))?;
*slot = sensor_value as u16;
*slot = sensor_value;
}
}
}
Ok(output)
}
/// A decoded plane value as a sensor sample of the CMP1 bit depth.
///
/// Lossless planes reproduce the sensor exactly, so a value outside the bit depth is corrupt data.
/// Wavelet (C-RAW) planes are quantized: next to clipped highlights the reconstruction overshoots
/// the sensor's range by up to a few quantization steps (measured on 17 CC0 files: at most 36 codes
/// above 16383 at 14 bits, never below 0), while lossless files of the same bodies clip at exactly
/// 16383. Those samples are clamped to the range the sensor can record.
fn sensor_sample(value: i32, midpoint: i32, maximum: i32, quantized: bool) -> Result<u16> {
let sample = value.checked_add(midpoint).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
let sample = if quantized { sample.clamp(0, maximum) } else { sample };
u16::try_from(sample).ok().filter(|_| sample <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))
}
/// Header-only probing must reject the same unsupported coding variants as full decoding.
pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> {
supports(config)?;
@@ -913,6 +925,50 @@ mod tests {
}
}
#[test]
fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() {
// As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200
// (Rice escape: 41 zeros, 1, 21-bit literal 16400 = signed +8200). Flat synthesis gives
// 8192 + 8200 = 16392, 9 codes above the 14-bit maximum, as in clipped C-RAW highlights.
let mut config = coding();
config.width = 16;
config.height = 16;
config.tile_width = 16;
config.tile_height = 16;
config.header_size = 540;
config.levels = 3;
let mut sample = Vec::new();
marker(&mut sample, 0xff01, 80, 0);
for plane in 0..4 {
marker(&mut sample, 0xff02, 20, (plane << 28) | 0x0800_0000);
for band in 0..10 {
marker(&mut sample, 0xff03, [8, 1, 1, 1, 1, 1, 1, 2, 2, 2][band as usize], (band << 28) | 0x0020_0000);
}
}
for _ in 0..4 {
sample.extend([0, 0, 0, 0, 0, 0x40, 0x80, 0x20]); // LL3 = +8200.
sample.extend([0x80; 3]); // The three 1×1 HF3 bands.
sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands.
for _ in 0..3 {
sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands.
}
}
validate(&sample, &config).unwrap();
assert_eq!(decode(&sample, &config).unwrap(), vec![16383; 256]);
}
#[test]
fn only_quantized_planes_clamp_out_of_range_samples() {
assert_eq!(sensor_sample(8200, 8192, 16383, true).unwrap(), 16383);
assert_eq!(sensor_sample(-8300, 8192, 16383, true).unwrap(), 0);
assert_eq!(sensor_sample(8191, 8192, 16383, false).unwrap(), 16383);
assert_eq!(sensor_sample(-8192, 8192, 16383, false).unwrap(), 0);
// Lossless planes reproduce the sensor exactly: outside the bit depth is corrupt data.
assert!(matches!(sensor_sample(8192, 8192, 16383, false), Err(RawError::Corrupt(_))));
assert!(matches!(sensor_sample(-8193, 8192, 16383, false), Err(RawError::Corrupt(_))));
assert!(matches!(sensor_sample(i32::MAX, 8192, 16383, true), Err(RawError::Corrupt(_))));
}
#[test]
fn measured_quantization_and_allocation_limits() {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
+35
View File
@@ -534,3 +534,38 @@ fn corpus_fujifilm_compressed_samples() {
}
eprintln!("verified {seen} Fujifilm compressed sensor arrays");
}
/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real
/// raws: every corpus file keeps the format its maker implies, and none is described as a private block.
#[test]
fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut checked = 0;
for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
let expected = match name.split(['-', '.']).next().unwrap_or_default() {
"arw" => RawFormat::Arw,
"cr2" => RawFormat::Cr2,
"cr3" => RawFormat::Cr3,
"dng" => RawFormat::Dng,
"nef" => RawFormat::Nef,
"nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs
"orf" => RawFormat::Orf,
"pef" => RawFormat::Pef,
"raf" => RawFormat::Raf,
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
_ => continue,
};
let bytes = std::fs::read(&p).unwrap();
assert_eq!(probe(&bytes), Some(expected), "{name}");
if let Err(RawError::Unsupported(why)) = probe_info(&bytes) {
assert!(!why.contains("private block"), "{name}: {why}");
}
checked += 1;
}
eprintln!("{checked} corpus raws keep their container");
}
+9
View File
@@ -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).",
+48 -2
View File
@@ -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",
@@ -658,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",
@@ -677,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",
@@ -693,7 +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",
"Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft",
"Instant ’70s": "Sofortbild der 70er",
"Interface": "Oberfläche",
"Interface language": "Sprache der Oberfläche",
@@ -760,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",
@@ -919,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.",
@@ -1046,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",
@@ -1090,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",
@@ -1103,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",
@@ -1116,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",
@@ -1156,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",
@@ -1182,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",
@@ -1197,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.",
@@ -1243,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",
@@ -1251,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",
@@ -1281,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",
@@ -1331,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",
@@ -1441,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",
@@ -1455,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 has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Diese Version enthält noch keine Downloadquelle für das Modell: Lege die Dateien selbst im obigen Ordner ab (siehe docs/ai-masks.md).",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
"Threshold": "Schwellenwert",
@@ -1483,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",
@@ -1561,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",
@@ -1748,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."
}
+191
View File
@@ -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
+9
View File
@@ -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}をリセット",
+48 -2
View File
@@ -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": "グループ名",
@@ -501,17 +510,24 @@
"Hours": "時間",
"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": "写真を調べる",
"Inspecting Lightroom Catalog": "Lightroom カタログを確認中",
"Instant ’70s": "インスタント’70s",
"Invert Component": "構成要素を反転",
"JPEG (Large)": "JPEG(大)",
@@ -532,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 +/−": "行数 +/−",
@@ -588,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": "名前付きの人物はまだいません",
@@ -617,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": "前の写真",
@@ -630,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": "境界を微調整",
@@ -650,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": "フォルダ名を変更",
@@ -658,6 +692,8 @@
"Rename Preview": "名前変更のプレビュー",
"Rename Template Tags": "名前変更テンプレートのタグ",
"Reorder Masks": "マスクを並べ替え",
"Reset All": "すべてリセット",
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
"Reshuffle": "シャッフルし直す",
"File name": "ファイル名",
"Fill Edges": "境界を補完",
@@ -1088,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": "復元する",
@@ -1129,6 +1166,7 @@
"Save as Smart Album…": "スマートアルバムとして保存…",
"Save as set": "セットとして保存",
"Save preset": "プリセットを保存",
"Saving Lightroom import index…": "Lightroom 読み込みインデックスを保存中…",
"Scale": "拡大率",
"Scan failed": "スキャンに失敗しました",
"Scanning": "検索中",
@@ -1136,6 +1174,7 @@
"Screen": "画面用",
"Search": "検索",
"Search Photos": "写真を検索",
"Search commands or keys": "コマンドまたはキーを検索",
"Seasons": "四季",
"Second Window": "第2ウィンドウ",
"Select": "選択",
@@ -1165,6 +1204,7 @@
"Set Develop Values": "現像設定を変更",
"Set Flag": "フラグを設定",
"Set Image Zoom and Pan": "画像のズームとパンを設定",
"Set Keyboard Shortcut": "キーボードショートカットを設定",
"Set Mask Adjustments": "マスクの調整を設定",
"Set Point Curve": "ポイントカーブを変更",
"Set Profile": "プロファイルを設定",
@@ -1214,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": "ファイル名を表示",
@@ -1311,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)",
@@ -1319,7 +1361,6 @@
"The photo has these settings": "この写真に適用されている設定",
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
"Thirds": "三分割",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "このビルドにはモデルのダウンロード元がまだありません。上のフォルダにファイルを手動で置いてください(docs/ai-masks.md を参照)",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません",
"This photo can't be opened": "この写真を開けません",
"Threshold": "しきい値",
@@ -1343,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",
@@ -1416,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": "あなたの名前",
@@ -1593,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",
+47 -1
View File
@@ -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",
@@ -623,6 +632,7 @@
"Horizontal": "Horizontal",
"Horizontal text": "Texto horizontal",
"Hours": "Horas",
"How to install the model": "Como instalar o modelo",
"Hue": "Matiz",
"Hue Range": "Faixa de matiz",
"ISO Speed": "Sensibilidade ISO",
@@ -631,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",
@@ -646,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",
@@ -662,7 +677,10 @@
"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",
"Interface language": "Idioma da interface",
@@ -727,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",
@@ -882,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.",
@@ -1006,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",
@@ -1050,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",
@@ -1063,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",
@@ -1076,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",
@@ -1116,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",
@@ -1139,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",
@@ -1154,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.",
@@ -1200,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",
@@ -1207,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",
@@ -1237,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",
@@ -1286,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",
@@ -1390,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)",
@@ -1401,7 +1445,7 @@
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Elas vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
"Thirds": "Terços",
"This Computer": "Este computador",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Esta versão ainda não tem um local de download para o modelo: coloque os arquivos você mesmo na pasta acima (veja docs/ai-masks.md).",
"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.": "Esta versão ainda não consegue baixar o modelo. Para instalá-lo manualmente, coloque model.safetensors, vocab.json e merges.txt do facebook/sam3 da Meta na pasta acima: Objeto e Descrever funcionam assim que eles estiverem lá.",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.",
"This photo can't be opened": "Não foi possível abrir esta foto",
"Threshold": "Limite",
@@ -1429,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",
@@ -1505,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",
+9
View File
@@ -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}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
+47
View File
@@ -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": "Жёсткая улица",
@@ -509,12 +518,15 @@
"History & Activity (Y)": "История и активность (Y)",
"Horizontal": "По горизонтали",
"Horizontal text": "Горизонтальный текст",
"How to install the model": "Как установить модель",
"Hue": "Тон",
"Hue Range": "Диапазон тонов",
"If file exists": "Если файл существует",
"Image": "Изображение",
"Import": "Импорт",
"Import Date": "Дата импорта",
"Import Lightroom Catalog": "Импорт каталога Lightroom",
"Import Lightroom Catalog…": "Импорт каталога Lightroom…",
"Import Photos": "Импорт фото",
"Import Photos…": "Импорт фото…",
"Import Point Curve Presets": "Импортировать пресеты точечной кривой",
@@ -528,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": "В памяти — ничего не сохраняется",
@@ -540,7 +555,10 @@
"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": "Интерфейс",
"Interface language": "Язык интерфейса",
@@ -596,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": "Линейный градиент",
@@ -724,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": "Новых пресетов нет",
@@ -826,6 +848,7 @@
"Presets & Profiles": "Пресеты и профили",
"Presets (Shift+P)": "Пресеты (Shift+P)",
"Presets ▾": "Пресеты ⌄",
"Press keys…": "Нажмите клавиши…",
"Preview": "Просмотр",
"Preview Build Progress": "Ход создания просмотров",
"Preview only": "Только просмотр",
@@ -864,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",
@@ -875,6 +908,7 @@
"Read mode": "Режим чтения",
"Read the SAM License": "Прочитать лицензию SAM",
"Reading": "Чтение",
"Reading Lightroom catalog…": "Чтение каталога Lightroom…",
"Reading folder…": "Чтение папки…",
"Recently Added": "Недавно добавленные",
"Recently Deleted": "Недавно удалённые",
@@ -885,6 +919,7 @@
"Red Primary": "Основной красный",
"Red Saturation": "Насыщенность красного",
"Red channel": "Красный канал",
"Red label": "Красная метка",
"Red/Cyan Fringe": "Красно-голубая кайма",
"Redo": "Повторить",
"Reference": "Эталон",
@@ -910,6 +945,7 @@
"Remove from Stack": "Убрать из стопки",
"Remove from survey": "Убрать из обзора",
"Remove location info": "Удалить сведения о местоположении",
"Remove shortcut": "Удалить сочетание",
"Remove this filter": "Убрать этот фильтр",
"Remove this group": "Убрать эту группу",
"Remove this rule": "Убрать это условие",
@@ -930,7 +966,9 @@
"Rendering": "Рендеринг",
"Rendering…": "Рендеринг…",
"Reset": "Сбросить",
"Reset All": "Сбросить всё",
"Reset All Curves": "Сбросить все кривые",
"Reset All Keyboard Shortcuts": "Сбросить все сочетания клавиш",
"Reset Before to Import State": "Сбросить «до» к состоянию при импорте",
"Reset Control": "Сбросить элемент управления",
"Reset Crop": "Сбросить кадрирование",
@@ -944,6 +982,7 @@
"Resize to": "Изменить размер до",
"Resolution (ppi)": "Разрешение (ppi)",
"Restore": "Восстановить",
"Restore (no shortcut)": "Восстановить (без сочетания)",
"Restore Library from Backup…": "Восстановить библиотеку из резервной копии…",
"Restore Version": "Восстановить версию",
"Retouch": "Ретушь",
@@ -983,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": "Второе окно",
@@ -1012,6 +1053,7 @@
"Set Crop": "Задать кадрирование",
"Set Develop Values": "Задать значения коррекции",
"Set Flag": "Задать флаг",
"Set Keyboard Shortcut": "Задать сочетание клавиш",
"Set Mask Adjustments": "Задать настройки маски",
"Set Point Curve": "Задать точечную кривую",
"Set Profile": "Задать профиль",
@@ -1053,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": "Показывать имена файлов",
@@ -1144,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-файл с его правками.",
@@ -1151,6 +1195,7 @@
"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.": "Эта сборка пока не умеет скачивать модель. Чтобы установить её вручную, положите model.safetensors, vocab.json и merges.txt из facebook/sam3 от Meta в папку выше: «Объект» и «Описать» заработают, как только файлы окажутся там.",
"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": "Порог",
@@ -1174,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": "Снять флаг",
@@ -1243,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}",
+48 -2
View File
@@ -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 全景合并预览",
@@ -507,12 +516,19 @@
"Hours": "小时",
"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": "在光标处插入",
"Inspect Lightroom Catalog": "检查 Lightroom 目录",
"Inspecting Lightroom Catalog": "正在检查 Lightroom 目录",
"Invert Component": "反相组件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
"Label": "标签",
@@ -524,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 +/−": "行数 +/−",
@@ -550,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": "还没有已命名的人物",
@@ -572,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": "仍然退出",
@@ -579,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": "细化边缘",
@@ -596,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": "填充边缘",
@@ -1144,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.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
@@ -1190,6 +1227,7 @@
"Save as set": "保存为集",
"Save as…": "另存为…",
"Save preset": "保存预设",
"Saving Lightroom import index…": "正在保存 Lightroom 导入索引…",
"Scale": "缩放",
"Scan failed": "扫描失败",
"Scanning": "正在扫描",
@@ -1197,6 +1235,7 @@
"Screen": "屏幕",
"Search": "搜索",
"Search Photos": "搜索照片",
"Search commands or keys": "搜索命令或按键",
"Search…": "搜索…",
"Seasons": "四季",
"Second Window": "第二个窗口",
@@ -1227,6 +1266,7 @@
"Set Develop Values": "设置编辑参数",
"Set Flag": "设置旗标",
"Set Image Zoom and Pan": "设置图像缩放和平移",
"Set Keyboard Shortcut": "设置键盘快捷键",
"Set Mask Adjustments": "设置蒙版调整",
"Set Point Curve": "设置点曲线",
"Set Profile": "设置配置文件",
@@ -1276,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": "显示文件名",
@@ -1379,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)",
@@ -1388,7 +1430,6 @@
"Theme": "主题",
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
"This photo can't be opened": "无法打开此照片",
"Threshold": "阈值",
@@ -1416,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",
@@ -1492,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": "你的名字",
@@ -1593,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}",
+48 -2
View File
@@ -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 全景合併預覽",
@@ -507,12 +516,19 @@
"Hours": "小時",
"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": "在游標處插入",
"Inspect Lightroom Catalog": "檢查 Lightroom 目錄",
"Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄",
"Invert Component": "反轉元件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
"Label": "標籤",
@@ -524,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 +/−": "行數 +/−",
@@ -550,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": "尚無已命名的人物",
@@ -572,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": "仍要結束",
@@ -579,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": "調整邊緣",
@@ -596,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": "填補邊緣",
@@ -1144,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.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
@@ -1190,6 +1227,7 @@
"Save as set": "儲存為關鍵字集",
"Save as…": "另存新檔…",
"Save preset": "儲存預設集",
"Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…",
"Scale": "縮放",
"Scan failed": "掃描失敗",
"Scanning": "正在掃描",
@@ -1197,6 +1235,7 @@
"Screen": "螢幕",
"Search": "搜尋",
"Search Photos": "搜尋照片",
"Search commands or keys": "搜尋命令或按鍵",
"Search…": "搜尋…",
"Seasons": "四季",
"Second Window": "第二視窗",
@@ -1227,6 +1266,7 @@
"Set Develop Values": "設定顯像參數",
"Set Flag": "設定旗標",
"Set Image Zoom and Pan": "設定影像縮放與平移",
"Set Keyboard Shortcut": "設定鍵盤快速鍵",
"Set Mask Adjustments": "設定遮色片調整",
"Set Point Curve": "設定點曲線",
"Set Profile": "設定描述檔",
@@ -1276,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": "顯示檔案名稱",
@@ -1379,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)",
@@ -1388,7 +1430,6 @@
"Theme": "主題",
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
"This photo can't be opened": "無法開啟此照片",
"Threshold": "臨界值",
@@ -1416,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",
@@ -1492,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": "你的名字",
@@ -1593,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 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
}
+14 -1
View File
@@ -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)
+6 -5
View File
@@ -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);
}
+5
View File
@@ -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),
];
+85 -3
View File
@@ -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();
+26 -5
View File
@@ -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);
+471
View File
@@ -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"]));
}
}
+31 -3
View File
@@ -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,
});
}
+42 -46
View File
@@ -99,6 +99,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
// (nothing to download from in this build: the dialog explains the manual install)
Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model",
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
Dialog::About => "About LightCraft",
Dialog::Shortcuts => "Keyboard Shortcuts",
@@ -778,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| {
@@ -829,7 +788,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let sam = &app.session.segmenter;
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
// no download location in this build: nothing to offer but the manual install
let sam_nowhere = !sam_installed && !sam_running && sam.mirrors().is_empty();
let sam_nowhere = sam_by_hand(sam);
let cancel = match &dlg {
Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close",
Dialog::SamModel { .. } => "Not Now",
@@ -880,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;
}
@@ -925,6 +885,22 @@ pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool {
matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed()
}
/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is
/// all the dialog can offer.
fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool {
!sam.installed() && !sam.download_status().running && sam.mirrors().is_empty()
}
/// The model installation guide.
const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model";
/// Show the SAM 3 model folder in the file manager (created first, so there is something to show).
fn show_model_folder(app: &mut LightcraftApp, dir: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
reveal(&dir.to_string_lossy())
}
/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress.
fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) {
use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES};
@@ -971,12 +947,32 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str
return;
}
if seg.mirrors().is_empty() {
// nothing to download from: say how to install it by hand, with the folder and the guide a click away
let dir = seg.dir.clone();
ui.label(
egui::RichText::new(crate::i18n::tr(
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).",
"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.",
))
.color(t.text_dim),
);
ui.horizontal_wrapped(|ui| {
if let Some(dir) = dir.filter(|_| app.services.reveal.is_some()) {
let r = ui.button(crate::i18n::tr(crate::menus::reveal_label()));
crate::widgets::register(ui.ctx(), "button:samFolder", r.rect);
if r.clicked()
&& let Err(e) = show_model_folder(app, &dir)
{
app.toast(ui.ctx(), e);
}
}
let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP);
crate::widgets::register(ui.ctx(), "link:samHelp", r.rect);
if r.clicked()
&& let Err(e) = crate::links::open(app, SAM_HELP)
{
app.toast(ui.ctx(), e);
}
});
}
if let Some(e) = error.map(str::to_string).or(d.error) {
ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80)));
+224
View File
@@ -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),
}
}
+10 -4
View File
@@ -56,8 +56,11 @@ fn open(app: &mut LightcraftApp, path: Option<String>) {
Ok(serde_json::Value::Null) => {} // folder dialog cancelled
Ok(_) => {
let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default();
if !files.is_empty() {
let _ = app.run("library.import", json!({"paths": files}));
// in the background, like files dropped on the window (issue #374)
if !files.is_empty()
&& let Err(e) = crate::import::start_paths(app, files)
{
log::warn!("import: {e}");
}
}
Err(e) => {
@@ -144,8 +147,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
p.dismissed = true;
}
let files = app.library_problem.as_mut().map(|p| std::mem::take(&mut p.pending_import)).unwrap_or_default();
if !files.is_empty() {
let _ = app.run("library.import", json!({"paths": files}));
// in the background, like files dropped on the window (issue #374)
if !files.is_empty()
&& let Err(e) = crate::import::start_paths(app, files)
{
log::warn!("import: {e}");
}
}
Some(Choice::Quit) => app.ui.quit = true,
+85 -23
View File
@@ -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);
}
}
+1
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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]
+3
View File
@@ -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(),
}
}
}
+148
View File
@@ -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);
}
@@ -75,6 +75,8 @@ fn unopenable_library_asks_instead_of_running_a_demo() {
assert!(!has(&h, "button:libraryRetry"));
assert!(has(&h, "indicator:temporarySession"));
assert!(h.app.session.library.is_none());
// imported on the import worker (issue #374: never synchronously on the UI thread)
h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1);
assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session");
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T);
+14
View File
@@ -649,6 +649,20 @@ fn ai_masks_without_the_model_offer_the_download() {
// no location configured in this build: no Download button (only Close), and an agent
// confirming anyway gets the reason; the dialog stays
h.step();
// …but a way to install it by hand: the guide, and the model folder (created on demand)
assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link");
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let log = shown.clone();
h.app.services.reveal = Some(Box::new(move |p: &str| {
log.lock().unwrap().push(p.to_string());
Ok(())
}));
h.step();
let r = h.request("ui.clickWidget", json!({"id": "button:samFolder"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(dir.is_dir(), "the model folder is created to be shown");
assert_eq!(*shown.lock().unwrap(), vec![dir.to_string_lossy().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], false, "{r}");
let r = h.request("ui.dialog.confirm", json!({}), T);
@@ -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:?}");
}
}
}
+22 -1
View File
@@ -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);
@@ -377,7 +397,8 @@ fn a_renamed_folder_keeps_its_place_in_the_tree() {
let _cleanup = Cleanup(base.clone());
std::fs::create_dir_all(base.join("pics/trip")).unwrap();
std::fs::create_dir_all(base.join("pics/home")).unwrap();
let b = base.to_string_lossy().to_string();
// the folder tree writes paths with forward slashes, and its widget ids carry them
let b = base.to_string_lossy().replace('\\', "/");
let mut h = folders_app(&[&format!("{b}/pics/trip/a.jpg"), &format!("{b}/pics/home/b.jpg")]);
h.app.session.source = lightcraft_engine::LibrarySource::LibraryFolder;
h.app.session.library_folder = Some(format!("{b}/pics/trip"));
+9
View File
@@ -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
+3 -1
View File
@@ -80,7 +80,9 @@ The model is fetched from an ordered list of mirrors: base URLs where `<base>/mo
> **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist
> (see the `TODO(maintainer)` there): add them in order of preference, host the three files
> unchanged, and pin `vocab.json` / `merges.txt` (size + SHA-256) in `SAM3_FILES` at the same
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so.
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so:
> it names the three files to put in the model folder by hand, with a button that shows that folder
> (created if needed) and a link to this guide.
Hugging Face's `facebook/sam3` can't be a default: it is **gated** (each person must accept the
licence there, wait for approval and download with their own token). Someone with access can
+1 -1
View File
@@ -56,7 +56,7 @@ The additional supplied folder contains 748 RAFs. Profiles were fitted separatel
For validation, every fifth usable photo of each model in sorted path order was withheld. Profiles trained on the remaining 160 X-H2S and 436 X-T4 photos were tested on 41 and 109 withheld photos. Mean CIE76 ΔE against the camera JPEG was 9.86 → 1.70 with per-file fitting → 1.62 with the pooled X-H2S profile, and 13.64 → 2.43 → 2.34 for X-T4. The profiles improved 108 of 150 files; the largest regression against per-file fitting was 0.60 ΔE, with no previously accepted fit rejected. These are withheld files from the same limited scene collection, not independent coverage of all subjects or illuminants.
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). One public X-T20 reference still fails the gates and keeps the fallback.
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). The public X-T20 reference fails the gates on all pixels (held-out RMS 0.114) and passes on the second attempt away from edges (0.056, from 0.185 uncorrected), so it is fitted too.
RAF inputs also work with `lightcraft-cli calibrate`. Tests cover real X-Trans/16-bit Bayer fits, profile use on dull scenes, per-file fitting after profile rejection, monochrome rejection, resolution independence, header/decoded relative WB, old-catalog WB transitions and invalid RAF calibration inputs. A full-size 6240×4160 X-T4 JPEG export also succeeded.
+5 -2
View File
@@ -22,8 +22,11 @@ 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
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
its reconstruction can overshoot the sensor range by a few quantization steps
(up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the
CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data.
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
+59
View File
@@ -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.
+47
View File
@@ -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 -2
View File
@@ -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
+14 -16
View File
@@ -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
@@ -127,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. 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-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) |
@@ -138,7 +135,7 @@ 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 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-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), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", 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 |
@@ -344,7 +341,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand; see docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
@@ -484,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 | ⬜ | | |
@@ -520,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 | ⬜ | | |
@@ -731,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 |
+21 -2
View File
@@ -9,6 +9,11 @@
- `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
@@ -17,11 +22,23 @@
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.
- Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet
now import as "preview only" with their embedded JPEG instead of failing. A raw whose data is damaged but whose
preview is intact does the same.
- Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS
TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode
yet and import as "preview only", with the reason.
- 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.
@@ -47,10 +64,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`)