Compare commits

..
Author SHA1 Message Date
Brandon Thomas ec0cda9a6f Merge pull request #2486 from storytold/release-0.11.0
Release 0.11.0
2026-10-21 14:05:52 -04:00
Brandon ThomasandClaude Opus 5.5 79d916c149 Release 0.11.0
The web release now builds with craft-fonts too (web-craft-fonts), as VectorCraft's
does, so the published web app ships the Chinese and Japanese faces docs/releasing.md
promises.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 14:05:43 -04:00
aiongg e142e2ffa4 Merge PR #2470: integration of 43 reviewed PRs (round 1) 2026-10-21 12:59:32 -04:00
aiongg 22ae8bc9a8 Integration: #2280 drop a clone of the Copy crop in its test 2026-10-21 15:04:38 +00:00
aiongg 21903294bf Merge track r1-rawcat into next
crates/ui-egui/locales/{cs,it,pl}-formats.json: kept the translations and added the track's new keys.
docs/parity-checklist.md: newer header line; revision rows from both sides.
docs/whats-new.md: both tracks' 2026-10-15 entries.
2026-10-21 14:58:55 +00:00
aiongg 69fcc25535 Revert "Merge PR #995: LR-VIEW-CLIPPING: ⌥-drag previews for Blacks, Whites, Exposure, Sharpening and NR (#937)"
Held for the owner: #995 and #999 overlap, and #995's author offered on 10-12 to
close #995 once his commit was in #999. docs/parity-checklist.md: header keeps the
#1485 note and drops the #995 part.
2026-10-21 14:56:17 +00:00
aiongg a1455fa7cd Merge PR #1988: #1507: web start-up treats only NotFoundError as a missing file
apps/lightcraft-web/src/files.rs: keeps #1987's and #1988's tests
docs/whats-new.md: keeps both 2026-10-15 entries
2026-10-21 14:56:12 +00:00
aiongg e9e44643a5 Revert "Integration: #995 time drag drafts only while the GPU draws the view"
This reverts commit 5aa5c6681f.
2026-10-21 14:55:59 +00:00
aiongg 697d201e97 Merge PR #1987: #1508: web backup refuses to start while changes are unsaved 2026-10-21 14:55:55 +00:00
aiongg 047e9bb962 Integration: #1924 a fresh re-import keeps its own stem sidecar 2026-10-21 14:55:45 +00:00
aiongg 5443225c3c Merge track r1-i18nci into next 2026-10-21 14:54:41 +00:00
aiongg 4e581739bd Merge PR #1924: Do not read a sibling's shared stem sidecar on import (#1905)
docs/parity-checklist.md: header keeps the newer 2026-10-18 change
2026-10-21 14:54:19 +00:00
aiongg 523894b00b Merge PR #1998: LR-BEHAV-BG-TASKS: Reload from Disk reads its files in the background
docs/parity-checklist.md: header names #1998's change
2026-10-21 14:54:08 +00:00
aiongg a44a2eeb1d Merge track r1-ui into next
docs/whats-new.md: kept both tracks' entries, newest first, one 2026-10-18 heading.
2026-10-21 14:54:06 +00:00
aiongg c1c9f94094 Merge PR #842: Read large Lightroom catalogs by page
crates/engine/src/lightroom_catalog.rs: test helpers chained; native_catalog_rows takes #842's last_page argument, native_catalog_with_last_table_page builds on it
docs/parity-checklist.md: LRC-LIB-CATALOG-IMPORT keeps both texts
2026-10-21 14:53:52 +00:00
aiongg 73ac15ddaf Merge PR #2181: web: Auto language follows the browser's languages (#2085)
#2089 proposed the same fix for #2085 earlier.
2026-10-21 14:53:26 +00:00
aiongg 06a5c2edb7 Merge PR #2244: fonts: Georgian UI text from craft-fonts' Noto Sans Georgian (release builds) 2026-10-21 14:53:17 +00:00
aiongg 80a7b8bba8 Merge PR #2230: Lightroom import: skip images whose file record is missing instead of failing 2026-10-21 14:53:06 +00:00
aiongg 25feff7182 Merge PR #2294: Write ISOSpeed extension tag for ISO above 65534 in write_exif 2026-10-21 14:53:02 +00:00
aiongg 83ac0bca70 Merge PR #1923: LR-IMP-FORMATS: CRX high-frequency band width from the tile geometry … 2026-10-21 14:53:02 +00:00
aionggandEmpressAthleticus 4cf0b3472e Integration: #2003 decode test for real lossy tiles (from #1994)
Co-authored-by: EmpressAthleticus <[email protected]>
2026-10-21 14:52:59 +00:00
aiongg 47be297b69 Merge PR #2258: i18n(cs): bring the Czech catalog up to date 2026-10-21 14:52:57 +00:00
aiongg 3ac7034ef5 Merge PR #2238: LR-BEHAV-LOCALIZE: translate AI Denoise progress and face-model hints into zh-hans 2026-10-21 14:52:57 +00:00
aiongg 8424779044 Merge PR #1869: i18n(pl): menu command names, the formats left in English, and Vibrance as Jaskrawość 2026-10-21 14:52:57 +00:00
aiongg aee07a0376 Merge PR #1871: i18n(it): native-speaker corrections and every interface string translated 2026-10-21 14:52:57 +00:00
aiongg 452f317d38 Merge PR #1993: macOS bundle: drop Info.plist types LightCraft cannot open (#1951) 2026-10-21 14:52:57 +00:00
aiongg e68a0e753d Merge PR #2334: the slideshow-template cache fix and contributor entry only
Its store.rs, tests_export.rs and tests_folder_drop.rs hunks are covered by #1975 and #1967; resolved to the merged version.
2026-10-21 14:52:57 +00:00
aiongg 598a6ecb9b Merge PR #2004: the tests_offline bounded wait only
Its tests_export.rs and tests_folder_drop.rs hunks are covered by #1975 and #1967; resolved to the merged version.
2026-10-21 14:52:48 +00:00
aiongg 142eca0970 Merge PR #1975: Tests: fix the 11 Windows-only test failures on main 2026-10-21 14:52:38 +00:00
aiongg 6e893fdaa3 Merge PR #1967: CI: catalog's staging-link test module compiles only on Unix (fixes clippy -D warnings on Windows) 2026-10-21 14:52:38 +00:00
aiongg 32791d4bb4 Merge PR #2003: Lightroom lossy DNGs: decode their JPEG XL tiles and render them like Lightroom 2026-10-21 14:52:36 +00:00
aiongg 9dd811de5d Merge PR #2272: #2266: a presets.json that can't be written counts as unsaved
docs/whats-new.md: entry added under the existing 2026-10-18 heading
2026-10-21 14:52:05 +00:00
aiongg 6196ebb968 Merge PR #2280: #2253: crop ratios keep the orientation asked for
docs/parity-checklist.md: LR-CROP-ASPECT keeps #2253's orientation note and #991's fresh-crop lock
docs/whats-new.md: 2026-10-18 entry placed between 2026-10-19 and 2026-10-15
2026-10-21 14:51:48 +00:00
aiongg 0a27b0d804 Merge PR #1990: #1927: Constrain Crop tightens the stored crop
docs/whats-new.md: both entries kept, 2026-10-19 above 2026-10-15
2026-10-21 14:51:24 +00:00
aiongg 7c3aed08fa Merge PR #2314: #2114: hidden Local locations come back one at a time 2026-10-21 14:51:12 +00:00
aiongg 0899915bdc Merge PR #1485: LR-VIEW-ZOOM: drag-scale follow-ups from #1009 review; slow verdicts expire (#330)
docs/parity-checklist.md: header keeps both changes, dated 2026-10-20
2026-10-21 14:51:12 +00:00
aiongg 5aa5c6681f Integration: #995 time drag drafts only while the GPU draws the view 2026-10-21 14:50:55 +00:00
aiongg 70e6b02569 Merge PR #995: LR-VIEW-CLIPPING: ⌥-drag previews for Blacks, Whites, Exposure, Sharpening and NR (#937) 2026-10-21 14:50:38 +00:00
aiongg 346a5be628 Integration: #2012 fit the hover preview to the drawn size; keep the zoomed whole frame canvas-sized 2026-10-21 14:50:35 +00:00
aiongg 30b6d3160c Merge PR #2067: LR-MASK-OBJECTS: SAM 3 falls back to the CPU when the GPU can't run it (#1974) 2026-10-21 14:49:23 +00:00
aiongg 5e5f1259ad Merge PR #1963: M12: Describe selections from SAM 3's semantic mask: sure inside, no lines between instances 2026-10-21 14:49:23 +00:00
aiongg 26c1c2b1b0 Merge PR #2273: #2242: reverseGeocode maxDistance, and the 15 000-people limit is documented 2026-10-21 14:49:23 +00:00
aiongg 087a82e88a Merge PR #1966: LR-EXP-BITDEPTH: app.export refuses a bitDepth the format doesn't have 2026-10-21 14:49:23 +00:00
aiongg 8ec24dc868 Integration: #2180 keep the pixel limit as the only size cap 2026-10-21 14:49:18 +00:00
aiongg 1a4d81fd5a Merge PR #2180: raw, codecs: checked dimension products so wasm32 cannot wrap the size gates (#2138) 2026-10-21 14:49:01 +00:00
aiongg e13e6eb6c4 Merge PR #1892: Masks: bound the total dabs of one brush stroke (#1520) 2026-10-21 14:49:01 +00:00
aiongg 7760d71655 Merge PR #2044: Reject impossible hour/minute/second in normalize_iso 2026-10-21 14:49:01 +00:00
aionggandeliets2 fcd0832355 Integration: #2152 tool descriptions from #2171
Co-authored-by: eliets2 <[email protected]>
2026-10-21 14:48:57 +00:00
aiongg 145095663b Merge PR #2152: MCP: mark file-writing tools as potentially destructive 2026-10-21 14:48:49 +00:00
aiongg 893fdffc75 Merge PR #2145: Stop export lanes when a panic unwinds the batch thread 2026-10-21 14:48:49 +00:00
aionggandronibalthazar 7f9f97148d Integration: #2178 document the bound (from #2157)
Co-authored-by: ronibalthazar <[email protected]>
2026-10-21 14:48:45 +00:00
aiongg a4cbdb4937 Merge PR #2012: Detail: Fit is as sharp as the photo 2026-10-21 14:48:42 +00:00
aiongg af5b7bc3c0 Merge PR #2274: Draw the Before label on a dark pill so it reads on light photos (#2247) 2026-10-21 14:48:33 +00:00
aiongg b1b9463e93 Merge PR #2285: LR-LIB-STACK: a double-click on a stack badge toggles the stack once (#2233) 2026-10-21 14:48:33 +00:00
aionggandharanishi fcbbda591c Merge PR #1864: Crop: a fresh crop starts locked to the photo's own shape (#991)
Co-authored-by: haranishi <[email protected]>
2026-10-21 14:48:33 +00:00
aionggandharanishi 1c4b9c3c61 Integration: #1863 fill the active Pick filter too (from #1044)
Co-authored-by: haranishi <[email protected]>
2026-10-21 14:48:33 +00:00
aiongg 9bac4fbd88 Merge PR #2178: tiff: bound total entries across a parse (#2136) 2026-10-21 14:48:28 +00:00
aiongg 265b35a975 Merge PR #2101: Reject malformed mask and spot targets before mutation 2026-10-21 14:48:28 +00:00
aiongg df9d830189 Merge PR #2100: Reject fractional command indices before mutation 2026-10-21 14:48:28 +00:00
aionggandharanishi cf2bfa4ece Merge PR #1863: Library: a picked photo's flag is drawn filled (#1013)
Co-authored-by: haranishi <[email protected]>
2026-10-21 14:48:21 +00:00
aiongg 31dac2db32 Merge PR #2069: Keep the profile dropdown and its submenus inside the window (#2059) 2026-10-21 14:48:21 +00:00
Marco Arnulfo 3ac21d18a5 LR-EXP-BITDEPTH: savePreset from the previous export drops a stored depth its format lacks 2026-10-21 12:40:04 +02:00
Dylan M. Taylor abb49bdd76 ci: switch to shared nix workflow from craft-common-actions (#1980) 2026-10-21 02:38:14 -04:00
Brandon ThomasandClaude Opus 5.5 5fe74557ec Skip CodeRabbit reviews on Dependabot PRs (#2407)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:38:50 -04:00
Dylan M. Taylor 666f78432b docs: add SonarCloud and CodeRabbit badges to README (#2240)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-20 11:31:44 -04:00
fabiodalez-dev b9c9a97d7a i18n(it): translate the mask overlay views 2026-10-20 16:11:56 +02:00
fabiodalez-dev fa135940e7 i18n(it): native-speaker corrections and every interface string translated
Corrections from my review of #1198: Exposure is Esposizione (not Tempo di esposizione, the shutter time), Sharpening is Nitidezza (not Contrasto), Preset stays Preset in every preset label, and blurry photos are sfocate, not mosse.

Adds the 339 interface messages Italian lacked (print, slideshow, mask effects, faces, HDR, external editors, keywords, focus points and their dialogs) and translates the 60 format strings still in English in it-formats.json. Only the What's New lines stay in English, as before: 2,628 of 3,278 messages.
2026-10-20 16:08:27 +02:00
Matthew James SivakandClaude Opus 5.5 30fea8d34c LR-VIEW-CLIPPING: profiled diagnostic views keep the sRGB histogram; GPU equivalence for the clipping overlays
On a profiled display the Grey and Sharpening-mask views rendered in the display's primaries, so
their histogram counted display-primary pixels, not the photo's sRGB ones. They now render in sRGB
like the clipping views (`Overlay::renders_in_srgb`) and are converted for the display after; they
show no photo colour, so nothing is lost.

The GPU equivalence test now covers Grey (mean/max LSB limits) and the shadow/highlight clipping
views (share of differing pixels, as for Visualize Spots: both renderers draw them with the same
`visualize::apply`, and a 1-LSB difference at 0/255 flips a pixel).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:14:16 +02:00
Matthew James SivakandClaude Opus 5.5 f08522b8a9 LR-VIEW-ZOOM: the 20 ms rule clears a slow verdict only on full-size drags (docs)
Only a drag timed at full size (median of its full-size drafts, after the
first, at most 20 ms) forgets a slow-drag verdict; a drag that drafts small
from its start, or times fewer than two full-size drafts, leaves it unchanged,
as `DragScale::finish` does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 12:56:48 +02:00
Davit MatchakhelidzeandClaude Opus 5.5 2015f44c06 Release: keep the web build without craft-fonts for now (its WASM would pass Cloudflare's 25 MiB limit); docs say so
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-20 12:56:17 +04:00
Brandon ThomasandClaude Opus 5.5 a2a41383c4 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:04 -04:00
mahmatias 4c4f4ca215 Contributors: add mahmatias 2026-10-19 14:02:35 -03:00
mahmatias 54c6505191 test(slideshow): drop the template cache after the hand-written bad files
The folder's mtime may not move from the save's listing to the list that follows (same clock tick on a fast machine), so the stale cache hid the bad files and problems came back empty - deterministic on a warm build. The test drops the cache as its own wild.json case already did (and as templates_changed documents).
2026-10-19 14:02:35 -03:00
mahmatias e3d2654bd2 fix(tests): make Windows path spellings match folder-tree widget ids
The folder tree writes FolderNode.path with forward slashes on every platform, but tests_folder_drop built its widget ids from to_string_lossy, which gives backslashes on Windows: the 8 tests never found their rows there. row_path() normalizes the way tests_sync already does. Same class of fix in tests_export (path comparison) and an unused-import guard in catalog's staging tests (the module's only test is unix-only).
2026-10-19 14:02:29 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 04e11e701a docs: Georgian draws with craft-fonts' Noto Sans Georgian; the web WASM keeps the Japanese, Thai and Georgian faces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-19 18:13:10 +04:00
Davit Matchakhelidze 97ef4f7ecc Merge main 2026-10-19 18:13:03 +04:00
Claude 3a52cbe464 #2114: the headless test clicks Restore All and the single-place row
The test checked that Restore All was listed but clicked it only when the
machine already had a hidden location, so CI never exercised that path.
It now hides two places and restores them with Restore All, then hides
one and checks the row restores it directly, without a list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 13:41:05 +00:00
Claude 4b8a5317fc #1508: a refused backup from the menu says why
The menu bar turned a command's error into a toast only for exports, so
File ▸ Back Up Library… did nothing visible when the host refused (changes
still unsaved, or nothing stored in this browser); the refusal reached only
the control channel (found in a real-browser check of PR #1987). Backup and
restore refusals are toasted too. Menu items are registered for automation
as `menuitem:<command id>`, and a headless test clicks File ▸ Back Up
Library… against a refusing host and reads the toast.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 12:39:25 +00:00
Claude e2ea657668 #2114: hidden Local locations come back one at a time
"Show N hidden locations" put every location removed from Local back at
once. With several hidden, the row now lists them (name, path on hover):
each comes back on its own, or all with Restore All. With one hidden, the
row restores it directly, as before. The command side already had
local.restoreHidden {path}; only the panel called it without a path.

The headless test hides a second place, opens the list from the row,
restores one, checks the other stays hidden, then restores the rest.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 11:29:48 +00:00
Claude d4627e9c26 #1507: dropping a Session no longer panics on the web
`face_download::Downloads::drop` gave a running download a second to stop,
with `Instant::now()` and `thread::sleep`, which panic on wasm32 ("time not
implemented on this platform"). The web host replaces its Session when the
library can't be opened, so with this branch an unreadable `catalog.log`
killed the page with the generic panic screen instead of showing the
library-problem window (found in a real-browser check of PR #1988). The wait
is skipped on wasm32, where there are no download threads; the cancel flags
are still set. Native test: an idle Downloads drops at once.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 09:01:22 +00:00
Claude 5f17b48c8d #2253: crop.aspect checks orientation before the current/toggle paths
A bad `orientation` was refused only on the refit path: "current" and an
unlocked "toggle" returned before the check, so {"aspect": "current",
"orientation": "bogus"} succeeded silently, and a value that isn't a string
fell back to the default. It is parsed first now, so every path refuses a
wrong word, a number, a boolean or null, and nothing changes (CodeRabbit on
PR #2280).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 08:49:40 +00:00
Matthew James SivakandClaude Opus 5.5 58c38ead2f LR-VIEW-ZOOM: describe the slow-drag verdict as per photo and control
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:13:51 +02:00
Matthew James SivakandClaude Opus 5.5 0360f940e3 Merge main into lightroom-jxl-dng
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:33:20 +02:00
NetterSilverandClaude Haiku 5.5 a3d00301cf Write ISOSpeed extension tag for ISO above 65534 in write_exif
Found while testing crates/meta/src/exif.rs:455 (write_exif)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 05:16:03 +00:00
chordpli de98d3a1ba LR-LIB-STACK: the badge double-click test checks the second click lands on the badge (#2233) 2026-10-19 09:28:31 +09:00
chordpli 266e668376 LR-LIB-STACK: a double-click on a stack badge toggles the stack once (#2233)
A click on a grid stack badge expands or collapses the stack. A double-click,
as in Lightroom, toggled it twice: the second click undid the first and the
stack stayed as it was. The badge now ignores the second click of a
double-click.
2026-10-19 09:05:04 +09:00
FrancoisandClaude d999e382ba #1949: pt-BR for Include selected photos and the mask overlay modes (#2033)
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK

Co-authored-by: Claude <[email protected]>
2026-10-18 16:59:33 -06:00
Claude 81f02a6947 #2253: crop ratios keep the orientation asked for
set_aspect always turned the ratio to the image's orientation, so a Custom
ratio taller than wide came out landscape on a landscape photo, and a
ratio picked after X (crop.rotateAspect) turned the crop back. A named
ratio now takes the crop's present orientation (landscape, or portrait
after X; a square crop follows the image), a custom [w, h] is used as
given, and crop.aspect takes orientation: "crop" | "given" to choose.
The window reapplies the remembered ratio to an untouched photo with
orientation "crop", as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-18 22:48:56 +00:00
MarekSpodBiedry 15e9a24891 Draw the Before label on a dark pill so it reads on light photos (#2247) 2026-10-18 23:36:52 +02:00
witkowski01 f140629bfd i18n(pl): translate the mask effect import and removed keyword messages 2026-10-18 22:53:56 +02:00
Claude 55b7b47925 #2242: reverseGeocode maxDistance, and the 15 000-people limit is documented
GeoNames cities15000 holds towns of 15 000 people or more, so a photo in a
smaller town got the nearest larger one in City (Portofino → Rapallo,
5.3 km), another municipality. metadata.reverseGeocode takes maxDistance
(metres): past it, City and Sublocation stay empty, State and Country
still fill, and the reply names the town as nearestCity. Hostile values
are refused. The command help, the Fill Location tooltip and the parity
row say what the list holds. Default unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-18 20:13:25 +00:00
witkowski01 6bb4517d77 i18n(pl): Apple Photos Imports describes adding to Photos, not importing from it 2026-10-18 22:06:21 +02:00
Matthew James SivakandClaude Opus 5.5 1ed76d851d Merge main into alt-clipping-preview
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:39:24 +02:00
Matthew James SivakandClaude Opus 5.5 349c27917b Merge main into fix/330-drag-scale-followups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:38:09 +02:00
Claude 00f55ef352 #2266: a presets.json that can't be written counts as unsaved
Preset commands change the preset list outside pending_log, and a failed
presets.json write was only logged: unsaved() stayed None, so nothing
warned and the web backup (#1987) would zip a stale presets.json; on
desktop a full disk lost the preset silently. The failure is now kept
(presets_error), counted by unsaved() (plus one change), retried by
persist_if_dirty with the same back-off as failed appends, and cleared
once the write succeeds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-18 19:28:49 +00:00
Dylan M. Taylor fb7ac08b9a ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:40 -06:00
Carlos VillanuevaandClaude Opus 5.5 16417e2eca Lightroom import: document the functions this change touches
CodeRabbit's docstring check counted the touched functions without doc
comments (read_with_progress, apply_prepared, inspect_report, result_dialog
and a test helper). Comments only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 18:04:34 +00:00
Matthew James SivakandClaude Opus 5.5 5b5cdb646e LR-BEHAV-BG-TASKS: background Reload names itself in bad-ids errors; a stopped Reload doesn't say the raw still can't be decoded
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:57:32 +02:00
Carlos VillanuevaandClaude Opus 5.5 9eec26804d Lightroom import: report skipped images as data and say so in the UI language
Review of #2230: the skipped-image warnings were English text shown as is
in the result window. Skipped images are now data in the import and inspect
reports (`skipped`: image id and reason: missingFile, missingFolder,
missingRoot, incompletePath, masterSkipped) instead of warnings, and the
result window adds one line counting them through tr_format!, translated in
every UI language; the eight-warning cap is unchanged.

Reader-level tests now cover a missing folder and root record and a virtual
copy of a skipped master read from a catalog file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 17:21:36 +00:00
Matthew James SivakandClaude Opus 5.5 7b2d7b25ea LR-BEHAV-BG-TASKS: an older Reload can't overwrite a newer one; background Reload auto-writes sidecars
Review of #1998. Each reload plan takes a ticket; apply skips a photo a
later-started reload has already been applied to, so an older read that
finishes last no longer restores the previous dimensions and content hash.
The app's background completion now commits through Session::execute_fn,
so the XMP sidecar auto-write (and the save) happen as for the command.
Regression tests: an_older_reload_does_not_replace_a_newer_one,
a_background_reload_writes_the_sidecar_with_auto_write_on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:07:40 +02:00
Carlos VillanuevaandClaude Opus 5.5 bb9fe91bf2 Merge storytold/lightcraft main into lightroom-import-skip-orphan-images
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 17:07:17 +00:00
LosminekandClaude Opus 5.5 a6c05ea0d9 i18n(cs): review fixes and updated counts in localization-cs.md
Independent AI review of the catalog catch-up (not a native speaker):
reflexive-possessive fix in the raw recheck messages, crash dialog
wording aligned with the existing startup failure message, Solo Mode
named after Edit Sections, bracket set term unified with the existing
catalog, Favorite star tooltip as a neutral state label, and smaller
wording fixes. The counts in docs/localization-cs.md now match the
catalogs (2 394 messages, 297 format templates).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:05:43 +02:00
LosminekandClaude Opus 5.5 c495ac8e09 i18n(cs): bring the Czech catalog up to date
Add 329 UI messages: the 175 that German, Spanish and French already
translate, 18 more that other catalogs have, and 136 that code looks up
but no catalog had yet (slideshow templates and export, print layout,
mask effect presets, focus points, face models, menu commands). Translate
the 44 format templates that were still English placeholders.

Every added key has a production lookup; "Click to type a value" (no
longer used) is left out. Release-note lines stay in English, as
docs/localization-cs.md describes. AI-assisted; not yet proofread by a
native speaker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:53:35 +02:00
Claude debfa644ec #1927: a slider drag and unrelated section eyes don't misplace crops
Two more cases from the review of #1990: the end of a slider drag replayed
the crop Constrain Crop had fitted during the drag, so Auto Sync still
copied the active photo's fitted crop to the others; the end of an
interaction now starts again from the original crop when the drag didn't
edit the crop. And any section eye counted as a warp change, so turning
Light off could refit a hand-drawn crop: only the Geometry and Optics
eyes do now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-18 16:48:52 +00:00
Davit MatchakhelidzeandClaude Opus 5.5 18cfda0ee9 Release: build the web version with craft-fonts too (web-craft-fonts)
The shared release workflow builds the web release without CRAFT_FONTS_DIR unless web-craft-fonts
is set, so the web version would still have no Georgian face (CodeRabbit on vectorcraft#2934).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 20:25:24 +04:00
Matthew James Sivak bf6e10fb06 Merge origin/main into reload-from-disk-background 2026-10-18 18:07:13 +02:00
Dylan M. Taylor 31a677fe3d docs: drop references to competing commercial software (#2213) 2026-10-18 10:41:49 -04:00
Brandon Thomas a945533f63 Merge pull request #2236 from storytold/claude/integrate-batch-42
Integration batch 42: 31 community PRs
2026-10-18 10:39:21 -04:00
IgnatAi401 714ca70b20 LR-BEHAV-LOCALIZE: zh-hans face hint says the match is a suggestion 2026-10-18 22:30:23 +08:00
Davit MatchakhelidzeandClaude Opus 5.5 e6e5d2d8a6 i18n(ka): × for the stop button in two What's new lines (Noto Sans Georgian has no ✕)
catalog_characters_are_paintable checks ka against the craft-fonts face since #27; U+2715 is in
none of the UI faces, U+00D7 is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 18:27:44 +04:00
IgnatAi401andClaude Opus 5.5 2b3c41a85a LR-BEHAV-LOCALIZE: translate AI Denoise progress and face-model hints into zh-hans
Eleven AI Denoise format strings in zh-hans-formats.json were still the
English source, and the New Album "Include selected photos" checkbox plus
four face-model hints had no zh-hans entry, so they showed in English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:08:54 +08:00
Carlos VillanuevaandClaude Opus 5.5 1de6401aeb Lightroom import: one warning per skipped image, as the other import warnings
Go back to one "image N: not imported; …" warning per image, the format the
import's other skip warnings use; the result window already caps the list
(first eight, then "N more warnings"). Note the behaviour in the
LRC-LIB-CATALOG-IMPORT tracker row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 13:25:44 +00:00
Claude 23f00822eb #1927: Auto Sync fits each photo's own crop under Constrain Crop
The crop fit ran before the Auto Sync delta, so a Geometry edit carried
the active photo's fitted crop rect into the delta and onto every other
selected photo, replacing (and could enlarge) their own crops. The delta
is now taken from the edit as asked, and each target fits its own crop.
Review on #1990.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-18 12:37:56 +00:00
Carlos VillanuevaandClaude Opus 5.5 ad43a976f3 Lightroom import: one warning per skip reason; skip incomplete original paths too
A catalog with many orphaned images produced one warning line each (82 in
the reported catalog). Skipped images are now listed in one warning per
reason, showing the first ten ids. An image whose original path is empty is
skipped the same way instead of failing the whole import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 12:33:18 +00:00
Carlos VillanuevaandClaude Opus 5.5 9c812b88a9 Lightroom import: skip images whose file record is missing instead of failing
A catalog can keep Adobe_images rows whose AgLibraryFile, AgLibraryFolder
or AgLibraryRootFolder record is gone; Lightroom ignores them, but the import
stopped at the first one with "image N: missing file record". Such images,
and their virtual copies, are now skipped with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
2026-10-18 12:19:10 +00:00
Davit MatchakhelidzeandClaude Opus 5.5 07b2f55022 fonts: Georgian UI text from craft-fonts' Noto Sans Georgian on the web
Native already appends every non-CJK craft face via ui_fallback; the wasm build
only embedded BIZ UDPGothic and Noto Sans Thai, so add Noto Sans Georgian to
that filter. Check its glyphs next to Thai and Arabic, and bump craft-fonts-ref
to a71c20e (storytold/craft-fonts#27).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 16:14:56 +04:00
Marco Arnulfo eabcae0fdc LR-EXP-BITDEPTH: judge a preset's bitDepth against the format the export writes
A preset without `format` lost its `bitDepth` before the call's `path` was merged in,
because it was judged against JPEG: `app.export {preset, path: a.png}` with a 16-bit
preset wrote an 8-bit PNG. Session::export_params now merges first and drops a preset's
depth only if the format written (with the caller's fallback) lacks it; a depth the call
passes is still validated. export.savePreset judges the depth at once only when the preset
has a `format`.
2026-10-18 13:45:08 +02:00
NetterSilverandClaude Haiku 5.5 6e97193b00 Reject impossible hour/minute/second in normalize_iso
Found while testing crates/catalog/src/dates.rs:58 (normalize_iso), used by crates/engine/src/cmd/manage.rs:201

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 11:36:44 +00:00
Marco Arnulfo 302faf547b LR-EXP-BITDEPTH: check bitDepth against the format a path without a known extension gets
app.export with a path like `output` or `output.xyz` and no `format` skipped the depth
check: the desktop app then wrote JPEG and the headless backend PNG, both at 8 bits.
ExportOptions::export_format resolves the format written (with the caller's fallback for
such a path), from_params_or validates against it, and both callers use it instead of
their own override.
2026-10-18 13:27:07 +02:00
FredApps d515f9affe Merge main into file-backed Lightroom catalog import (#798)
Preserve the file-backed reader and current upstream safeguards.
Resolve documentation conflicts without integrating unmerged work.
2026-10-18 00:49:15 +02:00
eliets2 fef2f36197 CI: re-trigger FreeBSD after flake in tests_panels::a_folder_takes_a_colour_label_from_its_menu
The failed FreeBSD run (39062750738) failed only this menu-interaction test
(None vs Some(Red) at tests_panels.rs:1773), unrelated to the wasm-only
i18n change; the branch already sits on current main, so this empty commit
exists only to produce a fresh merge run.
2026-10-18 00:38:19 +03:00
eliets2 01495684d9 web: Auto language follows the browser's languages (#2085)
default_language() — what the Auto setting resolves to — only read LIGHTCRAFT_LANGUAGE,
which does not exist in a browser, so the web build always started in English. ui-egui
gains an installable default (set_default_language + a pure locale_from_tags that picks
the first shippable language from BCP-47 tags via Locale::parse_tag), and the web app
installs it at startup from navigator.languages (falling back to navigator.language),
read fallibly through js_sys::Reflect so it also works from a worker. Desktop never
installs the override and is unchanged; a language stored in ui.json still wins, which is
exactly Auto semantics.

Tests: locale_from_tags takes the first shippable language (it-IT -> Italiano,
unsupported-first skips ahead, zh-TW -> zh-hant, ja_JP.UTF-8 -> Japanese, pt-BR region
variant), empty/unsupported -> En fallback.
2026-10-17 22:30:14 +03:00
eliets2 9f2052f56b raw: audit fixes — DEFLATE row bytes in u64, checked is_packed12, cap invariant documented (#2138)
The reviewer audit of the first commit found the DEFLATE arm computed row_bytes in
wrapping usize before the gate, so on wasm32 a hostile chunk header (width 2^29+1,
16-bit) could wrap cw * cpp * bits to a small value and slip a garbage chunk past the
new gate. row_bytes is now computed in u64 (the gate caps at MAX_SAMPLES, so the usize
cast is lossless on 32-bit too), checked_pixel_count takes u64 directly so no call site
can truncate before the check, is_packed12 uses a checked_mul chain instead of comparing
against a possibly-wrapped length, and the arw decode head documents the invariant the
cap provides: with w*h <= 2^30 every downstream usize product stays inside range on
32-bit targets.

Behaviour is unchanged for every input the previous commit accepted; the only shrunk
set is chunks that passed because wrapping made row_bytes small.
2026-10-17 22:20:48 +03:00
eliets2 36d22a2745 raw, codecs: checked dimension products so wasm32 cannot wrap the size gates (#2138)
On wasm32 usize is 32-bit, so 65536 x 65536 wraps to 0 and products like w * h,
w * h * cpp, fw * fh * nc and row_bytes * ch wrapped past their own validation and let
later true-dimension indexing panic (profile.web sets panic = abort). All the enumerated
sites now validate through a checked u64 gate:

- lightcraft-raw gains checked_pixel_count(width, height) (u64, capped at MAX_SAMPLES),
  used by the ARW decode head (the strip-length equality now compares the true sample
  count, not a wrapped one) and by RawImage::check's data-length validation;
- the CR2 lossless-JPEG frame size is checked in u64 on both the Full and Header paths
  (it used to be clamped AFTER wrapping), and the no-slices width fallback is checked;
- the TIFF DEFLATE chunk's inflate length is checked and capped before the cast;
- lightcraft-codecs gains checked_sample_buf_len for the finish() buffer bound, compared
  in u64 via try_from so no cast truncates on 32-bit.

Tests pin the gate arithmetic with values that would wrap 32-bit usize (65536 x 65536,
u32::MAX x 2, 2^30 x 4). cargo check --target wasm32-unknown-unknown passes for both
crates. The issue's suggested wasm32 arithmetic-side-effects clippy pass is a follow-up.
2026-10-17 22:20:48 +03:00
eliets2 555eb4c9fd tiff: bound total entries across a parse (#2136)
max_ifds (1024) x max_entries (8192) tables that overlap in the file — SubIFD pointers
shifted by one entry reuse the same bytes — could make Tiff::parse allocate ~8M Entry values
(~400 MB) from a ~100 KB input, since count-0 entries cost nothing from the value budget and
only the per-IFD entry cap existed. ParseOptions gains max_entries_total (65 536 default,
generous for real scanned TIFFs), charged per IFD before the entries Vec is allocated, so a
hostile file stops with TiffError::Limit("entry count") instead of amplifying.

Tests: a hostile overlapping-table chain stops after 8 IFDs inside one parse, and a
legitimate multi-IFD file (chain + Exif/GPS children) still parses fully.
2026-10-17 21:53:21 +03:00
adrdsouzaandCodex 4d8f93585a Keep live brush dab budget across frames (#1520)
Pitch-Impact: none

Co-authored-by: Codex <[email protected]>
2026-10-17 21:14:43 +05:30
Haocheng Fang 27c278ab33 MCP: advertise file overwrite tools as destructive (#2139) 2026-10-17 08:13:57 -05:00
NetterSilverandClaude Haiku 5.5 414764ef77 Stop export lanes when a panic unwinds the batch thread
Fixes #2132

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 11:25:12 +00:00
Alex Chambers 005881ce94 Issue #2098: reject malformed mask and spot targets 2026-10-17 07:42:38 +02:00
Alex Chambers de44200a8d Issue #2097: reject fractional command indices 2026-10-17 07:40:34 +02:00
MarekSpodBiedry 86147e1eeb Keep the profile dropdown and its submenus inside the window (#2059) 2026-10-17 00:27:11 +02:00
helgehelge123andClaude Opus 5.5 53f57b9404 LR-MASK-OBJECTS: SAM 3 falls back to the CPU when the GPU can't run it (#1974)
On an Intel Mac with an AMD Radeon Pro 5300M the Metal compiler crashes
on candle's mlx_gemm kernel, candle-metal-kernels panics on the compile
error, and the worker reported "the AI model failed unexpectedly" and
loaded the model on Metal again for every next job, so AI masks never
worked there.

The worker now retries a job that fails on the GPU (a panic or a candle
error) once on the CPU, after freeing the model. When that works, the
model stays on the CPU for the session and the job's outcome carries a
notice that segment_poll shows once; when the CPU fails too, the GPU
was not the cause and the next job uses it again. Missing or damaged
model files, render and remote-worker errors are not retried. Only
builds with a GPU path (macOS) retry; LIGHTCRAFT_GPU=0 and
LIGHTCRAFT_GPU_BACKEND=off now keep SAM 3 on the CPU from the start
(lightcraft_gpu::env_off_reason), like AI Denoise and the face models.

lightcraft-segment: Sam3::load_cpu, GPU_BUILD; text_logits returns the
crate's Error so Describe failures are classified like clicks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 23:55:32 +02:00
Matthew James SivakandClaude Opus 5.5 0d30c99e87 LR-VIEW-CLIPPING: merge main into #937 (⌥-drag previews)
Conflict only in docs/parity-checklist.md: the generated summary is
refreshed with `cargo xtask parity --write`, and the Last updated line
keeps main's format with this PR's change note (today's date) instead of
the duplicated note the branch carried. ⌥-drag preview still wins over
AllMasks and the selected mask in view_overlay; overlay kinds 6–9 unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:52:08 +02:00
ubergenericandClaude Opus 5.5 9392039726 Detail: at rest the whole frame renders at the preview level, shrunk once
Rendered at the drawn size, the pipeline itself shrinks the 2560 px preview
source (Mitchell prefilter, then the bilinear sampling pass) before the display
fit shrinks it again. At rest the whole-frame render now asks for no less than
the preview level (the photo's own size if smaller, a user's explicit Preview
size if lower, the texture limit at most): the source is copied 1:1 and shrunk
once, by the Lanczos display fit. Drags still render drafts at the planned size.

On a line-grating chart at Fit this takes 1.8 px bars from 0.83 to 1.00 of their
contrast (1920 x 1200 window), at the cost of a larger settled render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:28:14 +11:00
ubergenericandClaude Opus 5.5 7db0a64d7e Detail: Fit is as sharp as the photo (display fit, whole-pixel draw, Lanczos decode)
At Fit the loupe was softer than a good downscale of the same file. Four
resamples stacked up between the source and the screen:

- the render edge is rounded up to a 64 px step, so the texture was drawn up
  to ~6 % smaller than its size and the GPU shrank it with 2-tap bilinear
  sampling;
- the fitted texture sat on a fractional pixel position, so even at the right
  size every screen pixel was averaged with its neighbour (#1831 fixed the
  same for 100 %);
- the render box truncated its short side (2560 x 1706 for a 2560 x 1707
  source), a slightly anisotropic resample that blurred one axis only;
- the codec's decode-time shrink at 2:1 and more, and SourceRef::Fit, used a
  box filter, which halves the contrast of detail near the output's limit.

RenderJob::display_fit shrinks a full-quality render that is larger than its
drawn size to exactly that size with Lanczos-3 on the worker (after the caches
have the full render; drafts untouched; part of the key). The Detail view sets
it for the After and Before renders at rest, and snap_fit_rect draws a texture
that is its drawn size on whole physical pixels. The render boxes round their
short side, and the 2:1-and-more shrinks use Lanczos-3.

Refs #1472, #1042.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:28:12 +11:00
wstockandClaude Fable 5.1 76689a563a Crop: a fresh crop starts locked to the photo's own shape (#991)
With no aspect ever chosen, every crop started free, so the Lock had to
be switched on photo after photo. Nothing chosen yet now means locked to
Original, as Lightroom starts; switching the Lock off once is remembered
as free, and an explicit ratio is remembered as before.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-16 09:38:38 +00:00
wstockandClaude Fable 5.1 50690745e8 Library: a picked photo's flag is drawn filled (#1013)
The Pick flag on thumbnails, in Compare, in the loupe and on the Pick
button was the same outline as the button's idle state, so a pick was
hard to tell at a glance. A new filled pennant (`Icon::FlagPickFilled`)
marks a pick wherever a photo's own flag is shown; buttons, filters and
the reject flag keep their outlines.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-16 09:38:37 +00:00
Matthew James SivakandClaude Opus 5.5 62e82479d2 LR-PROF-CAMERACOLOR: fit a DNG's tone with a look table and a 1024 px preview
A DNG whose profile has a LookTable but no ProfileToneCurve (Adobe Standard in
Lightroom-written DNGs) was not tone-fitted and rendered with the near-linear
default tone: flat and ~0.8 EV darker than Lightroom. A look table defines colour,
not tone, and the fit already applies the tables to its proxy. The preview may now
be 10% of the raw's long edge (and 384 px), so Lightroom's 1024 px previews
qualify. Two lossy ILCE-7CM2 DNGs now match Lightroom's mean linear RGB within 3%.
Render cache 65, look version 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:46:01 +02:00
Matthew James SivakandClaude Opus 5.5 ed5afb7eb0 LR-IMP-FORMATS: document why lossy JPEG XL DNG tiles keep their declared curve
Lightroom Classic's lossy LinearRaw DNGs linearise the sRGB-coded samples with a
cubic MapPolynomial in OpcodeList2; measured against Lightroom's preview, the
declared-curve reading tracks its rendering and the linear one is 3.6 EV dark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:46:01 +02:00
Matthew James SivakandClaude Opus 5.5 2d57a1466d LR-IMP-FORMATS: decode lossy JPEG XL DNG tiles with a non-linear declared transfer curve
Lightroom Classic 15.1 writes lossy LinearRaw DNGs (e.g. Denoise output) whose
JPEG XL tiles declare Rec. 2100 primaries with the sRGB transfer curve. These
were rejected ("declared encoding that is not linear RGB") and fell back to the
embedded preview. The tile's samples mean what they would in a lossless tile with
the same header, so the declared curve (sRGB, BT.709, DCI, gamma) is now applied
after the primaries matrix, mirrored and unclipped. PQ / HLG / unknown stay
unsupported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:46:01 +02:00
Claude 5902a07354 Windows: folder-drop tests name folders as the Folders tree does; offline test waits for its worker
tests_folder_drop built widget ids from OS paths (C:\...), but the Folders
tree names folders with '/' on every platform (libfolder:C:/Users/...), so
8 tests found no row on Windows. tests_offline asserted that the background
probe had run without waiting for the worker thread; it now waits up to 5 s.
Found by running the tests on windows-latest (gap 9). Test-only changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-16 08:25:46 +00:00
Claude 109e4dabd3 Windows: compare exported paths as paths in the same-folder export test
same_folder_as_original_exports_next_to_each_photo compared path strings.
The export joins the subfolder with '/', so on Windows it reports
C:\...\A/Exports/IMG_1.jpg (a valid path) while the test expected
C:\...\A\Exports/IMG_1.jpg. Found by running the tests on windows-latest
(gap 9). Test-only change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-16 07:25:39 +00:00
Sergio PereaandDeepSeek V4.1 Flash fa26767b81 macOS bundle: drop Info.plist UTI declarations LightCraft cannot open (#1951)
The template was copied from a sibling app: it exported a "LightCraft Document"
UTI for PhotoCraft's `.pcraft` extension (MIME `application/x-lightcraft`) and
imported `.psb` (Photoshop Large Document) and `.qoi`. LightCraft reads none of
them, so the declarations only made Launch Services type PhotoCraft documents as
LightCraft's and left `.pcraft` with three claimant apps among the Craft suite.

Remove both `UTExportedTypeDeclarations` and `UTImportedTypeDeclarations`. No
CFBundleDocumentTypes is added: the formats LightCraft opens are already declared
by the system or another app, so adding them is out of scope here.

`packaging/check-app-specific.sh` now parses the template as a property list and
fails if it declares any of those types again, while still checking the required
bundle keys.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-16 09:07:29 +02:00
LochLogicandClaude Opus 5.5 2e0906b71a Tests: make the Windows-only failures pass (paths with /, MCP sockets vs child processes)
On Windows 11, `cargo test --workspace` on main fails 11 tests:

- tests_export::same_folder_as_original_exports_next_to_each_photo built its
  expected paths with Path::join (`\`), but export joins the subfolder and
  file name with `/` (join_path) on every platform. Expect what export writes.
- tests_folder_drop (8 tests) looked rows up as
  `source:libfolder:C:\...`, while the Folders section names them with `/`
  (`source:libfolder:C:/...`), so no row was found. Look them up the same way.
- mcp backend::tests::{valid_replies_and_errors_are_preserved,
  malformed_replies_are_rejected_without_resending} failed only when the whole
  crate ran: the shutdown and one-shot-process tests start a copy of the test
  binary, the child can inherit a socket a fake server just accepted, and when
  the child exits Windows closes its copy abortively, resetting the test's
  connection. A test-only lock keeps socket tests and child-process tests from
  overlapping (passes 6 of 6 whole-crate runs, failed 4 of 4 before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:11:32 -06:00
LochLogicandClaude Opus 5.5 180fdb3b41 CI: catalog's staging-link test module compiles only on Unix (clippy -D warnings on Windows)
`tests_staging` holds one `#[cfg(unix)]` test (#1502), so on Windows its
`use super::*` is unused and `cargo clippy --workspace --all-targets -- -D
warnings` (the clippy step of `cargo xtask ci`) fails in lightcraft-catalog.
Gate the module itself on Unix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:49:57 -06:00
Marco Arnulfo e14db89462 LR-EXP-BITDEPTH: app.export refuses a bitDepth the format doesn't have (part of #1952) 2026-10-16 02:21:07 +02:00
Claude 54c6c43b07 #1927: Constrain Crop tightens the stored crop
Constrain Crop only applied at render time, and the Crop tool shows the
uncropped view with the stored crop, so in the Crop panel (where the
checkbox is) ticking it or moving a Geometry slider changed nothing.
Session::set_develop now shrinks the stored crop about its centre until it
fits the warped photo when Constrain Crop is turned on, or when an edit
changes the warp (Geometry, Upright, lens corrections, section eyes) while
it is on. A crop edit itself is kept as drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-16 00:01:00 +00:00
helgehelge123andClaude Opus 5.5 90a248edad M12: Describe selections from SAM 3's semantic mask: sure inside, no lines between instances
Describe merged the detector's per-instance masks. Those stay near 0 even well inside what they
found: a sky came out about 87 % selected, a volleyball net about 70 %, a person about 94 %.
Two instances that touch both fade out where they meet, so a line showed between people standing
side by side.

- The detector's semantic head is now ported: semantic_projection over the pixel decoder, the
  transformers implementation's semantic_seg. It matches the reference to about 1e-3, like the
  instance masks. tools/sam3_reference.py writes it and the reference test checks it; older
  reference files still pass.
- Each instance found (score above the threshold, as before) takes the semantic logits where it
  is the strongest instance and near (above -4). The semantic mask is sure inside and runs on
  across the boundary between two instances. Far from every instance nothing changes, so
  Describe selects nothing it didn't find before.

On 16 prompts over three photos (a mountain landscape, two gym photos), the median coverage
inside each selection rises, e.g.:
- sky 0.87 -> 1.00
- mountains 0.81 -> 0.99
- net 0.71 -> 0.99
- floor 0.92 -> 1.00
- person 0.94 -> 0.99

Each selection gains 0 to 1.9 % of the photo at its soft rim and loses none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 01:35:59 +02:00
Claude 37a3ab4177 #1507: web start-up treats only NotFoundError as a missing file
OPFS lookups turned any exception into "absent", and start-up only logged
other read errors, so a library with an unreadable file opened incomplete
and the next save could overwrite the stored copy. Only NotFoundError now
means absent; when a stored library file or the active-library pointer
can't be read, the library isn't opened, nothing is saved and the window
names the file. An unreadable thumbnail index no longer deletes every
stored thumbnail as unindexed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-15 23:08:54 +00:00
Claude abf29ea392 #1508: web backup refuses to start while changes are unsaved
The browser backup copies the stored library files, so while a save to
browser storage is failing it reported success but left out the queued
changes. It now refuses with the number of unsaved changes and why
(Session::unsaved), and is allowed again once they are written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-15 21:53:46 +00:00
FredApps 08e3893731 LR-798: refresh file-backed catalog against main 2026-10-15 22:44:43 +02:00
fanyzp 5bf0d45ba8 Do not read a sibling's shared stem sidecar on import (#1905)
With stem naming, `IMG_0001.xmp` is the sidecar of every file of that stem and, in a
folder Lightroom has worked on, it is the raw's. On import the packet was looked up
with the library naming alone, so a TIFF or JPEG arriving beside a raw of the same
name read the raw's packet and took its rating, label, keywords and `crs:` develop
settings (the case radialmonster reported in #92; the read side of a catalogued photo
is #1523).

`ImportStems` ranks the files an import is adding as if they were catalogued already,
in the order `StemOwners` uses for writing: the library's files and the arriving ones
together, a raw first, then the file name in lower case. A file that does not own the
stem sidecar reads only its own `<file>.xmp`, never the sibling's packet. A file that
is not catalogued yet has no id, and the id it draws when the import commits is above
every id in the library, so it loses a tie against a catalogued file; between
arriving files the order they arrive in stands for it.

Folder synchronize and the Lightroom catalog import go through the same function, so
a file arriving there is ranked the same way.
2026-10-16 02:20:40 +08:00
Pierre-Edouard HonnetandClaude Opus 5.5 c806debada LR-IMP-FORMATS: CRX high-frequency band width from the tile geometry (#899)
The decoder tried each high-frequency band at its synthesis width and up to
two more columns, keeping the first width whose entropy stream ended exactly.
Two widths can both do that with different coefficients (#899).

Measured on every C-RAW plane in cr3_corpus.rs (CC0 raw.pixls.us files):
each band decodes at exactly one width. A tile with a neighbour on its right
codes bands 5 and 8 (the vertical high-pass of the level-2 and level-1
low-pass columns) at an odd width, so an even band gains one unused column;
no other band is extended. The width now comes from that rule, and a stream
that does not fit it is corrupt (the CR3 falls back to its preview).

All corpus sensor hashes are unchanged. The property-test encoder now codes
bands 5 and 8 the same way. Middle tiles (three or more C-RAW tiles) follow
the left-tile rule; no public file has one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:12:46 +02:00
Matthew James SivakandClaude Opus 5.5 b9b80aee8a LR-BEHAV-BG-TASKS: Reload from Disk reads its files in the background
photo.reload decoded every targeted file on the UI thread, so a selection
of raws froze the window until all were read. The command is now split into
plan / probe / apply (engine `cmd::reload`); the app intercepts photo.reload
and probes the files on a worker thread with a cancellable activity row
(kind `reload`, counted progress), applying the result as one undo step.
`wait: true` keeps the old in-place behaviour for agents; CLI/MCP unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:35:11 +02:00
Adrian D'souzaandClaude Opus 5.5 d770c52313 Masks: bound the total dabs of one brush stroke (#1520)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:23:18 +05:30
Matthew James SivakandClaude Opus 5.5 c990dec0f0 LR-VIEW-ZOOM: tracker row describes the two-strike, expiring slow-drag verdict (#330)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:27:07 +02:00
Matthew James Sivak 71f7c2ce59 Merge remote-tracking branch 'origin/main' into fix/330-drag-scale-followups 2026-10-15 11:08:50 +02:00
witkowski01 342afcb81e i18n(pl): menu command names, the formats left in English, and Vibrance as Jaskrawość 2026-10-15 10:26:01 +02:00
Matthew James SivakandClaude Opus 5.5 9a0f076105 LR-VIEW-CLIPPING: ⌥-drag previews for Blacks, Whites, Exposure, Sharpening and NR (#937)
Holding ⌥ (Option) while dragging a slider shows a diagnostic view in the loupe, as in
Lightroom; releasing ⌥ or the slider brings the photo back and nothing is saved:

- Blacks: shadow clipping (white; black where every channel is 0, the colour of the
  unclipped channels where only some are).
- Whites / Exposure: highlight clipping (black; white where every channel is 255, the
  clipped channels' colour where only some are).
- Sharpening Masking: the weight sharpening gets at each pixel, white on black.
- Sharpening Amount / Radius / Detail, luminance NR: the image in grey.

New `Overlay` variants (ShadowClip, HighlightClip, SharpenMask, Grey) are drawn by the shared
`visualize::apply`, so CPU and GPU agree; the sharpening mask needs the detail planes and
renders on the CPU (like HdrRange). The mask uses the same weight function as the render
(`finish::sharpen_weight`). With a display profile the clipping views are tested in sRGB, like
the histogram, and diagnostic views keep the photo's histogram instead of the view's.

Tracker: LR-VIEW-CLIPPING ✅ → 🟡 (J only frames the photo, it doesn't paint clipped pixels)
and LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:41:44 +02:00
Matthew James SivakandClaude Opus 5.5 3ee61f28ed Merge #1009 (with origin/main, batch 35) into fix/330-drag-scale-followups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:56:19 +02:00
FredApps 6f12b28f28 Merge main into file-backed Lightroom catalog reader
Keep main's spots.rs implementation and preserve the catalog reader and
updated capability documentation. Readers are byte-identical to the PR.

Validation: 48 catalog and 9 spot tests pass on candidate and main.
Formatting, Clippy, parity, layers, assets and WASM checks pass.
Full CI remains blocked by the same mask-overlay test failure reproduced
on main; no assertions or tests were changed to hide it.

Refs #798.
2026-10-13 20:28:49 +02:00
Matthew James SivakandClaude Opus 5.5 6a66860ea9 LR-VIEW-ZOOM: hidden-window test drags twice so it catches a missing frame-time bound (#330)
One slow drag only makes the pair suspect, so a single hidden-window
drag passed even without the FRAME_MS_KNOWN upper bound. Two such drags
followed by a full-size third fail (0.6) when the bound is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:11:27 +02:00
Matthew James SivakandClaude Opus 5.5 8d91abe603 LR-VIEW-ZOOM: a slow drag verdict needs two strikes and expires (#330)
A busy moment (another app, a backup) could make a slider draft small for
the rest of the session. DragScale keeps timing in wall-clock time, what the
user waits for, but now:

- a (photo, control) pair is slow only after two drags in a row over 20 ms
  (1.25x the 16 ms budget); the first makes it suspect, and a drag within
  20 ms forgets the verdict;
- a slow verdict lasts 30 s, then a drag tries full size again; a slow
  retry doubles the wait, up to 240 s (after WebRTC's CPU overuse detector:
  40 s ramp-up delay, doubled after a failed up-switch, capped at 240 s).

The time comes from egui's input clock, which the web build has too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 13:33:38 +02:00
Matthew James SivakandClaude Opus 5.5 a2911a5086 LR-VIEW-ZOOM: drag-scale follow-ups from review (#330)
- A full set of slow drags forgets only the one remembered longest ago
  instead of all 256 (each forgotten pair costs one more slow full-size drag).
- DragScale's doc says a slow drag is remembered for its photo only.
- A host frame time over 1 s (a hidden, occluded or stalled window) counts
  as unknown, like no frame time at all, so it can't mark a drag slow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 12:56:50 +02:00
FredApps 44d5cf7590 #798: reconcile page-backed catalog import with upstream
Preserve page-backed large-catalog import while integrating upstream catalog and documentation changes. Adapt only the sparse test fixture to the shared upstream fixture helper.

Use the equivalent complete RGB-triple iterator required by Rust 1.99 Clippy; the same lint failure was reproduced on the exact upstream baseline.

Validation: 47 catalog tests, nine spot tests, exact sparse regression, all eight CI gates, and an original synthetic headless import/selection check passed.

Generated with Codex.
2026-10-11 17:52:32 +02:00
FredApps b273f8e608 #798: read large Lightroom catalogs by page
Generated with Codex.
2026-10-11 00:53:36 +02:00
141 changed files with 5714 additions and 782 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -33,4 +33,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+3 -8
View File
@@ -18,11 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: nix flake check --no-build --all-systems
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+2 -1
View File
@@ -26,7 +26,8 @@ jobs:
with:
app-name: lightcraft
app-title: LightCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
Generated
+33 -33
View File
@@ -3078,7 +3078,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3120,7 +3120,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3136,7 +3136,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3167,7 +3167,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3176,7 +3176,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3193,7 +3193,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3208,7 +3208,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"ab_glyph",
"craft-docpath",
@@ -3263,7 +3263,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
@@ -3278,7 +3278,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"log",
"rustls",
@@ -3289,7 +3289,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3297,7 +3297,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3305,7 +3305,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3325,7 +3325,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3333,14 +3333,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3349,7 +3349,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3360,7 +3360,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3376,7 +3376,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3388,7 +3388,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3406,7 +3406,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3418,7 +3418,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3430,7 +3430,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"dhat",
"jpeg-encoder",
@@ -3454,7 +3454,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3465,7 +3465,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3479,7 +3479,7 @@ dependencies = [
[[package]]
name = "lightcraft-slideshow"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"lightcraft-raster",
"serde",
@@ -3488,15 +3488,15 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.9.1"
version = "0.11.0"
[[package]]
name = "lightcraft-testkit"
version = "0.9.1"
version = "0.11.0"
[[package]]
name = "lightcraft-tiff"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3505,7 +3505,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -3534,7 +3534,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"crc32fast",
"eframe",
@@ -7004,7 +7004,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"brotli",
"flate2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.9.1"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+8 -7
View File
@@ -18,7 +18,7 @@
</p>
<p align="center">
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
<b>Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.</b><br>
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
</p>
@@ -28,6 +28,9 @@
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_lightcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/lightcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -333,9 +336,9 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in
- **By feature count we're at ~82%** (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
@@ -513,9 +516,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp`
Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version:
- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
work from public specs and black-box observation.
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
@@ -645,8 +648,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+5 -7
View File
@@ -18,7 +18,7 @@
</p>
<p align="center">
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
<b>照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。</b><br>
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
</p>
@@ -299,9 +299,9 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
@@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
@@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
+4 -3
View File
@@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> {
for (k, v) in opts {
p[k] = v;
}
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
if p.get("format").is_none() {
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
o.format = ExportFormat::parse(&ext)
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
// given before validating, so `bitDepth` is checked against the format written (issue #1952)
p["format"] = json!(ext);
}
let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
+9 -6
View File
@@ -22,6 +22,12 @@ fn err(e: JsValue) -> String {
e.as_string().or_else(|| e.dyn_ref::<js_sys::Error>().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}"))
}
/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure
/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507).
fn not_found(e: &JsValue) -> bool {
crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref())
}
/// `navigator.storage` in a window or a worker.
fn storage_manager() -> Option<web_sys::StorageManager> {
let g = js_sys::global();
@@ -134,10 +140,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await {
Ok(d) => dir = d.unchecked_into(),
Err(e) if !create => {
let _ = e;
return Ok(None);
}
Err(e) if !create && not_found(&e) => return Ok(None),
Err(e) => return Err(err(e)),
}
}
@@ -150,7 +153,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await {
Ok(h) => Ok(Some(h.unchecked_into())),
Err(_) if !create => Ok(None),
Err(e) if !create && not_found(&e) => Ok(None),
Err(e) => Err(err(e)),
}
}
@@ -250,7 +253,7 @@ impl Backend {
let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) };
match JsFuture::from(dir.remove_entry(&name)).await {
Ok(_) => Ok(()),
Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()),
Err(e) if not_found(&e) => Ok(()),
Err(e) => Err(err(e)),
}
}
+12
View File
@@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool {
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in
/// the app but not yet written to browser storage (a save failed). The zip copies the stored
/// library files, so it would report success while missing them; `None` when everything is saved.
pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option<String> {
let (ops, why) = unsaved?;
let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") };
Some(format!(
"Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \
Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved."
))
}
/// Zip entry name for an original.
pub fn original_entry(hash: &str, file_name: &str) -> String {
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
+81
View File
@@ -198,6 +198,27 @@ impl Store for FilesStore {
/// Names of the library files read at start-up.
pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"];
/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only
/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure.
pub fn is_not_found(dom_exception_name: Option<&str>) -> bool {
dom_exception_name == Some("NotFoundError")
}
/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that
/// exists but couldn't be read stops start-up instead (#1507): opening the library without it
/// would show an incomplete catalog and then save over the stored copy.
pub fn preload_library(files: &Files, reads: Vec<(&str, Result<Option<Vec<u8>>, String>)>) -> Result<(), String> {
let mut failed = Vec::new();
for (name, read) in reads {
match read {
Ok(Some(data)) => files.preload(name, data),
Ok(None) => {}
Err(e) => failed.push(format!("{name}: {e}")),
}
}
if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +303,66 @@ mod tests {
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
}
/// A backup copies the stored library files, so while a save is failing it would miss the
/// queued changes: it is refused with the reason until they are written (#1508).
#[test]
fn backup_is_refused_while_changes_are_unsaved() {
let files = Files::default();
let mut s = Session::new();
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
s.open_library_in(stores, true).unwrap();
assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start");
files.set_error(Some("QuotaExceededError".into()));
let _ = s.execute("photo.rate", &json!({"rating": 5}));
let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved");
assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}");
files.set_error(None);
s.persist_if_dirty();
s.persist().unwrap();
assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written");
}
#[test]
fn unsaved_refusal_counts_changes() {
use crate::backup::unsaved_refusal;
assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not"));
assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not"));
}
/// Start-up only treats a missing file as absent: a stored file that couldn't be read stops
/// it, naming the file, instead of opening an incomplete library (#1507).
#[test]
fn unreadable_library_file_stops_start_up() {
let files = Files::default();
let reads = vec![
("catalog.snap", Ok(Some(b"snap".to_vec()))),
("catalog.log", Err("NotReadableError: the file could not be read".to_string())),
("presets.json", Ok(None)),
];
let e = preload_library(&files, reads).unwrap_err();
assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}");
}
/// A new library (nothing stored yet) still starts.
#[test]
fn missing_library_files_are_a_new_library() {
let files = Files::default();
let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect();
preload_library(&files, reads).unwrap();
assert!(files.get("catalog.snap").is_none());
let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))];
preload_library(&files, reads).unwrap();
assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..]));
}
#[test]
fn only_not_found_errors_mean_absent() {
assert!(is_not_found(Some("NotFoundError")));
for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] {
assert!(!is_not_found(name), "{name:?}");
}
}
#[test]
fn library_survives_reload_through_flushes() {
let mut disk = BTreeMap::new();
+74 -19
View File
@@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
screen_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) {
return Err(why);
}
// the photos' own names for the originals in the zip
let names: std::collections::HashMap<String, String> = session
.catalog
@@ -280,6 +283,9 @@ struct Boot {
index: ThumbIndex,
/// The library folder in storage (`library`, or a restored backup's).
lib_dir: String,
/// Library files that exist but couldn't be read (#1507): the library isn't opened and
/// nothing is saved, so the stored copy stays as it was.
load_error: Option<String>,
}
async fn boot(opts: Options) -> Boot {
@@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot {
let files = Files::default();
let mut index = ThumbIndex::default();
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
let mut load_error = None;
if let Some(b) = &backend {
if opts.reset {
// everything this site keeps in the browser, photos included: never without asking
@@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot {
}
}
// a restored backup lives in its own folder (see `backup`)
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
let d = String::from_utf8_lossy(&d).trim().to_string();
if valid_library_dir(&d) {
lib_dir = d;
match b.read(ACTIVE_LIBRARY).await {
Ok(Some(d)) => {
let d = String::from_utf8_lossy(&d).trim().to_string();
if valid_library_dir(&d) {
lib_dir = d;
}
}
Ok(None) => {}
// not knowing which library is in use, any library opened could be the wrong one
Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")),
}
if load_error.is_none() {
let mut reads = Vec::new();
for name in LIBRARY_FILES {
reads.push((name, b.read(&library_key(&lib_dir, name)).await));
}
if let Err(e) = crate::files::preload_library(&files, reads) {
log::error!("{e}");
load_error = Some(e);
}
}
for name in LIBRARY_FILES {
match b.read(&library_key(&lib_dir, name)).await {
Ok(Some(data)) => files.preload(name, data),
Ok(None) => {}
Err(e) => log::error!("reading {name}: {e}"),
}
}
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
index = ThumbIndex::from_json(&j);
let index_read = b.read(THUMB_INDEX).await;
match &index_read {
Ok(Some(j)) => index = ThumbIndex::from_json(j),
Ok(None) => {}
Err(e) => log::warn!("reading the thumbnail index: {e}"),
}
crate::backend::request_persistence(|granted| {
if !granted {
@@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot {
);
}
});
// thumbnails stored without an index entry (index saved late, or lost): delete them
// thumbnails stored without an index entry (index saved late, or lost): delete them; not
// when the index couldn't be read, which would make every thumbnail look unindexed
let b2 = b.clone();
let known: std::collections::HashSet<String> = b
.list("thumbs")
@@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot {
.filter(|n| n.ends_with(".jpg"))
.map(|n| n.trim_end_matches(".jpg").to_string())
.collect();
let orphans: Vec<String> = known.into_iter().filter(|k| !index.contains(k)).collect();
let orphans: Vec<String> = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() };
if !orphans.is_empty() {
wasm_bindgen_futures::spawn_local(async move {
for k in &orphans {
@@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot {
index.len(),
index.total() as f64 / 1e6
);
Boot { opts, backend, files, index, lib_dir }
Boot { opts, backend, files, index, lib_dir, load_error }
}
struct WebApp {
@@ -389,7 +408,7 @@ struct WebApp {
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
let Boot { opts, backend, files, index, lib_dir } = boot;
let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
let originals = Originals::default();
let t = perf_now();
let mut session = Session::new();
@@ -401,7 +420,13 @@ impl WebApp {
on_disk: false,
};
let mut problem = None;
match session.open_library_in(stores, true).cloned() {
// a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy
let read_failed = load_error.is_some();
let opened = match load_error {
Some(e) => Err(format!("{e}. Nothing was changed in storage")),
None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()),
};
match opened {
Ok(r) => log::info!(
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
if r.created { "created" } else { "loaded" },
@@ -432,7 +457,8 @@ impl WebApp {
}
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
let frozen = Rc::new(Cell::new(false));
// nothing is saved while the stored library couldn't be read
let frozen = Rc::new(Cell::new(read_failed));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
@@ -667,11 +693,40 @@ impl eframe::App for WebApp {
}
}
/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else
/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible
/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English,
/// as before.
fn install_browser_language() {
let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return };
let mut tags: Vec<String> = Vec::new();
if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into())
&& let Some(languages) = languages.dyn_ref::<js_sys::Array>()
{
for tag in languages.iter() {
if let Some(tag) = tag.as_string() {
tags.push(tag);
}
}
}
if tags.is_empty()
&& let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into())
&& let Some(language) = language.as_string()
{
tags.push(language);
}
if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) {
lightcraft_ui_egui::i18n::set_default_language(language);
log::info!("lightcraft: default language {language:?} from the browser's languages");
}
}
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
#[wasm_bindgen]
pub fn start(chinese_font: Vec<u8>) {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
install_browser_language();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
if chinese_font.is_empty() {
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
+4
View File
@@ -57,3 +57,7 @@ sync_github_name = false
[people.egegl]
real_name = "Ege Gursel"
sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
+9
View File
@@ -233,6 +233,15 @@ mod tests {
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
}
#[test]
fn impossible_times_are_rejected_not_rolled_over() {
for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
assert_eq!(normalize_iso(bad), None, "{bad}");
assert_eq!(shift_iso(bad, 5), None, "{bad}");
}
assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59"));
}
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
+3
View File
@@ -296,6 +296,9 @@ pub fn iso_seconds(s: &str) -> Option<i64> {
}
let (hh, mm, ss) =
if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
if hh >= 24 || mm >= 60 || ss >= 60 {
return None;
}
// days from civil (proleptic Gregorian)
let y2 = if m <= 2 { y - 1 } else { y };
let era = y2.div_euclid(400);
+1 -1
View File
@@ -242,7 +242,7 @@ impl Store for MemStore {
}
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests_staging {
use super::*;
+6 -3
View File
@@ -99,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -
}
pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result<Decoded> {
let n = raw.width * raw.height;
let ch = raw.model.channels() + raw.alpha as usize;
if n == 0 || raw.buf.len() < n * ch {
let buf_len =
crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?;
if (raw.buf.len() as u64) < buf_len {
return Err(Error::Malformed(format, "sample buffer too short".into()));
}
let float = matches!(raw.buf, Buf::F32(_));
@@ -141,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
let (image, alpha) = match opts.max_size {
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
// (Lanczos at 2:1 and more as well: a box average there halves the contrast of
// detail near the output's resolution limit and aliases what is finer)
let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell };
let img = fit(&image, mw as usize, mh as usize, f);
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
(img, alpha)
+26
View File
@@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
source_height: d.source_height,
})
}
/// Checked sample-buffer length `width × height × ch`, in u64: `None` on overflow or a zero
/// dimension. Image size is capped by `DecodeOptions::max_pixels` alone; once the buffer is known to
/// hold this many samples, every later `usize` product is bounded by an allocation that exists.
pub(crate) fn checked_sample_buf_len(width: usize, height: usize, ch: usize) -> Option<u64> {
(width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0)
}
#[cfg(test)]
mod checked_arithmetic_tests {
use super::*;
#[test]
fn checked_sample_buf_len_wrap_inputs_rejected() {
assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None);
assert_eq!(checked_sample_buf_len(0, 1, 3), None);
}
#[test]
fn checked_sample_buf_len_normal_accepted() {
assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300));
assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1));
// 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count.
assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000));
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
//! script only reads a local directory (no network).
//!
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
//! watermark falls back to it there. Native builds embed every font.
use std::fmt::Write as _;
@@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+24 -14
View File
@@ -34,7 +34,8 @@ use lightcraft_raw::{
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
pub const LOOK_VERSION: u32 = 9;
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
pub const LOOK_VERSION: u32 = 10;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -107,14 +108,13 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
}
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
/// to its proxy, so it fits only the tone around them.
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
let p = &raw.color.profile;
raw.format == RawFormat::Dng
&& !t.matrix_is_fallback
&& identity_tone_curve(p.tone_curve.as_ref())
&& p.look_table.is_none()
&& p.gain_table_map.is_none()
raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
@@ -424,9 +424,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
/// the plain render ([`clips_more`]).
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
/// clip more highlights than the plain render ([`clips_more`]).
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
let refuse = |why: &str| {
@@ -500,8 +500,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
}
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
/// small thumbnail is not fitted.
const MIN_PREVIEW_FRACTION: f64 = 0.9;
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
const MIN_PREVIEW_FRACTION: f64 = 0.1;
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
@@ -3959,7 +3960,7 @@ mod tests {
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, no look table; and no other raw format.
/// identity, with or without a look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
@@ -3981,6 +3982,15 @@ mod tests {
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
}
/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
#[test]
fn dng_tone_fit_takes_lightroom_sized_previews() {
let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
assert!(1024.0 >= floor(7008.0));
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
}
+9 -24
View File
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
/// dimensions and content hash, cached sources dropped. The app runs the same steps
/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p)?;
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
Some(Source::File { path }) => Some((*id, path)),
_ => None,
})
.collect();
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
use crate::cmd::reload as r;
let items = r::plan(s, p)?;
let probe = r::file_probe(s);
let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
let reloaded = r::apply(s, items, results)?;
Ok(r::to_json(&reloaded))
}
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk",
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
has_selection,
reload
),
+27 -13
View File
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
Ok((a as u32, b as u32))
}
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
/// How a new aspect ratio is turned (issue #2253).
#[derive(Clone, Copy, PartialEq)]
enum Turn {
/// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
/// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
LikeCrop,
/// As given: a custom `[w, h]` taller than wide is a portrait crop.
AsGiven,
}
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
let r = d.crop.geometry.rect_px(w, h);
let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
d.crop.aspect = aspect;
let a = aspect.map(|(aw, ah)| {
// orientation of the crop follows the image (landscape image → landscape crop)
let (aw, ah) = (aw as f64, ah as f64);
if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
});
let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
@@ -628,11 +637,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Aspect",
[],
None,
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
has_active,
|s, p| {
let id = active(s, "crop.aspect")?;
let (w, h) = crop_dims(s, id);
// checked first, whatever the aspect: a bad value is an error on every path
let turn = match p.get("orientation") {
Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
Some(Value::String(o)) if o == "given" => Turn::AsGiven,
Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
None => Turn::LikeCrop,
};
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
let lock_current = match p.get("aspect").and_then(Value::as_str) {
Some("current") => true,
@@ -667,7 +684,7 @@ pub fn specs() -> Vec<CommandSpec> {
if aspect.is_none() {
d.crop.aspect = None;
} else {
set_aspect(d, w, h, aspect);
set_aspect(d, w, h, aspect, turn);
}
Ok(())
})
@@ -1103,11 +1120,8 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let c = "history.restore";
let id = active(s, c)?;
let i = f64_req(p, "index", c)?;
if !(i.is_finite() && i >= 0.0) {
return Err(bad(c, "no such step"));
}
go_to_history_step(s, c, id, i as usize)
let i = index_req(p, "index", c)?;
go_to_history_step(s, c, id, i)
}
),
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
+27 -4
View File
@@ -17,6 +17,12 @@ impl Session {
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
/// call's own (`preset` itself removed). Unknown preset names are an error.
pub fn export_params(&self, p: &Value) -> std::result::Result<Value, String> {
self.export_params_or(p, crate::export::ExportFormat::default())
}
/// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
/// (see [`crate::export::ExportOptions::export_format`]).
pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result<Value, String> {
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
let (preset, _) = self
.all_export_presets()
@@ -24,12 +30,17 @@ impl Session {
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
let mut out = crate::export::ExportOptions::known_keys(&preset.params);
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
o.insert(k.clone(), v.clone());
}
}
// the preset's depth is judged against the format written, known only now (`path`); a
// depth the call passes itself is the caller's, and validated as such
if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
}
Ok(out)
}
}
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut params = match p.get("params") {
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
// a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
// written before #1952 and is dropped, as Export with Previous does; without `format`
// it is judged when the preset exports
None => {
let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
let mut last = crate::export::ExportOptions::known_keys(last);
if last.get("format").is_some_and(Value::is_string) {
crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
}
last
}
};
if let Some(o) = params.as_object_mut() {
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
}
// a preset that app.export would refuse is refused here, where the typo is on screen
crate::export::ExportOptions::validate(ID, &params)?;
// a preset that app.export would refuse is refused here, where the typo is on screen; without
// `format`, its `bitDepth` is judged when it exports, against the format written then
let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(&params, Default::default()));
crate::export::ExportOptions::validate_as(ID, &params, format)?;
let preset = ExportPreset { name: name.to_string(), params };
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = preset,
+49 -3
View File
@@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
// the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest
// larger town, which may be another municipality; past this many metres City is left empty (#2242)
let max_distance = match p.get("maxDistance") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?),
};
let ids = s.targets(p)?;
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
@@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
}
t.progress(n as u64, todo.len() as u64);
}
let Some(place) = names.reverse(*at) else { continue };
let Some(mut place) = names.reverse(*at) else { continue };
// too far from the town found: State and Country still hold, City (and below) doesn't
let too_far = max_distance.is_some_and(|m| place.distance > m);
let nearest_city = too_far.then(|| std::mem::take(&mut place.city));
if too_far {
place.sublocation.clear();
}
let Some(ph) = s.catalog.photo(*id) else { continue };
let mut meta = ph.meta.clone();
fill(&mut meta, &place, overwrite);
if meta != ph.meta {
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
found.push(json!({"id": id.0, "place": place}));
let mut entry = json!({"id": id.0, "place": place});
if let Some(city) = nearest_city {
entry["nearestCity"] = json!(city);
}
found.push(entry);
}
drop(task);
let changed = ops.len();
@@ -271,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill Location from GPS",
["Photo"],
None,
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City",
has_selection,
reverse
),
@@ -343,6 +359,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a
/// village finds the nearest larger town. `maxDistance` leaves City empty past that distance
/// (State and Country still fill) and says which town it was.
#[test]
fn max_distance_leaves_a_far_town_out_of_city() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let dir = dataset("maxdist");
// about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris
set_gps(&mut s, ids[0], Some((48.90, 2.35)), "");
set_gps(&mut s, ids[1], Some((48.855, 2.35)), "");
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap();
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France"));
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough");
let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap();
assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}");
assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0);
// without it, the nearest town fills City as before
s.undo_step().unwrap();
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris");
// hostile values are refused, not taken as "no limit"
for bad in [json!(-1), json!(0), json!("far"), json!(true)] {
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err();
assert!(e.to_string().contains("maxDistance"), "{e}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explains_missing_data_and_gps() {
let mut s = Session::with_demo();
+1 -1
View File
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
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| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
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| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings,"skipped":data.skipped}))
}),
cmd!(
"library.importLightroom",
+6 -9
View File
@@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
/// The selected mask's adjustments on the active photo.
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = p
.get("id")
.and_then(Value::as_u64)
.and_then(|v| u32::try_from(v).ok())
.or(s.active_mask)
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
let mid = super::masks::mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
}
@@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
// the masks: `ids`, `all`, else `id` or the selected one
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
d.masks.iter().map(|m| m.id).collect()
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
} else {
vec![selected_mask(s, p, C)?.id]
match p.get("ids") {
None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id],
Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::<Result<_>>()?,
Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")),
}
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
+33 -13
View File
@@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br
out
}
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result<u32> {
v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
}
/// Omitted or null targets keep the command's default; malformed explicit targets never do.
fn optional_mask_id(p: &Value, c: &str) -> Result<Option<u32>> {
p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
}
pub(super) fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
}
fn optional_index(p: &Value, key: &str, c: &str) -> Result<Option<usize>> {
p.get(key)
.filter(|v| !v.is_null())
.map(|v| {
v.as_u64()
.and_then(|v| usize::try_from(v).ok())
.ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
})
.transpose()
}
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result<usize> {
@@ -307,7 +327,7 @@ pub fn specs() -> Vec<CommandSpec> {
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
@@ -363,7 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
let shape: MaskShape =
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
let mid = mask_id(p, s.active_mask, "mask.update")?;
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.update")?;
@@ -381,6 +401,8 @@ pub fn specs() -> Vec<CommandSpec> {
has_active,
|s, p| {
let c = "mask.sampleColor";
let mid = mask_id(p, s.active_mask, c)?;
let comp = optional_index(p, "component", c)?;
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
@@ -390,8 +412,6 @@ pub fn specs() -> Vec<CommandSpec> {
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
@@ -482,8 +502,8 @@ pub fn specs() -> Vec<CommandSpec> {
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
let k = match p.get("component").and_then(Value::as_u64) {
Some(k) => k as usize,
let k = match optional_index(p, "component", c)? {
Some(k) => k,
None => m
.components
.iter()
@@ -604,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
s.active_mask = optional_mask_id(p, "mask.select")?;
ok()
}),
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
@@ -684,7 +704,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option<MaskOp> = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
@@ -879,8 +899,8 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
s.active_spot = match p.get("index").and_then(Value::as_u64) {
Some(i) if (i as usize) < n => Some(i as usize),
s.active_spot = match optional_index(p, "index", "spot.select")? {
Some(i) if i < n => Some(i),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
@@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize {
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
+37 -4
View File
@@ -38,6 +38,7 @@ pub mod previews;
mod print;
mod query;
pub mod recheck;
pub mod reload;
mod slideshow;
mod sync;
mod xmp;
@@ -198,12 +199,18 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
/// saturates to 0), just as `history.restore` already refuses a negative step index.
/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral
/// floating-point values for compatibility, without truncating or saturating their conversion.
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer"));
if let Some(i) = p.get(key).and_then(Value::as_u64) {
return usize::try_from(i).map_err(|_| invalid());
}
let i = f64_req(p, key, cmd)?;
if !(i.is_finite() && i >= 0.0) {
return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
// The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets.
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) {
return Err(invalid());
}
Ok(i as usize)
}
@@ -223,3 +230,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option<lightcraft_geom::Point> {
let a = p.get(key)?.as_array()?;
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::index_req;
#[test]
fn command_indices_preserve_integer_precision_and_check_usize_bounds() {
for i in [0, 1, usize::MAX] {
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i);
}
#[cfg(target_pointer_width = "64")]
{
let i = 9_007_199_254_740_993usize;
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64");
}
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate");
#[cfg(target_pointer_width = "32")]
{
assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err());
assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX);
}
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a
//! raw re-decode takes a second or more per file, and a selection of them froze the window while
//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads
//! their files without the session (cancellable, with progress), and [`apply`] commits what came
//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once.
//!
//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so
//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later
//! has already been applied to it: an older read never replaces a newer one.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile
/// is skipped by [`apply`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
pub id: PhotoId,
pub path: String,
/// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]).
pub ticket: u64,
}
/// Which reload each photo last took its file information from, so a reload started earlier but
/// finishing later doesn't put back what its file was before (session memory, not saved).
#[derive(Debug, Default)]
pub struct Tickets {
last: u64,
applied: std::collections::BTreeMap<PhotoId, u64>,
}
/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No
/// file-system calls.
pub fn plan(s: &mut Session, p: &Value) -> Result<Vec<Item>> {
let ids = s.targets(p)?;
s.reload_tickets.last = s.reload_tickets.last.saturating_add(1);
let ticket = s.reload_tickets.last;
Ok(ids
.into_iter()
.filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) {
Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }),
_ => None,
})
.collect())
}
/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file
/// access, where a reload reads nothing but the file name, as the command does).
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session.
/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further
/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]).
pub fn probe(
probe: Option<&FileProbe>,
items: &[Item],
cancel: &AtomicBool,
progress: &dyn Fn(usize),
) -> Vec<std::result::Result<ProbeInfo, String>> {
let paths: Vec<String> = items.iter().map(|i| i.path.clone()).collect();
let done = AtomicUsize::new(0);
#[cfg(not(target_arch = "wasm32"))]
let r = std::thread::scope(|sc| {
let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel));
while !h.is_finished() {
progress(done.load(Ordering::Relaxed));
std::thread::sleep(std::time::Duration::from_millis(50));
}
// a probe that panicked (already logged): nothing is known about any file
h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect())
});
#[cfg(target_arch = "wasm32")]
let r = crate::import::probe_all(probe, &paths, &done, cancel);
progress(done.load(Ordering::Relaxed));
r
}
/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual
/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed
/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns
/// the photos that changed.
pub fn apply(s: &mut Session, items: Vec<Item>, results: Vec<std::result::Result<ProbeInfo, String>>) -> Result<Vec<PhotoId>> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for (item, info) in items.into_iter().zip(results) {
let Ok(info) = info else { continue };
let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path));
let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket);
if !current || superseded {
continue;
}
s.reload_tickets.applied.insert(item.id, item.ticket);
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(reloaded)
}
/// `{reloaded: [ids]}`.
pub fn to_json(reloaded: &[PhotoId]) -> Value {
json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()})
}
+146 -6
View File
@@ -816,8 +816,17 @@ impl ExportOptions {
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
/// for good after an update.
/// for good after an update. A `bitDepth` the stored format doesn't have (written at the
/// format's own depth before issue #1952) is dropped too.
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
let mut p = Self::known_keys(p);
Self::drop_unfit_depth(&mut p, ExportFormat::default());
p
}
/// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be
/// known once the call's own params (`path`) are merged in.
pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
@@ -828,13 +837,44 @@ impl ExportOptions {
p
}
/// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with
/// `fallback`) doesn't have: it was written at the format's own depth before issue #1952.
pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) {
let format = Self::export_format(p, fallback);
if let Some(o) = p.as_object_mut()
&& let Some(depth) = o.get("bitDepth").and_then(whole)
&& !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth)
{
o.remove("bitDepth");
}
}
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
/// are accepted and left to the caller.
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
Self::validate("app.export", p)?;
Ok(Self::from_json(p))
Self::from_params_or(p, ExportFormat::default())
}
/// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no
/// format; the format returned is [`ExportOptions::export_format`], the one written.
pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result<Self> {
let format = Self::export_format(p, fallback);
Self::validate_as("app.export", p, Some(format))?;
Ok(Self { format, ..Self::from_json(p) })
}
/// The format `app.export` writes: `format` (`null`: the default), else the one the extension of
/// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there,
/// the headless backend PNG), else the default.
pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat {
use serde_json::Value;
match (p.get("format"), p.get("path").and_then(Value::as_str)) {
(Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(),
(None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback),
(None, None) => ExportFormat::default(),
}
}
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
@@ -842,6 +882,12 @@ impl ExportOptions {
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default())))
}
/// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported
/// (`None`: not known yet, as for a preset without `format`).
pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option<ExportFormat>) -> crate::Result<()> {
use serde_json::Value;
let bad = |msg: String| crate::cmd::bad(cmd, msg);
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
@@ -949,6 +995,18 @@ impl ExportOptions {
_ => return Err(bad(unknown_key("parameter", k, &known))),
}
}
// issue #1952: a depth the format doesn't have would be written at the format's own depth
if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) {
let mut depths: Vec<u8> = Self::bit_depths(format).iter().map(|d| d.0).collect();
if !depths.iter().any(|d| i64::from(*d) == depth) {
depths.sort_unstable();
let mut list: Vec<String> = depths.iter().map(u8::to_string).collect();
let last = list.pop().unwrap_or_default();
let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) };
let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
return Err(bad(format!("`bitDepth` must be {list} for {name}")));
}
}
Ok(())
}
@@ -2021,6 +2079,9 @@ fn run_batch_with(
ahead: lanes,
};
std::thread::scope(|sc| {
// stops the lanes however this thread leaves the scope: a panic in `progress` or the placer
// would otherwise leave them waiting and the scope's join would never return
let _stop = StopOnDrop(&queue);
for k in 1..lanes {
let q = &queue;
// a lane that can't start leaves its photos to the others (and to this thread)
@@ -2038,13 +2099,20 @@ fn run_batch_with(
}
Ok::<_, String>(())
};
let r = placed();
queue.stop();
r
placed()
})?;
Ok(std::mem::take(&mut placer.out))
}
/// Stops a [`Queue`] when dropped, also while unwinding from a panic.
struct StopOnDrop<'a>(&'a Queue);
impl Drop for StopOnDrop<'_> {
fn drop(&mut self) {
self.0.stop();
}
}
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
/// becomes that photo's error, not the batch's end.
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
@@ -3212,6 +3280,7 @@ mod tests {
resize: Some(Resize::default()),
limit_kb: Some(500),
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
format: ExportFormat::Png,
bit_depth: Some(16),
..Default::default()
};
@@ -3248,6 +3317,51 @@ mod tests {
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
}
/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers
/// it), not written at the format's own depth; the error names the depths the format takes.
#[test]
fn bit_depth_must_suit_the_format() {
use serde_json::json;
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg"));
assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png"));
assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff"));
// no `format`: the one `path` names, else the default (JPEG)
assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png"));
assert!(err(json!({"bitDepth": 16})).contains("for jpeg"));
assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok());
// a path naming no format, or a `null` format, exports the default too
assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg"));
assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg"));
assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg"));
// or the caller's own format for such a path, which is the one returned
let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png);
assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png"));
assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png);
assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff);
// stored settings (a preset, Export with Previous) lose such a depth instead of failing
let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16}));
assert_eq!(stored.get("bitDepth"), None);
assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32);
for f in [
ExportFormat::Jpeg,
ExportFormat::Png,
ExportFormat::Tiff,
ExportFormat::Psd,
ExportFormat::Webp,
ExportFormat::Avif,
ExportFormat::Jxl,
ExportFormat::Original,
ExportFormat::Dng,
ExportFormat::RenderedDng,
] {
for &(b, _) in ExportOptions::bit_depths(f) {
let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() };
assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}");
}
}
}
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
/// folder; it is checked like the other params and kept in presets and Export with Previous.
#[test]
@@ -3474,6 +3588,32 @@ mod tests {
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
}
// A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it
#[test]
fn a_panic_in_progress_does_not_hang_the_lanes() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40}));
let items = prepare_batch(&mut s, &ids, &o).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let to = Destination { dir: "out".into(), exact: None };
let mut write = |_: &str, _: &[u8]| Ok(());
let mut calls = 0;
let mut progress = |_: usize, _: &str| {
calls += 1;
assert!(calls < 2, "progress panics on its second call");
true
};
let r = crate::guard::catch("test batch", || {
run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)
});
tx.send(r.and_then(|r| r)).ok();
});
let r = rx.recv_timeout(std::time::Duration::from_secs(60));
assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}");
}
// A photo that fails is reported in its place while the others are exported; with
// stop_on_error the batch ends there and nothing after it is written
#[test]
+22 -3
View File
@@ -181,9 +181,16 @@ impl Drop for Downloads {
for job in self.jobs.values() {
job.cancel.store(true, Ordering::Relaxed);
}
let started = std::time::Instant::now();
while self.running() && started.elapsed() < Duration::from_secs(1) {
std::thread::sleep(Duration::from_millis(10));
// Give a running download a moment to notice. Not on the web: there are no download
// threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"),
// which killed the page when a `Session` was replaced after its library failed to open
// (issue #1507).
#[cfg(not(target_arch = "wasm32"))]
{
let started = std::time::Instant::now();
while self.running() && started.elapsed() < Duration::from_secs(1) {
std::thread::sleep(Duration::from_millis(10));
}
}
}
}
@@ -317,6 +324,18 @@ mod tests {
}
}
/// Dropping a `Downloads` with nothing running (every session has one) returns at once: the
/// wait for running threads is the only part that takes time, and the web has no threads.
#[test]
fn dropping_idle_downloads_returns_at_once() {
let t = std::time::Instant::now();
drop(Downloads::default());
let mut d = Downloads::default();
d.set_outcome("m", State::Failed("x".into()));
drop(d);
assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed());
}
#[test]
fn a_running_download_shows_in_activity_and_cancels() {
use crate::activity::{Activity, Unit};
+1 -1
View File
@@ -105,7 +105,7 @@ mod tests {
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
use ab_glyph::Font;
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] {
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
+18 -6
View File
@@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
pub(crate) fn probe_all(
probe: Option<&crate::media::FileProbe>,
paths: &[String],
done: &std::sync::atomic::AtomicUsize,
@@ -965,9 +965,11 @@ impl ImportJob {
}
/// What the library held when the import was set up, by path and by content: made once, with
/// the first files (on the thread that prepares them), from the library as it was then.
fn look_up_library(&mut self) {
let Some((library, fresh)) = self.library.take() else { return };
/// the first files (on the thread that prepares them), from the library as it was then. The
/// snapshot comes back: the files the import is adding are ranked against it for the ownership
/// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]).
fn look_up_library(&mut self) -> Option<lightcraft_catalog::Catalog> {
let (library, fresh) = self.library.take()?;
for p in library.photos() {
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
if p.deleted && fresh {
@@ -980,12 +982,14 @@ impl ImportJob {
self.by_hash.insert(h.clone(), Some(p.id));
}
}
Some(library)
}
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
use std::sync::atomic::Ordering::Relaxed;
self.look_up_library();
// the library as it was, kept for the ownership of the shared stem sidecars below
let library = self.look_up_library();
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
@@ -1025,6 +1029,13 @@ 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();
// Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones
// arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar
// and never the raw's packet (issue #1905).
let stems = crate::sidecar::ImportStems::for_import(
library.as_ref(),
todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))),
);
// 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.
@@ -1063,7 +1074,7 @@ impl ImportJob {
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, self.naming)
let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
@@ -1566,6 +1577,7 @@ impl Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
+64 -11
View File
@@ -186,6 +186,10 @@ pub struct Interaction {
pub label: String,
pub photo: PhotoId,
pub original: Arc<DevelopSettings>,
/// Whether an edit during the interaction changed the crop itself. If none did, the crop the
/// photo shows was fitted by Constrain Crop, and the final commit starts again from the original
/// crop, so Auto Sync carries the edit and not the active photo's fitted crop.
pub crop_edited: bool,
}
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
@@ -240,6 +244,8 @@ pub struct Session {
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]).
pub(crate) reload_tickets: cmd::reload::Tickets,
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>,
@@ -434,6 +440,7 @@ impl Session {
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
reload_tickets: Default::default(),
unsaved_sidecars: Default::default(),
denoise: Default::default(),
face_models_dir: None,
@@ -881,22 +888,58 @@ impl Session {
/// undo step; otherwise it is committed with a history entry.
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
let now = (self.clock)();
let settings = Arc::new(settings);
if let Some(i) = &self.interaction
// Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990)
let requested = settings;
let settings = Arc::new(self.fit_constrained_crop(id, requested.clone()));
let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry);
if let Some(i) = &mut self.interaction
&& i.photo == id
{
i.crop_edited |= crop_edited;
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
ops.extend(self.auto_sync_ops(id, &settings, label));
ops.extend(self.auto_sync_ops(id, &requested, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
/// history / snapshot restores.
/// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue
/// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections,
/// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner
/// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as
/// drawn (the render still constrains it).
fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings {
if !d.geometry.constrain_crop {
return d;
}
let Some(photo) = self.catalog.photo(id) else { return d };
let before = &photo.develop;
let turned_on = !before.geometry.constrain_crop;
let warp_changed = before.geometry != d.geometry
|| before.optics != d.optics
|| before.orientation != d.orientation
|| ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s));
let crop_edited = before.crop.geometry != d.crop.geometry;
if !(turned_on || (warp_changed && !crop_edited)) {
return d;
}
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
photo.width.max(1) as usize,
photo.height.max(1) as usize,
&d,
true,
photo.embedded_lens.as_ref(),
);
d.crop.geometry.rect = frame.crop.rect;
d
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,
/// before its crop is fitted) over to the other selected photos: only the settings that changed;
/// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process
/// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each
/// photo's own crop: the active photo's fitted crop is never copied over another's.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
@@ -922,7 +965,7 @@ impl Session {
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
@@ -944,7 +987,7 @@ impl Session {
}
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
let original = self.develop_of(id).unwrap_or_default();
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
Ok(())
}
@@ -955,9 +998,15 @@ impl Session {
if *cur == *i.original {
return Ok(());
}
// Restore the original silently, then commit the final value as one step.
// Restore the original silently, then commit the final value as one step. A crop nobody
// edited goes back to the original's: Constrain Crop fits it again for the final warp, and
// Auto Sync carries the edit, not the active photo's fitted crop (review of #1990).
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
self.set_develop(i.photo, (*cur).clone(), &i.label)
let mut last = (*cur).clone();
if !i.crop_edited {
last.crop.geometry = i.original.crop.geometry;
}
self.set_develop(i.photo, last, &i.label)
}
pub fn cancel_interaction(&mut self) -> Result<()> {
@@ -1249,8 +1298,12 @@ mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_constrain_crop;
#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
mod tests_crop_aspect;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
+20 -5
View File
@@ -84,6 +84,9 @@ pub struct Library {
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
/// exist only in memory ([`Session::unsaved`]).
pub unsaved_error: Option<String>,
/// Why the last write of presets.json failed; `None` once it is written. While set, the preset
/// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266).
presets_error: Option<String>,
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
@@ -577,6 +580,7 @@ impl Session {
report,
last_error: None,
unsaved_error: None,
presets_error: None,
retry_at: None,
forgot_local: None,
presets_saved,
@@ -651,10 +655,17 @@ impl Session {
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
lib.presets_error = None;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
// the state stays stale: the next save tries again
// the state stays stale: retried by the next command and the frame loop (backing off);
// meanwhile the presets count as unsaved, so the UI warns and a backup refuses
log::error!("library: presets: {e}");
lib.presets_error = Some(format!("presets: {e}"));
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
} else {
if lib.presets_error.take().is_some() {
log::info!("library: presets written to disk");
}
lib.presets_saved = state;
}
Ok(())
@@ -664,7 +675,8 @@ impl Session {
/// changed outside [`Session::execute`]).
pub fn persist_if_dirty(&mut self) {
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some());
if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off {
let _ = self.persist();
} else if let Some(lib) = self.library.as_mut() {
poll_compaction(lib);
@@ -672,11 +684,14 @@ impl Session {
}
/// Changes applied in memory but not yet written to disk because saving failed: the number of
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
/// every command and by [`Session::persist_if_dirty`].
/// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library
/// is open). Retried by every command and by [`Session::persist_if_dirty`].
pub fn unsaved(&self) -> Option<(usize, &str)> {
let lib = self.library.as_ref()?;
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty());
let presets = lib.presets_error.as_deref();
let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some());
ops.or(presets).map(|why| (n, why))
}
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
+2
View File
@@ -524,6 +524,7 @@ mod tests {
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
skipped: Vec::new(),
}
}
@@ -547,6 +548,7 @@ mod tests {
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()],
skipped: Vec::new(),
}
}
+191 -12
View File
@@ -8,7 +8,7 @@ use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
use lightcraft_geom::{Affine, Orientation, Point, Rect};
use serde::{Deserialize, Serialize};
@@ -181,6 +181,34 @@ pub struct CatalogImport {
pub members: Vec<Row>,
pub collection_content: Vec<Row>,
pub warnings: Vec<String>,
/// Catalog images left out because their records are incomplete; reported as data (not text)
/// so each front end can say so in its own language.
#[serde(default)]
pub skipped: Vec<SkippedImage>,
}
/// A catalog image that was not imported, and why.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkippedImage {
/// The image's `Adobe_images.id_local`.
pub image: i64,
pub reason: SkipReason,
}
/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkipReason {
/// Its `AgLibraryFile` record is gone.
MissingFile,
/// Its file's `AgLibraryFolder` record is gone.
MissingFolder,
/// Its folder's `AgLibraryRootFolder` record is gone.
MissingRoot,
/// Its file name or root folder path is empty.
IncompletePath,
/// A virtual copy whose master was skipped.
MasterSkipped,
}
#[derive(Clone, Default, Serialize, Deserialize)]
@@ -408,6 +436,9 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> {
read_with_progress(path, &cancel, &total, &done)
}
/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and
/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and
/// listed in [`CatalogImport::skipped`] instead of failing the read.
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result<CatalogImport, String> {
// 13 catalog tables plus one validation stage; ImportJob adds one unit per original path.
total.store(14, Ordering::Relaxed);
@@ -416,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
let main = bytes(path)?;
let main = FileSource::open(path)?;
let wal = match std::fs::metadata(&wal_path) {
Ok(_) => bytes(&wal_path)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.to_string()),
};
let wal_len = committed_wal_len(&main, &wal)?;
let wal_len = committed_wal_len(main.header(), &wal)?;
ensure_read_active(cancel)?;
let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
@@ -522,19 +553,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
warnings,
skipped: Vec::new(),
};
// images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
// these behind and ignores them); they and their virtual copies are skipped and reported
// instead of failing the import
let mut orphans = HashSet::new();
for image in table("Adobe_images", true)? {
ensure_read_active(cancel)?;
let id = number(&image, "id_local");
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
Ok((file, folder, root))
});
let (file, folder, root) = match record {
Ok(found) => found,
Err(reason) => {
out.skipped.push(SkippedImage { image: id, reason });
orphans.insert(id);
continue;
}
};
let root_id = number(root, "id_local");
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
return Err(format!("image {id}: incomplete original path"));
out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
orphans.insert(id);
continue;
}
let (photo_path, original) = original_path(root_path, folder_path, name, path);
if let Original::Refused(why) = &original
@@ -562,11 +610,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
original,
});
}
drop_copies_of(&mut out, &orphans);
refuse_non_media(&mut out);
ensure_read_active(cancel)?;
reader.db.ensure_unchanged()?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if wal_before != wal_after {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
Ok(out)
}
/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
/// no original of its own.
fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet<i64>) {
if masters.is_empty() {
return;
}
let mut skipped = Vec::new();
data.photos.retain(|p| {
let master = number(&p.image, "masterImage");
// 0 marks a master, never a copy
if master == 0 || !masters.contains(&master) {
return true;
}
skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
false
});
data.skipped.extend(skipped);
}
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
/// import's warnings.
///
@@ -785,6 +858,8 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
/// Turn the read catalog into library records in one undo step and return the import report
/// (added, missing, failed, warnings and skipped images).
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
@@ -1018,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
@@ -1062,6 +1137,7 @@ mod tests {
],
collection_content: Vec::new(),
warnings: Vec::new(),
skipped: Vec::new(),
}
}
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
@@ -1521,6 +1597,16 @@ mod tests {
}
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
native_catalog_with_last_table_page(tables, None)
}
/// [`native_catalog`] whose last table claims `last_page` as its root page.
fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec<SqlValue>)], last_page: Option<i64>) -> Vec<u8> {
let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
native_catalog_rows(&tables, last_page)
}
/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last
/// table's root page.
fn native_catalog_rows(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], last_page: Option<i64>) -> Vec<u8> {
use SqlValue::{Integer as I, Text as T};
let mut bytes = vec![0u8; 5 * 2048];
bytes[..16].copy_from_slice(b"SQLite format 3\0");
@@ -1534,15 +1620,15 @@ mod tests {
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
let mut schema = Vec::new();
for (i, (name, columns, row)) in tables.iter().enumerate() {
for (i, (name, columns, rows)) in tables.iter().enumerate() {
schema.push(vec![
T("table".into()),
T((*name).into()),
T((*name).into()),
I(i as i64 + 2),
I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
T(format!("CREATE TABLE {name} ({columns})")),
]);
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
}
leaf(&mut bytes[..2048], 100, &schema);
bytes
@@ -1576,11 +1662,104 @@ mod tests {
assert_eq!(catalog.photos[0].uuid, "fixture-image");
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
assert!(catalog.photos[0].path.ends_with("test.ARW"));
// Put the required image table beyond the old 1 GiB cutoff without allocating it.
// A main-file-sized allocation would defeat the purpose of the page-backed reader.
// The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
#[cfg(unix)]
{
let far_offset = 1u64 << 30;
let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
use std::io::{Seek, SeekFrom, Write};
let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
sparse.write_all(&bytes).unwrap();
sparse.set_len(far_offset + 2048).unwrap();
sparse.seek(SeekFrom::Start(far_offset)).unwrap();
sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
drop(sparse);
let large = read(&path).unwrap();
assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
let mut session = crate::Session::new();
session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(session.catalog.len(), 1);
assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.catalog.is_empty());
}
std::fs::write(&path, b"bad catalog").unwrap();
assert!(read(&path).is_err());
std::fs::remove_file(path).unwrap();
}
/// Reads `tables` as a catalog file and returns what the reader made of it.
fn read_tables(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], name: &str) -> CatalogImport {
let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
std::fs::write(&path, native_catalog_rows(tables, None)).unwrap();
let catalog = read(&path).unwrap();
std::fs::remove_file(path).unwrap();
catalog
}
/// `tables` with each row as a one-row table, for [`native_catalog_rows`].
fn one_row(tables: Vec<(&'static str, &'static str, Vec<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> {
tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
}
#[test]
fn images_with_incomplete_records_are_skipped_not_fatal() {
use SqlValue::{Integer as I, Text as T};
// (table, column, value): the record that breaks, and the reason the import gives
let cases = [
(3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
(2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
(1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
(2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
];
for (table, column, value, reason) in cases {
let mut tables = one_row(photo_tables(("/catalog/", "")));
tables[table].2[0][column] = value;
let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
assert!(catalog.photos.is_empty(), "{reason:?}");
assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
}
}
#[test]
fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
use SqlValue::{Integer as I, Null as N, Text as T};
let mut tables = one_row(photo_tables(("/catalog/", "")));
tables[3] = (
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
vec![
// the master's file record is gone, so it is skipped …
vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
// … and its virtual copy goes with it, although its own file record is there
vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
],
);
let catalog = read_tables(&tables, "skipped-copy");
assert!(catalog.photos.is_empty());
assert_eq!(
catalog.skipped,
[SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
);
validate(&catalog).unwrap();
}
#[test]
fn skipped_images_are_in_the_import_report() {
let mut data = sample();
data.photos.retain(|p| p.source_id != 1);
drop_copies_of(&mut data, &HashSet::from([1]));
data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
let mut s = crate::Session::new();
let report = apply(&mut s, data, false).unwrap();
assert_eq!(report["photos"], 0);
assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
assert_eq!(report["warnings"], json!([]));
}
#[test]
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
+2
View File
@@ -355,6 +355,7 @@ mod tests {
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
skipped: Vec::new(),
}
}
@@ -399,6 +400,7 @@ mod tests {
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
skipped: Vec::new(),
},
normal: Prepared::default(),
index: ImportIndex::default(),
+212 -8
View File
@@ -6,7 +6,79 @@
//! than a SQL engine. It never writes its input and has no SQLite runtime
//! dependency.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::fs::{File, Metadata};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const MAX_DATABASE_BYTES: u64 = 16 << 30;
/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file.
pub struct FileSource {
file: Mutex<File>,
path: PathBuf,
before: Metadata,
header: Vec<u8>,
}
impl FileSource {
pub fn open(path: &Path) -> Result<Self, String> {
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
return Err("SQLite catalog must be a regular file".into());
}
let mut file = File::open(path).map_err(|e| e.to_string())?;
let before = file.metadata().map_err(|e| e.to_string())?;
if !before.is_file() {
return Err("SQLite catalog must be a regular file".into());
}
if before.len() > MAX_DATABASE_BYTES {
return Err("catalog exceeds 16 GiB import limit".into());
}
let mut header = vec![0; 100];
file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?;
let source = Self { file: Mutex::new(file), path: path.into(), before, header };
source.ensure_unchanged()?;
Ok(source)
}
pub fn header(&self) -> &[u8] {
&self.header
}
pub fn ensure_unchanged(&self) -> Result<(), String> {
let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
let handle = file.metadata().map_err(|e| e.to_string())?;
let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?;
if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
Ok(())
}
fn page(&self, page_id: u32, size: usize) -> Result<Vec<u8>, String> {
let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?;
if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) {
return Err(format!("page {page_id} is truncated"));
}
let mut out = vec![0; size];
let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?;
Ok(out)
}
}
fn same_metadata(a: &Metadata, b: &Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if a.dev() != b.dev() || a.ino() != b.ino() {
return false;
}
}
a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok()
}
const MAX_PAGE_ID: u32 = 16_777_216;
const MAX_COLUMNS: usize = 16_384;
@@ -44,6 +116,7 @@ pub struct LiveTable {
/// Read-only SQLite database image, with a committed WAL overlay if supplied.
pub struct Database {
main: Vec<u8>,
file: Option<FileSource>,
overlay: HashMap<u32, Vec<u8>>,
page_size: usize,
reserved: usize,
@@ -84,17 +157,34 @@ impl Database {
/// The WAL argument must include its 32-byte header and only complete
/// frames through a commit. Frames are overlaid by page number, with the
/// final commit's database-size field defining the visible page count.
#[cfg(test)]
pub fn open_with_wal(main: Vec<u8>, committed_wal: &[u8]) -> Result<Self, String> {
Self::open(main, None, committed_wal)
}
pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result<Self, String> {
Self::open(source.header().to_vec(), Some(source), committed_wal)
}
pub fn ensure_unchanged(&self) -> Result<(), String> {
if let Some(file) = &self.file {
file.ensure_unchanged()?;
}
Ok(())
}
fn open(main: Vec<u8>, file: Option<FileSource>, committed_wal: &[u8]) -> Result<Self, String> {
let main_header = main.get(..100).ok_or("truncated SQLite header")?;
if main_header.get(..16) != Some(b"SQLite format 3\0") {
return Err("invalid SQLite header signature".into());
}
let page_size = sqlite_page_size(be_u16(main_header, 16)?)?;
validate_header_fields(main_header, page_size)?;
if main.len() < page_size || !main.len().is_multiple_of(page_size) {
let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len());
if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) {
return Err("SQLite image is not a whole number of pages".into());
}
let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?;
let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?;
if main_pages == 0 || main_pages > MAX_PAGE_ID {
return Err("invalid SQLite image page count".into());
}
@@ -140,7 +230,7 @@ impl Database {
_ => return Err("unsupported SQLite text encoding".into()),
};
let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
let schema = db.parse_schema()?;
db.tables = schema.tables;
db.rowid_aliases = schema.aliases;
@@ -166,17 +256,20 @@ impl Database {
Ok(state.out)
}
fn page(&self, page_id: u32) -> Result<&[u8], String> {
fn page(&self, page_id: u32) -> Result<Cow<'_, [u8]>, String> {
if page_id == 0 || page_id > self.page_count {
return Err(format!("page {page_id} is outside database"));
}
if let Some(page) = self.overlay.get(&page_id) {
return Ok(page);
return Ok(Cow::Borrowed(page));
}
if let Some(file) = &self.file {
return file.page(page_id, self.page_size).map(Cow::Owned);
}
let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?;
let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?;
let end = start.checked_add(self.page_size).ok_or("page offset overflow")?;
self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated"))
self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated"))
}
fn visit_table(&self, page_id: u32, ncols: usize, alias: Option<usize>, depth: usize, state: &mut WalkState) -> Result<(), String> {
@@ -219,7 +312,7 @@ impl Database {
if state.payload_total > MAX_TABLE_PAYLOAD {
return Err("SQLite table payload exceeds bounded import size".into());
}
let payload = self.payload(page, at, payload_len)?;
let payload = self.payload(&page, at, payload_len)?;
let mut values = decode_record(&payload, ncols, self.encoding)?;
if let Some(index) = alias {
if index >= values.len() {
@@ -325,7 +418,7 @@ impl Database {
out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?);
remaining -= take;
if remaining > 0 {
next = be_u32_at(overflow, 0)?;
next = be_u32_at(&overflow, 0)?;
}
}
Ok(out)
@@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result<u32, String> {
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new(bytes: &[u8]) -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id()));
std::fs::write(&path, bytes).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn file_backed(mut db: Database) -> (Scratch, Database) {
let scratch = Scratch::new(&db.main);
db.file = Some(FileSource::open(&scratch.0).unwrap());
db.main.truncate(100);
(scratch, db)
}
#[test]
fn file_source_bounds_and_mutation_detection() {
let scratch = Scratch::new(&[0; 512]);
let source = FileSource::open(&scratch.0).unwrap();
assert_eq!(source.page(1, 512).unwrap().len(), 512);
assert!(source.page(2, 512).unwrap_err().contains("truncated"));
File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap();
assert!(source.page(1, 512).is_err());
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
drop(source);
// Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows.
#[cfg(unix)]
{
File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap();
assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit");
}
}
#[cfg(unix)]
#[test]
fn file_source_detects_same_sized_path_replacement() {
let original = Scratch::new(&[0; 512]);
let replacement = Scratch::new(&[0; 512]);
let source = FileSource::open(&original.0).unwrap();
std::fs::rename(&replacement.0, &original.0).unwrap();
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
}
#[test]
fn file_source_detects_same_length_content_changes() {
let scratch = Scratch::new(&[0; 512]);
let source = FileSource::open(&scratch.0).unwrap();
std::fs::write(&scratch.0, [1; 512]).unwrap();
// Make the timestamp change deterministic even on filesystems with coarse timestamps.
File::options()
.write(true)
.open(&scratch.0)
.unwrap()
.set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2))
.unwrap();
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
}
fn varint(mut value: u64) -> Vec<u8> {
let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)];
value >>= 7;
@@ -1026,6 +1185,40 @@ mod tests {
assert!(tables.iter().any(|table| table.name == "t"));
let rows = db.read_table(2, 2).unwrap();
assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1)));
let scratch = Scratch::new(&db.main);
let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap();
assert_eq!(file_db.live_tables().unwrap(), tables);
assert_eq!(file_db.read_table(2, 2).unwrap(), rows);
assert_eq!(file_db.main.len(), 100);
file_db.ensure_unchanged().unwrap();
// The same committed WAL page overrides the lazily read main page.
let mut wal = vec![0; 32 + 24 + 512];
wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes());
wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
wal[8..12].copy_from_slice(&512u32.to_be_bytes());
wal[32..36].copy_from_slice(&2u32.to_be_bytes());
wal[36..40].copy_from_slice(&2u32.to_be_bytes());
leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']);
let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap();
let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap();
assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap());
assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2));
let mut malformed = db.main.clone();
malformed.push(0);
let partial = Scratch::new(&malformed);
assert_eq!(
Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(),
"SQLite image is not a whole number of pages"
);
let mut malformed = db.main.clone();
malformed[24..28].copy_from_slice(&1u32.to_be_bytes());
malformed[92..96].copy_from_slice(&1u32.to_be_bytes());
malformed[28..32].copy_from_slice(&3u32.to_be_bytes());
let oversized = Scratch::new(&malformed);
assert_eq!(
Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(),
"SQLite header page count exceeds image"
);
}
#[test]
@@ -1042,6 +1235,7 @@ mod tests {
leaf(&mut main[1024..1536], 0, 1, &[2, 0]);
leaf(&mut main[1536..], 0, 2, &[2, 0]);
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1073,6 +1267,7 @@ mod tests {
let mut aliases = HashMap::new();
aliases.insert(1, 0);
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1093,6 +1288,7 @@ mod tests {
main[103..105].copy_from_slice(&1u16.to_be_bytes());
main[108..110].copy_from_slice(&490u16.to_be_bytes());
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1103,6 +1299,8 @@ mod tests {
rowid_aliases: HashMap::new(),
};
assert!(db.read_table(1, 1).is_err());
let (_scratch, db) = file_backed(db);
assert!(db.read_table(1, 1).is_err());
}
#[test]
@@ -1115,6 +1313,7 @@ mod tests {
let mut main = vec![0u8; 2 * 512];
let db = Database {
file: None,
main: std::mem::take(&mut main),
overlay: HashMap::new(),
page_size: 512,
@@ -1141,6 +1340,10 @@ mod tests {
assert_eq!(bytes.len(), 600);
assert_eq!(&bytes[..92], &[b'x'; 92]);
assert_eq!(&bytes[92..], &[b'y'; 508]);
let (_scratch, mut db) = file_backed(db);
db.overlay.clear();
let bytes = db.payload(&cell_page, 0, 600).unwrap();
assert_eq!(&bytes[92..], &[b'y'; 508]);
}
#[test]
@@ -1164,6 +1367,7 @@ mod tests {
let mut page = vec![0u8; 512];
page[16..18].copy_from_slice(&512u16.to_be_bytes());
let db = Database {
file: None,
main: vec![0; 512],
overlay: HashMap::new(),
page_size: 512,
+64 -2
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 = 64;
pub const RENDER_CACHE_VERSION: u64 = 65;
/// 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];
@@ -277,7 +277,9 @@ impl SourceRef {
if from.image.width.max(from.image.height) <= *max_edge {
return Ok((**from).clone());
}
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
// Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near
// the output's resolution limit and aliases what is finer
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
@@ -761,6 +763,24 @@ pub struct RenderJob {
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
/// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once
/// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up
/// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at
/// small ratios and aliases it at large ones. The caches keep the full render.
pub display_fit: Option<(usize, usize)>,
}
/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]).
pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 {
use lightcraft_raster::resample::{Filter, fit};
let f = lightcraft_raster::Image {
width: img.width,
height: img.height,
data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(),
};
let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3);
let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() }
}
pub struct RenderResult {
@@ -784,6 +804,14 @@ pub struct RenderResult {
impl RenderJob {
/// Render at draft quality (interactive drags). Gets its own result key.
/// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see
/// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again.
pub fn with_display_fit(mut self, w: usize, h: usize) -> Self {
self.display_fit = Some((w, h));
self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
self
}
pub fn draft(mut self) -> Self {
if self.request.quality != Quality::Draft {
self.request.quality = Quality::Draft;
@@ -994,6 +1022,15 @@ impl RenderJob {
{
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
}
// the display fit, after the caches have the full render
if let Some((fw, fh)) = self.display_fit
&& self.request.quality == Quality::Full
{
let (w, h) = (rendered.image.width, rendered.image.height);
if w > fw.max(1) || h > fh.max(1) {
rendered.image = fit_for_display(&rendered.image, fw, fh);
}
}
RenderResult {
request_id: self.request_id,
source_key: self.source_key,
@@ -1266,6 +1303,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
})
}
@@ -1319,6 +1357,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
})
}
@@ -1373,6 +1412,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut,
display_fit: None,
})
}
@@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests {
mod tests {
use super::*;
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
/// a draft, and a render no larger than the drawn size, are left as rendered.
#[test]
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
let long = plain.width.max(plain.height);
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
assert_ne!(a.key, b.key, "the fit is part of the result key");
}
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
+20 -2
View File
@@ -75,6 +75,7 @@ pub(crate) struct Input {
}
/// What a detail pass re-runs.
#[derive(Clone)]
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
pub(crate) enum Prompt {
Clicks(Vec<Click>),
@@ -440,7 +441,11 @@ impl Session {
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
self.segmenter.worker.submit(job)?;
match rx.recv_timeout(WAIT) {
Ok(o) => o.result,
Ok(o) => {
// (shown by the next poll in an app; logged anyway)
self.segmenter.messages.extend(o.notice);
o.result
}
Err(_) => Err("the AI model took too long; try again".into()),
}
}
@@ -579,7 +584,7 @@ impl Session {
self.segment_queue(id, kind, tag, Some(region))?;
} else {
let patch = self.segment_wait(id, kind, Some(region))?;
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None };
let mut polled = Polled::default();
self.apply_outcome(outcome, &mut polled);
}
@@ -618,6 +623,7 @@ impl Session {
if o.superseded {
return;
}
polled.messages.extend(o.notice);
match o.tag {
Tag::Wait => {}
Tag::Prepare => {
@@ -711,6 +717,18 @@ impl Session {
mod tests {
use super::*;
#[cfg(feature = "sam")]
#[test]
fn a_worker_notice_reaches_the_user_with_the_result() {
// the job after which the model runs on the CPU (the GPU failed) says so
let mut s = Session::with_demo();
let notice = Some(worker::GPU_FAILED.to_string());
s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap();
let polled = s.segment_poll();
assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]);
assert!(s.segment_poll().messages.is_empty(), "once");
}
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
let l: Vec<f32> =
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
+222 -34
View File
@@ -7,6 +7,13 @@
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
//!
//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an
//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the
//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest
//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and
//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on
//! the CPU from the start.
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
@@ -29,6 +36,7 @@ use crate::media::RenderJob;
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
/// What to compute.
#[derive(Clone)]
pub enum Kind {
/// Load the model and encode the photo (so the first click is fast).
Prepare,
@@ -42,6 +50,12 @@ pub enum Kind {
/// Panics (tests of the worker's recovery).
#[cfg(test)]
Panic,
/// Fails like the model on a GPU that can't run it (a panic or a candle error), and
/// succeeds on the CPU (tests of the CPU fallback).
#[cfg(test)]
FailOnGpu {
panic: bool,
},
}
/// One request.
@@ -65,6 +79,70 @@ pub struct Outcome {
pub result: Result<Option<SegMask>, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
/// Something the user should know besides the result ([`GPU_FAILED`]).
pub notice: Option<String>,
}
/// The notice of the job after which the model runs on the CPU.
pub const GPU_FAILED: &str =
"The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log.";
/// Where the model is loaded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Placement {
/// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU.
Gpu,
/// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model
/// failed on it.
Cpu,
}
impl Placement {
fn initial() -> Self {
if !lightcraft_segment::GPU_BUILD {
return Placement::Cpu;
}
if let Some(why) = lightcraft_gpu::env_off_reason() {
log::info!("SAM 3 runs on the CPU ({why})");
return Placement::Cpu;
}
Placement::Gpu
}
}
/// Why a job failed.
#[derive(Debug)]
enum Failure {
/// The model failed (a candle error, or a panic): on the GPU, the CPU may run it.
Model(String),
/// Anything else (no model files, the photo, a remote worker): another device won't help.
Other(String),
}
impl Failure {
fn message(self) -> String {
match self {
Failure::Model(m) | Failure::Other(m) => m,
}
}
}
impl From<String> for Failure {
fn from(m: String) -> Self {
Failure::Other(m)
}
}
impl From<&str> for Failure {
fn from(m: &str) -> Self {
Failure::Other(m.into())
}
}
impl From<lightcraft_segment::Error> for Failure {
fn from(e: lightcraft_segment::Error) -> Self {
if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) }
}
}
/// Counters the session reads without locking.
@@ -159,7 +237,7 @@ impl Worker {
// (re)start the thread
let (tx, rx) = mpsc::channel();
let shared = self.shared.clone();
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) {
Ok(t) => {
self.thread = Some(t);
self.tx = Some(tx.clone());
@@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool {
}
}
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration, mut placement: Placement) {
let mut state = State::default();
let mut queue: VecDeque<Job> = VecDeque::new();
loop {
@@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None });
continue;
}
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
Ok(r) => r,
Err(p) => {
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::error!("SAM 3 panicked: {why}");
// don't trust what the model kept: load it again next time
state = State::default();
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
}
// what a retry on the CPU needs (running the job takes them)
let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone()));
let mut result = attempt(&mut state, shared, &mut job, placement);
let mut notice = None;
let gpu_failed = match &result {
Err(Failure::Model(why)) => Some(why.clone()),
_ => None,
};
if let (Some(why), Some((kind, render))) = (gpu_failed, retry) {
// the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free
// the model there and try once on the CPU
log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU");
state = State::default();
(job.kind, job.render) = (kind, render);
match attempt(&mut state, shared, &mut job, Placement::Cpu) {
Ok(r) => {
log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU");
placement = Placement::Cpu;
notice = Some(GPU_FAILED.to_string());
result = Ok(r);
}
Err(e) => {
// the CPU fails too, so the GPU isn't the cause: the next job uses it again
log::warn!("SAM 3 failed on the CPU too: {}", e.message());
state = State::default();
}
}
}
let result = result.map_err(Failure::message);
if result.is_err() && !is_detail {
state.cache = None;
}
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice });
}
}
/// The model for `dir`, loading it when needed.
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the
/// model kept.
fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) {
Ok(r) => r,
Err(p) => {
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::error!("SAM 3 panicked: {why}");
// don't trust what the model kept: load it again next time
*state = State::default();
Err(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into()))
}
}
}
/// The model for `dir`, loading it on `placement` when needed.
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> {
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
state.model = None;
state.cache = None;
let _busy = Flag::raise(&shared.encoding);
let t = web_time::Instant::now();
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
log::info!("SAM 3 loaded in {:?}", t.elapsed());
let m = match placement {
Placement::Gpu => Sam3::load(dir)?,
Placement::Cpu => Sam3::load_cpu(dir)?,
};
log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" });
state.model = Some((dir.to_path_buf(), m));
shared.loaded.store(true, Ordering::SeqCst);
}
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into())
}
/// Render the model input.
@@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result<Input, String> {
}
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
model(state, shared, &job.dir)?;
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> {
model(state, shared, &job.dir, placement)?;
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
state.cache = None;
let input = render(job)?;
let _busy = Flag::raise(&shared.encoding);
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
m.prepare_clicks(&mut enc)?;
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
state.cache = Some((job.key, enc));
}
@@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
}
}
#[cfg(test)]
Kind::Panic => return Err("test panic request".into()),
Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()),
};
let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } };
let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation };
@@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
}))
}
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
if let Some(endpoint) = remote::configured() {
let result = remote_job(state, shared, job, &endpoint);
if result.is_err() {
state.remote = None;
}
return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."));
return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")));
}
let side = lightcraft_segment::MASK_SIDE;
match std::mem::replace(&mut job.kind, Kind::Prepare) {
Kind::Prepare => {
encoded(state, shared, job)?;
encoded(state, shared, job, placement)?;
Ok(None)
}
Kind::Clicks(clicks) => {
let (m, enc) = encoded(state, shared, job)?;
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
let (m, enc) = encoded(state, shared, job, placement)?;
let p = m.segment_clicks(enc, &clicks)?;
Ok(Some(SegMask::from_logits(side, &p.logits)))
}
Kind::Text(text) => {
let (m, enc) = encoded(state, shared, job)?;
let (m, enc) = encoded(state, shared, job, placement)?;
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
}
#[cfg(test)]
#[allow(clippy::panic)]
Kind::Panic => panic!("a test panic"),
#[cfg(test)]
#[allow(clippy::panic)]
Kind::FailOnGpu { panic } => match placement {
Placement::Gpu if panic => panic!("a test panic on the GPU"),
Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())),
Placement::Cpu => Ok(None),
},
Kind::Detail { prompt, region } => {
let input = render(job)?;
let m = model(state, shared, &job.dir)?;
let m = model(state, shared, &job.dir, placement)?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
if !(rw > 0.0 && rh > 0.0) {
return Ok(None);
@@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<S
if !inside.iter().any(|c| c.positive) {
return Ok(None);
}
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
Some(m.segment_clicks(&mut enc, &inside)?.logits)
}
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
};
@@ -438,7 +560,7 @@ mod tests {
jtx.send(job(Tag::Wait, &tx)).unwrap();
shared.pending.store(2, Ordering::SeqCst);
drop(jtx);
run(&jrx, &shared, Duration::from_secs(60));
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
let first = rx.recv().unwrap();
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
let second = rx.recv().unwrap();
@@ -447,6 +569,72 @@ mod tests {
assert!(!shared.encoding.load(Ordering::SeqCst));
}
/// Run `kinds` in turn on a worker that starts on `placement`; their outcomes.
fn run_kinds(placement: Placement, kinds: Vec<Kind>) -> Vec<Outcome> {
let (tx, rx) = mpsc::channel();
let (jtx, jrx) = mpsc::channel();
let shared = Shared::default();
shared.pending.store(kinds.len(), Ordering::SeqCst);
for kind in kinds {
let mut j = job(Tag::Wait, &tx);
j.kind = kind;
jtx.send(j).unwrap();
}
drop(jtx);
run(&jrx, &shared, Duration::from_secs(60), placement);
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
assert!(!shared.encoding.load(Ordering::SeqCst));
rx.try_iter().collect()
}
#[test]
fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() {
// issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]);
assert_eq!(out.len(), 2);
assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err());
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU");
assert_eq!(out[1].notice, None, "said once");
}
#[test]
fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() {
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]);
assert!(matches!(out[0].result, Ok(None)));
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
}
#[test]
fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() {
let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]);
assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly"));
assert_eq!(out[0].notice, None);
// not the GPU's fault: the next job tries the GPU first again
assert!(matches!(out[1].result, Ok(None)));
assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED));
}
#[test]
fn a_worker_on_the_cpu_never_tries_the_gpu() {
// no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off
let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]);
assert!(matches!(out[0].result, Ok(None)));
assert_eq!(out[0].notice, None);
if !lightcraft_segment::GPU_BUILD {
assert_eq!(Placement::initial(), Placement::Cpu);
}
}
#[test]
fn missing_model_files_are_not_retried_on_the_cpu() {
let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]);
assert!(out[0].result.as_ref().unwrap_err().contains("not found"));
assert_eq!(out[0].notice, None);
let missing = lightcraft_segment::Error::Missing(PathBuf::from("models"));
assert!(matches!(Failure::from(missing), Failure::Other(_)));
}
#[test]
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
let (tx, rx) = mpsc::channel();
@@ -464,7 +652,7 @@ mod tests {
drop(jtx);
let shared = Shared::default();
shared.pending.store(3, Ordering::SeqCst);
run(&jrx, &shared, Duration::from_secs(60));
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
let out: Vec<Outcome> = rx.try_iter().collect();
assert_eq!(out.len(), 3);
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
+97 -7
View File
@@ -17,7 +17,10 @@
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks
//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF
//! arriving beside its raw's `IMG_0001.xmp` reads its own `<file>.xmp`, never the raw's packet
//! (issue #1905).
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
@@ -25,7 +28,7 @@
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -71,6 +74,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
sidecar_candidates(original, naming).find(|p| p.is_file())
}
/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out
/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not
/// own its stem sidecar reads its own `<file>.xmp` (the name it writes) and never the packet of
/// the sibling that owns the stem one.
pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option<PathBuf> {
if stems.owns(original) {
return find_sidecar(original, naming);
}
let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem));
sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file())
}
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
/// then under the other naming (the full name also with the photo extension in the other case),
/// each as `.xmp`, `.XMP` and `.Xmp`.
@@ -89,7 +104,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl
}
v
})
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
.flat_map(xmp_cases)
}
/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`.
fn xmp_cases(p: PathBuf) -> [PathBuf; 3] {
[p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
@@ -528,6 +548,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
}
}
/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file
/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks
/// last among files of its kind and name: see [`ImportStems`]).
type Rank = (bool, String, u64);
/// The rank of the file at `path`, of kind `kind`, catalogued as `id`.
fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank {
(kind != MediaKind::Raw, path.to_lowercase(), id)
}
/// Which photo owns each stem sidecar that several catalogued files map to (see
/// [`Session::sidecar_naming`]).
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
@@ -538,10 +568,10 @@ impl StemOwners {
/// owners were worked out before, for every write).
#[cfg(test)]
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
let rank = rank_of(path, p.kind, p.id.0);
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
@@ -566,7 +596,7 @@ impl StemOwners {
/// The owners of the shared stems in `wanted`.
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
// per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
let mut key = String::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
@@ -574,7 +604,7 @@ impl StemOwners {
if !wanted.contains(key.as_str()) {
continue;
}
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
let rank = rank_of(path, p.kind, p.id.0);
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
@@ -593,6 +623,66 @@ impl StemOwners {
}
}
/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of
/// their stem, and which read their own `<file>.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905:
/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating,
/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with
/// the arriving files ranked against the library and against each other: a raw first, then the
/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when
/// the import commits is above every id in the library, so it loses a tie against a catalogued
/// file; between arriving files the order they arrive in stands for it.
pub(crate) struct ImportStems {
/// The arriving files that own the stem sidecar of their stem, by path as it arrives.
owners: HashSet<String>,
}
impl ImportStems {
/// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a
/// library already looked over (a job prepared twice): there is nothing to rank against, so
/// every arriving file reads as it did before. The library is passed over once, and only the
/// stems the arriving files map to are looked at closer.
pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator<Item = (&'a str, MediaKind)>) -> ImportStems {
// per stem: the best of the library's files, and the best arriving file with its path
let mut best: HashMap<String, (Option<Rank>, Option<(Rank, &'a str)>)> = HashMap::new();
let mut arriving_paths = HashSet::new();
for (arriving, (path, kind)) in incoming.enumerate() {
arriving_paths.insert(path);
let e = best.entry(stem_key(path)).or_default();
let rank = rank_of(path, kind, arriving as u64);
if e.1.as_ref().is_none_or(|(b, _)| rank < *b) {
e.1 = Some((rank, path));
}
}
if let Some(cat) = cat {
let mut key = String::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
// the arriving file's own record (a trashed photo imported again) is not a rival
let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue };
stem_key_into(&mut key, path);
let Some(e) = best.get_mut(key.as_str()) else { continue };
let rank = rank_of(path, p.kind, p.id.0);
if e.0.as_ref().is_none_or(|b| rank < *b) {
e.0 = Some(rank);
}
}
}
let owners = best
.into_values()
.filter_map(|(in_library, arriving)| {
let (rank, path) = arriving?;
let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str()));
wins.then(|| path.to_string())
})
.collect();
ImportStems { owners }
}
/// Whether the arriving file at `original` owns the stem sidecar of its stem.
pub(crate) fn owns(&self, original: &str) -> bool {
self.owners.contains(original)
}
}
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
+74 -4
View File
@@ -1,4 +1,5 @@
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and
//! for the files an import is adding (`ImportStems`).
//!
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
//! that is synchronized, must not cost a table of them all.
@@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [
"/pics/dots/a.jpg",
];
/// The kind a file of this name is catalogued as: the `.cr3` ones are raws.
fn kind_of(path: &str) -> MediaKind {
if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image }
}
fn library() -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
@@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec<PhotoId>) {
let id = c.alloc_photo_id();
let name = path.rsplit('/').next().unwrap_or(path);
let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
if path.to_lowercase().ends_with("cr3") {
p.kind = MediaKind::Raw;
}
p.kind = kind_of(path);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
@@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() {
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
}
/// Given a library where files share stems in every way a name can, when files arrive in an
/// import, then each of them may read the shared stem sidecar exactly when it would own it once
/// it is catalogued: the arriving files are ranked against the library's files and against each
/// other, as the write rule ranks them (issue #1905).
#[test]
fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() {
let (c, _) = library();
// the arrivals: the same names under an upper-case folder, so each shares its stem with the
// library's files (the key lower-cases the path) without being one of them
let incoming: Vec<(String, MediaKind)> = PATHS
.iter()
.map(|p| {
let (dir, name) = p.rsplit_once('/').unwrap_or(("", p));
(format!("{}/{name}", dir.to_uppercase()), kind_of(p))
})
.collect();
// the library with these arrivals in it, the ids drawn in the order they arrive
let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog {
let mut cat = base.clone();
for &k in add {
let (path, kind) = &incoming[k];
let id = cat.alloc_photo_id();
let name = path.rsplit('/').next().unwrap_or(path);
let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01");
p.kind = *kind;
cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
cat
};
let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool {
let path = &incoming[k].0;
let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap();
StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem
};
let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() };
let all: Vec<usize> = (0..incoming.len()).collect();
for i in 0..incoming.len() {
let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter());
assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0);
for j in 0..incoming.len() {
// every pair arriving together
if i == j {
continue;
}
let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter());
let cat = arriving_in(&c, &[i, j]);
for k in [i, j] {
assert_eq!(
pair.owns(&incoming[k].0),
owns_once_catalogued(&cat, k),
"{} with {}",
incoming[k].0,
incoming[if k == i { j } else { i }].0
);
}
}
}
// a job prepared twice (a library already looked over) has nothing to rank against: every
// one of them is ranked among the arrivals alone, as it would be in a library of just them
let alone = ImportStems::for_import(None, as_arriving(&all).into_iter());
let only_arrivals = arriving_in(&Catalog::new(), &all);
for k in &all {
assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0);
}
}
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
/// above, for shapes a path from a file can have, and for every string of up to six of the
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
+147
View File
@@ -0,0 +1,147 @@
//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the
//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so
//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop.
use lightcraft_geom::{CropGeometry, Rect};
use lightcraft_pipeline::geometry::Frame;
use serde_json::json;
use crate::Session;
fn crop(s: &Session) -> CropGeometry {
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
}
/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)?
fn fits(s: &Session) -> bool {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let mut d = (*p.develop).clone();
d.geometry.constrain_crop = true;
let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());
let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
}
fn smaller(a: Rect, b: Rect) -> bool {
a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3
}
fn constrain(s: &mut Session, on: bool) {
s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap();
}
fn vertical(s: &mut Session, v: f64) {
s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap();
}
#[test]
fn a_geometry_slider_tightens_the_crop_while_constrained() {
let mut s = Session::with_demo();
let full = crop(&s).rect;
constrain(&mut s, true);
assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off");
vertical(&mut s, -60.0);
assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect);
assert!(fits(&s), "and holds no blank corner");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider");
}
#[test]
fn ticking_constrain_after_the_warp_tightens_the_crop() {
let mut s = Session::with_demo();
let full = crop(&s).rect;
vertical(&mut s, -60.0);
assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners");
assert!(!fits(&s));
constrain(&mut s, true);
assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect);
// unticking keeps the tighter crop, as in Lightroom
let tight = crop(&s).rect;
constrain(&mut s, false);
assert_eq!(crop(&s).rect, tight);
}
#[test]
fn a_crop_drawn_by_hand_is_kept_as_drawn() {
let mut s = Session::with_demo();
constrain(&mut s, true);
vertical(&mut s, -60.0);
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted");
}
/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's
/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop.
#[test]
fn auto_sync_fits_each_photos_own_crop() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (a, b) = (ids[0], ids[1]);
// B: a small crop off-centre; both constrained
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
constrain(&mut s, true);
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
constrain(&mut s, true);
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
// A active, both selected, Auto Sync on: a Geometry edit on A
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
vertical(&mut s, -60.0);
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
assert_eq!(db.geometry.vertical, -60.0, "the edit reached B");
let b_after = db.crop.geometry.rect;
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}");
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
assert!(fits(&s), "and fits B's warp");
}
/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the
/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B.
#[test]
fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (a, b) = (ids[0], ids[1]);
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
constrain(&mut s, true);
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
constrain(&mut s, true);
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap();
for v in [-20.0, -40.0, -60.0] {
vertical(&mut s, v);
}
s.execute("develop.endInteraction", &json!({})).unwrap();
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0));
let b_after = db.crop.geometry.rect;
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}");
for photo in [a, b] {
s.selection = crate::Selection { ids: vec![photo], active: Some(photo) };
assert!(fits(&s), "each crop fits its photo's warp");
}
}
/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning
/// another section's eye off leaves a crop drawn by hand as it is.
#[test]
fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() {
let mut s = Session::with_demo();
constrain(&mut s, true);
vertical(&mut s, -60.0);
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap();
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change");
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap();
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap();
assert!(fits(&s), "the Geometry eye is one: the crop fits again");
}
+86
View File
@@ -0,0 +1,86 @@
//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5",
//! "original") takes the crop's present orientation, landscape or portrait after X, as in
//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop.
use serde_json::{Value, json};
use crate::Session;
/// The demo photo, landscape, with its crop's width and height in pixels.
fn crop_px(s: &Session) -> (f64, f64) {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
let r = p.develop.crop.geometry.rect_px(w, h);
(r.width(), r.height())
}
fn landscape_photo() -> Session {
let mut s = Session::with_demo();
let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap();
s.selection = crate::Selection { ids: vec![id], active: Some(id) };
s
}
fn aspect(s: &mut Session, p: Value) {
s.execute("crop.aspect", &p).unwrap();
}
fn ratio(s: &Session) -> f64 {
let (w, h) = crop_px(s);
w / h
}
#[test]
fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": "2x3"}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s));
s.execute("crop.rotateAspect", &json!({})).unwrap();
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s));
// another named ratio after X stays portrait (it used to turn back to the image's landscape)
aspect(&mut s, json!({"aspect": "4x5"}));
assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s));
aspect(&mut s, json!({"aspect": "16x9"}));
assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s));
}
#[test]
fn a_custom_ratio_is_used_as_given() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": [2, 3]}));
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s));
aspect(&mut s, json!({"aspect": [3, 2]}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
}
/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a
/// landscape photo it lands landscape, whatever photo it was chosen on.
#[test]
fn orientation_overrides_and_is_checked() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"}));
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s));
let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err();
assert!(e.to_string().contains("orientation"), "{e}");
}
/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked
/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes.
#[test]
fn a_bad_orientation_is_refused_before_anything_is_locked() {
let mut s = landscape_photo();
let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop;
for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] {
for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] {
let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err();
assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}");
assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched");
}
}
// the valid words still lock as is
aspect(&mut s, json!({"aspect": "current", "orientation": "crop"}));
assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some());
}
+39 -1
View File
@@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is
/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth
/// gets its own, as stored settings do, while a depth the call itself passes is still refused.
#[test]
fn a_preset_depth_is_judged_against_the_format_written() {
use crate::export::ExportPreset;
let mut s = Session::with_demo();
let id = s.active().unwrap();
s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) });
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap();
let o = ExportOptions::from_params(&p).unwrap();
assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16)));
assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG");
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap();
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None);
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap();
assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg"));
// such a preset can be saved: its depth is judged when it exports; with a format, at once
s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap();
assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err());
}
/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A
/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does,
/// instead of refusing the save; without `format`, the depth waits for the format written.
#[test]
fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() {
let mut s = Session::with_demo();
s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"}));
s.execute("export.savePreset", &json!({"name": "Old"})).unwrap();
assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80}));
s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"}));
s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap();
let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap();
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16));
}
#[test]
fn presets_conflicts_and_subfolders() {
use crate::export::{Destination, export_batch};
@@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() {
let exists = |p: &str| std::path::Path::new(p).exists();
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
// `export` joins the subfolder and name with `/` (`join_path`), on Windows too.
let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())];
let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect();
want.sort();
got.sort();
+92
View File
@@ -115,3 +115,95 @@ fn negative_indices_are_refused() {
assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err());
assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed");
}
#[test]
fn fractional_version_delete_preserves_the_version() {
let mut s = Session::with_demo();
s.execute("version.create", &json!({"name": "A"})).unwrap();
let before = s.catalog.to_snapshot();
let result = s.execute("version.delete", &json!({"index": 0.9}));
assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before);
assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged");
}
/// Invalid command indices must leave objects, settings, History and undo unchanged.
#[test]
fn invalid_command_indices_leave_the_catalog_and_history_unchanged() {
let (mut s, _) = three_edits();
for name in ["A", "B"] {
s.execute("version.create", &json!({"name": name})).unwrap();
}
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
for x in [0.3, 0.6] {
s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
}
let before = s.catalog.to_snapshot();
let history = s.execute("history.list", &json!({})).unwrap();
let undo_len = s.undo.len();
// The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX.
let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap();
for command in [
"version.delete",
"version.rename",
"version.update",
"version.restore",
"pointColor.delete",
"redeye.delete",
"redeye.catchlight",
"history.restore",
] {
for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] {
let result = s.execute(command, &json!({"index": index, "name": "Changed"}));
assert!(result.is_err(), "{command} accepted {index}: {result:?}");
assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}");
assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}");
assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}");
}
assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index");
assert_eq!(s.catalog.to_snapshot(), before);
}
}
/// Whole floating-point JSON numbers remain valid for existing control and MCP callers.
#[test]
fn whole_command_indices_accept_integer_and_float_numbers() {
let (mut s, n) = three_edits();
let id = s.active().unwrap();
s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
assert_eq!(exposure(&s), 3.0);
for name in ["A", "B", "C"] {
s.execute("version.create", &json!({"name": name})).unwrap();
}
s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third");
s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap();
s.execute("version.update", &json!({"index": 1.0})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0);
s.execute("version.restore", &json!({"index": 0.0})).unwrap();
assert_eq!(exposure(&s), 3.0);
s.execute("version.delete", &json!({"index": 1})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["A", "Third"]);
s.execute("version.delete", &json!({"index": 1.0})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1);
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap();
assert!(s.develop_of(id).unwrap().point_colors.is_empty());
s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap();
assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some());
s.execute("redeye.delete", &json!({"index": 0.0})).unwrap();
assert!(s.develop_of(id).unwrap().red_eye.is_empty());
}
#[test]
fn fractional_history_restore_preserves_the_cursor() {
let (mut s, _) = three_edits();
let before = s.execute("history.list", &json!({})).unwrap();
let result = s.execute("history.restore", &json!({"index": 1.9}));
let after = s.execute("history.list", &json!({})).unwrap();
assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after);
assert_eq!(after, before);
assert_eq!(exposure(&s), 3.0);
}
+110
View File
@@ -1132,6 +1132,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() {
let _ = std::fs::remove_dir_all(&src);
}
/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a
/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it.
#[test]
fn a_fresh_reimport_reads_its_own_stem_sidecar() {
let (mut s, src, id) = trashed_photo("trash-fresh-sidecar");
std::fs::write(
src.join("a.xmp"),
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#,
)
.unwrap();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
let new = r["imported"][0].as_u64().unwrap();
assert_ne!(new, id);
assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}");
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn unknown_on_deleted_is_an_error() {
let mut s = Session::new().with_fs();
@@ -1792,3 +1811,94 @@ fn presets_apply_the_imported_profile_they_name() {
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
let _ = std::fs::remove_dir_all(&src);
}
/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one.
fn write_raw(path: &Path) {
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let px: Vec<u8> = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect();
let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
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, j.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(&j);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, f).unwrap();
}
/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft.
fn write_tiff(path: &Path, seed: u8) {
use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff};
let (w, h) = (48usize, 32usize);
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect();
let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first,
/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never
/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating,
/// label, metadata and develop settings.
#[test]
fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() {
let sidecar = |rating: u8| {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
)
};
let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() };
// the raw and the edited TIFF of it, same name, in one import
let src = temp_dir("stem-pair");
write_raw(&src.join("IMG_1.NEF"));
write_tiff(&src.join("IMG_1.TIF"), 7);
std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap();
std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
assert_eq!(r["sidecars"], 2, "{r}");
assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own");
assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's");
let _ = std::fs::remove_dir_all(&src);
// the TIFF with no sidecar of its own: nothing of the raw's packet comes across
let src = temp_dir("stem-none");
write_raw(&src.join("IMG_2.NEF"));
write_tiff(&src.join("IMG_2.TIF"), 8);
std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
assert_eq!(r["sidecars"], 1, "{r}");
assert_eq!(read(&s, "IMG_2.NEF"), 4);
assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's");
let _ = std::fs::remove_dir_all(&src);
// the raw already in the library, the edited TIFF arriving later
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
let src = temp_dir("stem-after");
write_raw(&src.join("IMG_3.NEF"));
write_tiff(&src.join("IMG_3.TIF"), 9);
std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap();
let mut s = Session::new().with_fs();
let raw = {
let id = s.catalog.alloc_photo_id();
let path = src.join("IMG_3.NEF").to_string_lossy().into_owned();
let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48");
p.kind = MediaKind::Raw;
p
};
s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap();
let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap();
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
assert_eq!(r["sidecars"], 0, "{r}");
assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp");
let _ = std::fs::remove_dir_all(&src);
}
+25
View File
@@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() {
s.close_library().unwrap();
assert_eq!(s.unsaved(), None);
}
/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as
/// unsaved (the top bar warns, the web backup refuses) until a later save writes it.
#[test]
fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() {
let files = Flaky::default();
let mut s = Session::new();
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
s.open_library_in(stores, true).unwrap();
assert!(s.unsaved().is_none());
files.set_failing(true);
s.execute("preset.create", &json!({"name": "Mine"})).unwrap();
let (n, why) = s.unsaved().expect("the preset is unsaved");
assert_eq!(n, 1);
assert!(why.contains("presets") && why.contains("disk full"), "{why}");
let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default();
assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine"));
// still failing: still unsaved
s.persist().unwrap();
assert!(s.unsaved().is_some());
files.set_failing(false);
s.persist().unwrap();
assert!(s.unsaved().is_none(), "written once the disk works again");
assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine"));
}
@@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() {
}
std::fs::create_dir_all(folder.join("folder.json")).unwrap();
std::fs::write(folder.join("notes.txt"), b"ignored").unwrap();
// written by hand, not through LightCraft: the folder's time may not have moved since the
// save's listing (same clock tick), so the cached listing is dropped as a write would drop it
s.slide_templates_cache = None;
let r = run(&mut s, "slideshow.templates.list", json!({}));
let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect();
assert_eq!(user.len(), 1, "only the good one: {r}");
+239
View File
@@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() {
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
}
fn target_session() -> Session {
let mut s = Session::with_demo();
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap();
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
for x in [0.3, 0.7] {
s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap();
}
// A rejected command must preserve a redo branch as well as existing undo steps.
s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s
}
fn target_state(s: &Session) -> serde_json::Value {
let photo = s.catalog.photo(s.active().unwrap()).unwrap();
json!({
"develop": photo.develop,
"history": photo.history,
"selection": s.selection,
"activeMask": s.active_mask,
"activeSpot": s.active_spot,
"undo": format!("{:?}", s.undo),
"redo": format!("{:?}", s.redo),
"commits": s.commits(),
"effects": s.mask_effects,
"rememberedEffects": s.mask_tool_effects,
"journal": s.journal,
"revision": s.catalog.revision,
})
}
fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) {
let before = target_state(s);
let result = s.execute(command, params);
let after = target_state(s);
assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after);
assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection");
for field in ["id", "component", "index"] {
if let Some(value) = params.get(field)
&& !value.is_null()
&& value.as_u64().is_none()
{
assert!(
result.unwrap_err().to_string().contains(&format!("`{field}`")),
"{command} must reject the malformed {field} before doing other work"
);
break;
}
}
}
fn malformed_targets() -> Vec<serde_json::Value> {
vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)]
}
#[test]
fn malformed_mask_ids_are_rejected_without_changes() {
let mut s = target_session();
let commands = [
("mask.delete", json!({})),
("mask.rename", json!({"name": "Wrong mask"})),
("mask.invert", json!({})),
("mask.visible", json!({"visible": false})),
("mask.duplicate", json!({})),
("mask.move", json!({"to": 0})),
("mask.adjust", json!({"values": {"exposure": 1.0}})),
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
("mask.addComponent", json!({"kind": "brush"})),
("mask.component", json!({"component": 0, "action": "invert"})),
("mask.brushStroke", json!({"points": [[0.5, 0.5]]})),
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
("mask.objectPoint", json!({"x": 0.5, "y": 0.5})),
("mask.refineDetail", json!({})),
];
for (command, mut params) in commands {
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
params["id"] = target;
rejects_target(&mut s, command, &params);
}
}
}
#[test]
fn malformed_mask_selection_is_rejected_without_changes() {
let mut s = target_session();
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) {
rejects_target(&mut s, "mask.select", &json!({"id": target}));
}
}
#[test]
fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() {
let mut s = target_session();
rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64}));
}
#[test]
fn malformed_mask_effect_targets_are_rejected_without_changes() {
let mut s = target_session();
for (command, mut params) in [
("mask.saveEffect", json!({"name": "Wrong effect"})),
("mask.updateEffect", json!({"name": "Existing"})),
("mask.applyEffect", json!({"name": "Existing"})),
] {
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
params["id"] = target;
rejects_target(&mut s, command, &params);
}
}
for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) {
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target}));
}
}
#[test]
fn mask_effect_targets_preserve_explicit_defaults_and_lists() {
let mut s = target_session();
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap();
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0);
for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] {
let mut params = target;
params["name"] = json!(name);
s.execute("mask.saveEffect", &params).unwrap();
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0);
params["name"] = json!("Existing");
s.execute("mask.updateEffect", &params).unwrap();
assert_eq!(s.mask_effects[0].adjust.exposure, 1.0);
}
s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
assert_eq!(s.mask_effects[0].adjust.exposure, 0.0);
for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] {
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
params["name"] = json!("Existing");
s.execute("mask.applyEffect", &params).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0);
}
s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap();
s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap();
s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap();
assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0));
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []}));
}
#[test]
fn malformed_mask_components_are_rejected_without_changes() {
let mut s = target_session();
for (command, mut params) in [
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
("mask.component", json!({"action": "invert"})),
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
("mask.refineDetail", json!({})),
] {
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
params["component"] = target;
rejects_target(&mut s, command, &params);
}
}
}
#[test]
fn malformed_spot_indices_are_rejected_without_changes() {
let mut s = target_session();
for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] {
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
params["index"] = target;
rejects_target(&mut s, command, &params);
}
}
}
#[test]
fn malformed_spot_selection_is_rejected_without_changes() {
let mut s = target_session();
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) {
rejects_target(&mut s, "spot.select", &json!({"index": target}));
}
}
#[test]
fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() {
use lightcraft_develop::MaskShape;
let mut s = target_session();
for target in [json!({}), json!({"id": null})] {
let mut params = target;
params["name"] = json!("Selected mask");
s.execute("mask.rename", &params).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask");
}
s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask");
assert_eq!(s.active_mask, Some(2));
for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] {
s.execute("mask.update", &params).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. }));
assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. }));
}
s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap();
assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky));
for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] {
s.execute("mask.brushStroke", &params).unwrap();
}
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2);
for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] {
s.execute("mask.sampleColor", &params).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") };
assert_eq!(samples.len(), 1);
}
for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] {
s.execute("spot.update", &params).unwrap();
assert_eq!(spots(&s)[1].size, 0.04);
}
s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap();
assert_eq!(spots(&s)[0].size, 0.03);
assert_eq!(s.active_spot, Some(1));
rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"}));
for params in [json!({"id": null}), json!({})] {
s.execute("mask.select", &json!({"id": 1})).unwrap();
assert_eq!(s.active_mask, Some(1));
s.execute("mask.select", &params).unwrap();
assert_eq!(s.active_mask, None);
}
for params in [json!({"index": null}), json!({})] {
s.execute("spot.select", &json!({"index": 0})).unwrap();
assert_eq!(s.active_spot, Some(0));
s.execute("spot.select", &params).unwrap();
assert_eq!(s.active_spot, None);
}
rejects_target(&mut s, "mask.delete", &json!({}));
rejects_target(&mut s, "spot.delete", &json!({}));
// An omitted mask still creates a brush mask when no mask is selected.
s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3);
}
+18 -8
View File
@@ -75,12 +75,8 @@ fn record_fallback(reason: String) {
/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …".
/// Never blocks.
pub fn unavailable_reason() -> Option<String> {
if env_disabled() {
#[cfg(not(target_arch = "wasm32"))]
if backend::env_off() {
return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into());
}
return Some("disabled by LIGHTCRAFT_GPU=0".into());
if let Some(why) = env_off_reason() {
return Some(why.into());
}
if !ENABLED.load(Ordering::Relaxed) {
return Some("disabled by the GPU rendering preference (app.gpu)".into());
@@ -245,12 +241,26 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn switched_off() -> Option<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
if let Some(why) = env_off_reason() {
return Some(why.into());
}
(!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into())
}
/// Why the environment switches the GPU off for the whole process (`LIGHTCRAFT_GPU=0` or
/// `LIGHTCRAFT_GPU_BACKEND=off`), or `None`. Code with a GPU path of its own (the SAM 3 model on
/// Metal) follows it too.
pub fn env_off_reason() -> Option<&'static str> {
if !env_disabled() {
return None;
}
#[cfg(not(target_arch = "wasm32"))]
if backend::env_off() {
return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off");
}
Some("disabled by LIGHTCRAFT_GPU=0")
}
/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process.
fn env_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+4
View File
@@ -193,6 +193,7 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
fn with_reply(reply: &[u8], check: impl FnOnce(&mut Remote, Result<Value, String>)) {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let reply = reply.to_vec();
@@ -307,6 +308,7 @@ mod tests {
#[test]
fn consecutive_replies_match_their_requests_on_one_connection() {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let worker = std::thread::spawn(move || {
@@ -331,6 +333,7 @@ mod tests {
#[test]
fn lost_reply_is_not_resent() {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(AtomicUsize::new(0));
@@ -353,6 +356,7 @@ mod tests {
#[test]
fn connection_closed_by_app_reconnects_before_sending() {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(AtomicUsize::new(0));
+3 -6
View File
@@ -61,16 +61,13 @@ impl Headless {
/// With `path` and no `format`, the format follows the path's extension.
fn export(&mut self, p: &Value) -> Result<Value, String> {
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch};
let p = &self.session.export_params(p)?;
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
let p = &self.session.export_params_or(p, ExportFormat::Png)?;
// PNG for a `path` that names no format; `bitDepth` is checked against the format written
let mut opts = ExportOptions::from_params_or(p, ExportFormat::Png).map_err(|e| e.to_string())?;
if !ExportOptions::has_size_param(p) {
opts.resize = Some(Resize::long_edge(3000));
}
let exact = p.get("path").and_then(Value::as_str);
if let (Some(path), None) = (exact, p.get("format")) {
let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png);
}
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
// like the desktop app: the selection (in grid order), else the given / active photo
+11
View File
@@ -33,3 +33,14 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7980";
#[cfg(test)]
mod tests;
/// Held by tests that start a child process (a copy of the test binary) and by tests that run a
/// loopback socket server, so the two never overlap. On Windows the child can inherit a socket the
/// server just accepted; when the child exits, Windows closes its copy abortively and the test's
/// connection is reset ("An existing connection was forcibly closed"), which failed the
/// `backend` reply tests whenever they ran alongside a child-process test.
#[cfg(test)]
pub(crate) fn serial_sockets_and_children() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
+1
View File
@@ -100,6 +100,7 @@ fn progress_flush_failure_does_not_wait_for_input_eof() {
}
fn run_shutdown_case(mode: &str) {
let _serial = crate::serial_sockets_and_children();
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "server::progress::shutdown_tests::output_failure_child", "--ignored", "--nocapture"])
.env("LIGHTCRAFT_MCP_SHUTDOWN_CASE", mode)
+38
View File
@@ -147,6 +147,43 @@ fn export_refuses_unknown_params_and_bad_values() {
assert!(e.contains("did you mean `quality`"), "{e}");
}
/// Review of #1966: a `path` whose extension names no format exports PNG through the headless
/// backend, so its `bitDepth` is checked against PNG's, not let through to an 8-bit file.
#[test]
fn export_checks_the_depth_of_the_format_a_bare_path_gets() {
let mut b = Headless::demo();
let base = std::env::temp_dir().join(format!("lc-mcp-bare-path-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
for name in ["output", "output.xyz"] {
let e = b.call("app.export", json!({"path": base.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10})).unwrap_err();
assert!(e.contains("`bitDepth` must be 8 or 16 for png"), "{name}: {e}");
}
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0, "nothing written");
// the format such a path gets is still PNG, and 16 bits is one of its depths
b.call("app.export", json!({"path": base.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 16})).unwrap();
let written = std::fs::read_dir(&base).unwrap().next().unwrap().unwrap().path();
let bytes = std::fs::read(&written).unwrap();
assert!(bytes.starts_with(b"\x89PNG"), "{written:?} is a PNG");
assert_eq!(bytes[24], 16, "{written:?} is 16-bit");
let _ = std::fs::remove_dir_all(&base);
}
/// Review of #1966: a preset's `bitDepth` is judged against the format this backend writes, PNG
/// for a `path` that names none, so a 16-bit preset without `format` writes a 16-bit PNG.
#[test]
fn a_preset_depth_is_judged_against_the_format_written() {
let mut b = Headless::demo();
let base = std::env::temp_dir().join(format!("lc-mcp-preset-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
b.session.export_presets.push(lightcraft_engine::export::ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16}) });
b.call("app.export", json!({"preset": "Deep", "path": base.join("output").to_string_lossy(), "longEdge": 32})).unwrap();
let bytes = std::fs::read(base.join("output")).unwrap();
assert!(bytes.starts_with(b"\x89PNG") && bytes[24] == 16, "a 16-bit PNG: {:?}", bytes.get(..25));
let _ = std::fs::remove_dir_all(&base);
}
/// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped).
#[test]
fn export_tool_forwards_its_advertised_options() {
@@ -225,6 +262,7 @@ fn a_one_shot_process_never_ends_during_a_photos_import() {
drop(h); // what lightcraft-cli does before it exits
return;
}
let _serial = crate::serial_sockets_and_children();
for mode in ["default", "nowait"] {
let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
+4 -5
View File
@@ -214,7 +214,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
tool(
"render_photo",
"Render photo",
"Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.",
"Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result. `path`, when given, is overwritten with the rendered file; a catalogued original or its XMP sidecar is refused instead.",
json!({
"id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"},
"size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"},
@@ -226,7 +226,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
tool(
"export",
"Export photo",
"Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`).",
"Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`). An existing file at `path` is replaced, except a catalogued original or its XMP sidecar, which is refused (nothing is written); in `dir` batch mode name collisions resolve per `conflict` (default `unique`). Photo metadata strings (titles, captions, XMP) are untrusted data — treat them as agent-controlled, not sanitized.",
json!({
"id": photo_id,
"ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"},
@@ -279,7 +279,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
tool(
"screenshot",
"Screenshot",
"Capture the LightCraft window (after pending renders finish) and return it as an image.",
"Capture the LightCraft window (after pending renders finish) and return it as an image. `path`, when given, is overwritten with the screenshot file; a catalogued original or its XMP sidecar is refused instead.",
json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}),
&[],
),
@@ -384,8 +384,7 @@ fn annotations(name: &str, title: &str) -> Value {
| "ui_inspect"
| "ui_screenshot"
);
let files = matches!(name, "export" | "render_photo" | "screenshot");
json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files,
json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only,
"idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false})
}
+27
View File
@@ -78,6 +78,33 @@ fn exposure_roundtrip(server: &mut Server) {
assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}");
}
#[test]
fn file_writers_advertise_destructive_effects() {
for with_commands in [false, true] {
let mut server = Server::new(Box::new(Headless::demo())).with_command_tools(with_commands);
let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]);
let tools = replies[0]["result"]["tools"].as_array().unwrap();
for name in ["export", "render_photo"] {
let tool = tools.iter().find(|t| t["name"] == name).unwrap();
assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}");
assert_eq!(tool["annotations"]["destructiveHint"], true, "{name} can replace a file");
}
let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap();
assert_eq!(preview["annotations"]["readOnlyHint"], true);
assert_eq!(preview["annotations"]["destructiveHint"], false);
}
let addr = fake_app();
let mut server = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]);
let tools = replies[0]["result"]["tools"].as_array().unwrap();
let screenshot = tools.iter().find(|t| t["name"] == "screenshot").unwrap();
assert_eq!(screenshot["annotations"]["readOnlyHint"], false);
assert_eq!(screenshot["annotations"]["destructiveHint"], true);
let image_only = tools.iter().find(|t| t["name"] == "ui_screenshot").unwrap();
assert_eq!(image_only["annotations"]["readOnlyHint"], true);
assert_eq!(image_only["annotations"]["destructiveHint"], false);
}
#[test]
fn headless_set_exposure_and_render() {
exposure_roundtrip(&mut Server::new(Box::new(Headless::demo())));
+9
View File
@@ -453,6 +453,7 @@ pub fn write_exif(meta: &Metadata) -> Vec<u8> {
(t::F_NUMBER, r(meta.f_number)),
(t::FOCAL_LENGTH, r(meta.focal_length)),
(t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))),
(t::ISO_SPEED_RATINGS_EXT, meta.iso.filter(|&i| i > 65_534).map(|i| Value::Long(vec![i]))),
(t::LENS_MAKE, ascii(&meta.lens_make)),
(t::LENS_MODEL, ascii(&meta.lens_model)),
(t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))),
@@ -518,4 +519,12 @@ mod write_tests {
let g = back.gps.unwrap();
assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}");
}
#[test]
fn iso_above_65534_round_trips() {
for iso in [65_534, 65_535, 102_400, 409_600] {
let back = read_exif(&write_exif(&Metadata { iso: Some(iso), ..Default::default() }));
assert_eq!(back.iso, Some(iso));
}
}
}
+58 -2
View File
@@ -484,6 +484,11 @@ pub fn push_pressures(pressure: &mut Vec<f32>, added: usize, from: f32, to: f32)
/// pointer is held still (the same screen position mapped again, up to float rounding).
const STATIONARY_PX: f64 = 1e-6;
/// Most dabs one stroke holds (32 MiB of centres). A segment is capped on its own, but a stroke of
/// many hostile segments (an agent, a crafted XMP or catalog) would otherwise add up without
/// bound; past this the rest of the stroke lays nothing (#1520).
pub const MAX_STROKE_DABS: usize = 2_000_000;
/// The dabs of stroke `s` in a `w × h` output.
pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs {
let to_out = frame.norm_to_out(w, h);
@@ -537,11 +542,21 @@ pub fn dab_spacing(r: f64) -> f64 {
/// their dabs with this, so they cover the same pixels (issue #517). A sample where the pointer
/// hasn't moved (a stationary hold records one per frame) or that is not a finite point lays
/// nothing, so holding the brush still adds no coverage (#518). Returns whether `q` was stamped;
/// callers move `prev` on only then.
/// callers move `prev` on only then. A stroke holding [`MAX_STROKE_DABS`] takes no more.
pub fn push_dabs(dabs: &mut Vec<Point>, prev: Option<Point>, q: Point, r: f64) -> bool {
if !(q.x.is_finite() && q.y.is_finite()) {
let mut emitted = dabs.len();
push_dabs_counted(dabs, &mut emitted, prev, q, r)
}
/// Append one segment using a persistent count of all dabs emitted for this stroke, including
/// dabs no longer held in `dabs`. Incremental callers retain `emitted` when clearing their buffer;
/// it advances only by the number actually appended. Acceptance and spacing match [`push_dabs`].
pub fn push_dabs_counted(dabs: &mut Vec<Point>, emitted: &mut usize, prev: Option<Point>, q: Point, r: f64) -> bool {
let room = MAX_STROKE_DABS.saturating_sub(*emitted);
if room == 0 || !(q.x.is_finite() && q.y.is_finite()) {
return false;
}
let before = dabs.len();
if let Some(prev) = prev {
let dist = prev.dist(q);
if dist.is_nan() || dist <= STATIONARY_PX {
@@ -551,11 +566,14 @@ pub fn push_dabs(dabs: &mut Vec<Point>, prev: Option<Point>, q: Point, r: f64) -
// a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge
// allocation: 1 M dabs is far more than any on-screen stroke needs
let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 };
// the in-between dabs and `q` together fit what is left of the stroke's budget
let n = n.min(room);
for k in 1..n {
dabs.push(prev.lerp(q, k as f64 / n as f64));
}
}
dabs.push(q);
*emitted += dabs.len() - before;
true
}
@@ -838,6 +856,17 @@ mod tests {
}
assert_eq!(committed.dabs, again);
assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9));
// many huge segments stop at the stroke budget instead of adding up without bound
let mut big = Vec::new();
let mut at = Point::new(0.0, 0.0);
for i in 0..5 {
let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0);
if push_dabs(&mut big, Some(at), q, 1.0) {
at = q;
}
}
assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len());
assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more");
// a hostile segment places no in-between dabs
let mut v = Vec::new();
assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r));
@@ -846,6 +875,33 @@ mod tests {
assert!(v.len() <= 1_000_002, "{}", v.len());
}
/// A temporary output buffer must spend the same stroke budget as a retained one.
#[test]
fn incremental_dab_buffers_share_the_stroke_budget() {
let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)];
let mut retained = Vec::new();
let mut streamed = Vec::new();
let mut retained_count = MAX_STROKE_DABS - 3;
let mut streamed_count = retained_count;
let mut prev = None;
for q in points {
let mut chunk = Vec::new();
let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0);
assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0));
streamed.extend(chunk);
if accepted {
prev = Some(q);
}
}
assert_eq!(retained, streamed);
assert_eq!(retained.len(), 3);
assert_eq!(retained_count, MAX_STROKE_DABS);
assert_eq!(streamed_count, retained_count);
assert_eq!(prev, Some(points[1]));
assert!(!retained.contains(&points[2]));
assert!(!retained.contains(&points[3]));
}
/// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile
/// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure.
#[test]
+194 -28
View File
@@ -11,11 +11,18 @@
//!
//! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity,
//! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear
//! sRGB primaries; the codestream's declared primaries are then restored with a plain 3×3 matrix. We
//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to
//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and
//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the
//! codec's loss, and can exceed 1.0 or dip below 0.
//! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a
//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a
//! lossless one with the same header, so a declared transfer curve is part of the stored values, not a
//! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store
//! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic
//! `MapPolynomial` per plane in `OpcodeList2`. Measured against Lightroom's own preview of two such
//! files, the polynomial applied to the samples in the declared encoding tracks Lightroom's rendering
//! (midtone log-log slope 0.98 and 1.38, about 1 EV apart); applied to the decoder's linear output it is
//! 3.6 EV too dark. We never let jxl-oxide convert to the declared encoding itself:
//! that step gamut-maps and clips to [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of
//! 32768 puts them far above) and desaturates. The result is the sample space the DNG's `WhiteLevel`
//! refers to, up to the codec's loss, and can exceed 1.0 or dip below 0.
//!
//! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets
//! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then
@@ -79,20 +86,49 @@ fn rgb_to_xyz(p: [[f64; 2]; 3]) -> Matrix {
std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j]))
}
/// A declared transfer curve, applied to linear values (linear → coded). Mirrored for negative values
/// and not clipped above 1.0, so lossy undershoot and raw values above white survive.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Curve {
Linear,
/// IEC 61966-2-1 (sRGB).
Srgb,
/// ITU-R BT.709.
Bt709,
/// A pure power: coded = linear^exponent.
Power(f32),
}
impl Curve {
fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
Curve::Linear => return v,
Curve::Srgb if a <= 0.003_130_8 => 12.92 * a,
Curve::Srgb => 1.055 * a.powf(1.0 / 2.4) - 0.055,
Curve::Bt709 if a < 0.018 => 4.5 * a,
Curve::Bt709 => 1.099 * a.powf(0.45) - 0.099,
Curve::Power(p) => a.powf(p),
};
e.copysign(v)
}
}
/// How a chunk is rendered by jxl-oxide.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Output {
/// In the codestream's own colour encoding (non-XYB: the identity).
AsCoded,
/// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared
/// primaries (`None` when those are sRGB's).
FromXyb(Option<[[f32; 3]; 3]>),
/// XYB (lossy) data: linear sRGB primaries from the decoder, then `matrix` to the declared
/// primaries (`None` when those are sRGB's), then the declared transfer `curve`.
FromXyb { matrix: Option<[[f32; 3]; 3]>, curve: Curve },
}
/// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour
/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is a
/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would
/// apply, otherwise the samples cannot be restored faithfully.
/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB
/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG
/// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored
/// faithfully.
fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result<Output> {
if !xyb {
return Ok(Output::AsCoded);
@@ -104,8 +140,26 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
let ColourEncoding::Enum(e) = encoding else {
return Err(unsupported("an ICC profile instead of a colour encoding"));
};
if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear {
return Err(unsupported("a declared encoding that is not linear RGB"));
if e.colour_space != ColourSpace::Rgb {
return Err(unsupported("a declared encoding that is not RGB"));
}
let curve = match e.tf {
TransferFunction::Linear => Curve::Linear,
TransferFunction::Srgb => Curve::Srgb,
TransferFunction::Bt709 => Curve::Bt709,
TransferFunction::Dci => Curve::Power(1.0 / 2.6),
// as jxl-oxide reads it: a codestream's gamma is the encoding exponent (×1e7) with `inverted`
TransferFunction::Gamma { g, inverted } => {
let g = g as f32 / 1e7;
Curve::Power(if inverted { g } else { 1.0 / g })
}
TransferFunction::Pq | TransferFunction::Hlg => return Err(unsupported("an HDR transfer curve (PQ or HLG)")),
TransferFunction::Unknown => return Err(unsupported("an unknown transfer curve")),
};
if let Curve::Power(p) = curve
&& !(p.is_finite() && p > 0.0)
{
return Err(unsupported("an invalid gamma"));
}
if e.white_point != WhitePoint::D65 {
return Err(unsupported("a white point other than D65"));
@@ -114,7 +168,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
return Err(unsupported("an intensity target above 255 nits"));
}
let primaries = match e.primaries {
Primaries::Srgb => return Ok(Output::FromXyb(None)),
Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }),
Primaries::Bt2100 => BT2100,
Primaries::P3 => P3,
Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)),
@@ -126,7 +180,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
if m.iter().flatten().any(|v| !v.is_finite()) {
return Err(unsupported("degenerate primaries"));
}
Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32)))))
Ok(Output::FromXyb { matrix: Some(m.map(|r| r.map(|v| v as f32))), curve })
}
fn corrupt(e: impl std::fmt::Display) -> RawError {
@@ -177,7 +231,7 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
};
let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?;
if matches!(output, Output::FromXyb(_)) {
if matches!(output, Output::FromXyb { .. }) {
img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative));
}
Ok((img, max, output))
@@ -205,18 +259,23 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp
}
let n = cw.saturating_mul(ch).saturating_mul(cpp);
let rows = fb.buf().chunks_exact(jw * fc);
// lossy data: restore the declared primaries (see the module documentation)
let matrix = match output {
Output::FromXyb(m) => m,
Output::AsCoded => None,
// lossy data: restore the declared primaries and transfer curve (see the module documentation)
let (matrix, curve) = match output {
Output::FromXyb { matrix, curve } => (matrix, curve),
Output::AsCoded => (None, Curve::Linear),
};
let restore = |s: &[f32], d: &mut [f32]| match matrix {
Some(m) => {
for (d, row) in d.iter_mut().zip(&m) {
*d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2];
let restore = |s: &[f32], d: &mut [f32]| {
match matrix {
Some(m) => {
for (d, row) in d.iter_mut().zip(&m) {
*d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2];
}
}
None => d.copy_from_slice(&s[..cpp]),
}
if curve != Curve::Linear {
d.iter_mut().for_each(|v| *v = curve.encode(*v));
}
None => d.copy_from_slice(&s[..cpp]),
};
Ok(if float {
let mut out = vec![0f32; n];
@@ -257,7 +316,7 @@ mod tests {
fn matrix(o: Output) -> [[f32; 3]; 3] {
match o {
Output::FromXyb(Some(m)) => m,
Output::FromXyb { matrix: Some(m), .. } => m,
other => panic!("expected a matrix, got {other:?}"),
}
}
@@ -275,7 +334,47 @@ mod tests {
#[test]
fn lossy_data_with_srgb_primaries_needs_no_matrix() {
assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None));
assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear });
}
#[test]
fn lossy_data_keeps_the_declared_transfer_curve() {
// Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve
let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual);
e.primaries = Primaries::Bt2100;
e.tf = TransferFunction::Srgb;
let curve = |tf| {
let mut e = e.clone();
e.tf = tf;
match output_for(true, &ColourEncoding::Enum(e), 255.0, 3).unwrap() {
Output::FromXyb { matrix: Some(_), curve } => curve,
other => panic!("expected a matrix and a curve, got {other:?}"),
}
};
assert_eq!(curve(TransferFunction::Srgb), Curve::Srgb);
assert_eq!(curve(TransferFunction::Bt709), Curve::Bt709);
assert_eq!(curve(TransferFunction::Dci), Curve::Power(1.0 / 2.6));
// a codestream's gamma (as read: inverted) is the encoding exponent; jxl-oxide's own γ 2.2 is not
assert_eq!(curve(TransferFunction::Gamma { g: 4_545_455, inverted: true }), Curve::Power(0.454_545_5));
assert_eq!(curve(TransferFunction::Gamma { g: 22_000_000, inverted: false }), Curve::Power(1.0 / 2.2));
}
#[test]
fn transfer_curves_encode_unclipped_and_mirrored() {
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
// sRGB: linear toe, 18 % grey, white, and the toe/power junction is continuous
assert!(close(Curve::Srgb.encode(0.002), 0.002 * 12.92));
assert!(close(Curve::Srgb.encode(0.18), 0.461_356));
assert!(close(Curve::Srgb.encode(1.0), 1.0));
assert!(close(Curve::Srgb.encode(0.003_130_8), Curve::Srgb.encode(0.003_130_9)));
assert!(close(Curve::Bt709.encode(1.0), 1.0));
assert!((Curve::Bt709.encode(0.017_999) - Curve::Bt709.encode(0.018)).abs() < 1e-3);
assert!(close(Curve::Power(0.5).encode(0.25), 0.5));
// raw values above white keep growing, lossy undershoot keeps its sign, linear is the identity
assert!(Curve::Srgb.encode(2.0) > 1.3);
assert!(close(Curve::Srgb.encode(-0.18), -0.461_356));
assert_eq!(Curve::Linear.encode(-3.5), -3.5);
assert_eq!(Curve::Power(0.5).encode(0.0), 0.0);
}
#[test]
@@ -301,13 +400,80 @@ mod tests {
assert!(srgb_green_in_p3.iter().all(|v| (0.0..=1.0).contains(v)), "sRGB green lies inside P3");
}
// 16 × 16 RGB tiles of 16-bit samples coded lossily (XYB) by libjxl at distance 0.5, each declaring BT.2020
// primaries, a D65 white point and the named transfer curve. The samples are [`gradient`]; generated for these
// tests (original work).
const SRGB_TILE: &[u8] = &[
0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xc7, 0x8d, 0x00, 0x13, 0x08, 0x00, 0xf0, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca,
0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83,
0x84, 0x84, 0x33, 0x70, 0xde, 0x90, 0x97, 0xbd, 0x79, 0x64, 0xe4, 0x3b, 0x62, 0xaf, 0x1b, 0xb2, 0x74, 0xf6, 0x36, 0x43, 0x17, 0x72, 0xe7,
0x31, 0x86, 0xb6, 0x41, 0x1c, 0x00, 0x15, 0x52, 0x20, 0x11, 0x70, 0xcb, 0x46, 0x32, 0x92, 0x65, 0xcd, 0xc8, 0x18, 0xc9, 0x48, 0xe6, 0x49,
0xe6, 0x4f, 0x94, 0x35, 0xc7, 0x5c, 0x91, 0xb0, 0x5d, 0xdf, 0x2b, 0x9b, 0x93, 0x3b, 0xb6, 0x70, 0x7d, 0x24, 0xf0, 0xdd, 0x26, 0xb2, 0x26,
0xdf, 0x49, 0x5f, 0xd4, 0xaa, 0x1c, 0x8f, 0x7d, 0xc4, 0xf2, 0x6a, 0xd2, 0x27, 0x86, 0x93, 0xe7, 0x15, 0x9a, 0x01, 0x60, 0x84, 0xc0, 0x09,
];
const BT709_TILE: &[u8] = &[
0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xa7, 0x08, 0x00, 0x13, 0x08, 0x00, 0xe8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca,
0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83,
0x84, 0x84, 0x33, 0xd8, 0x9c, 0x32, 0xe2, 0x85, 0x6f, 0x3e, 0x6c, 0xa2, 0x1c, 0xaa, 0xdf, 0x1b, 0x32, 0xf4, 0xef, 0x36, 0xe3, 0x96, 0xf2,
0xe0, 0x65, 0xe8, 0x15, 0xc4, 0x01, 0x50, 0x21, 0x05, 0xd2, 0x00, 0xd8, 0x8c, 0x8c, 0x14, 0x59, 0xc9, 0x31, 0x32, 0x46, 0x52, 0x60, 0x82,
0x71, 0x03, 0xd9, 0xff, 0x8a, 0x3e, 0x7c, 0xb4, 0xc1, 0xa8, 0x49, 0xfa, 0x83, 0xa8, 0x74, 0xd7, 0x1d, 0xa8, 0xb4, 0x25, 0x67, 0xc1, 0x83,
0x93, 0x3a, 0xf3, 0x81, 0xb5, 0x2f, 0x44, 0x50, 0x35, 0x36, 0x68, 0x1c, 0xf9, 0x20, 0x17, 0x98, 0x87, 0x1d, 0x83, 0xb0, 0x02,
];
const GAMMA22_TILE: &[u8] = &[
0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xf7, 0x75, 0xab, 0x28, 0x02, 0x00, 0x13, 0x08, 0x00, 0xf8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50,
0xa1, 0x32, 0xca, 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36,
0xdb, 0x0b, 0x83, 0x84, 0x84, 0x33, 0xd8, 0x9c, 0x02, 0xed, 0x85, 0x6f, 0x3e, 0x2c, 0x72, 0x1e, 0xb8, 0xe3, 0x3b, 0x12, 0x75, 0xf8, 0x4e,
0xa3, 0x97, 0x71, 0xea, 0x31, 0x43, 0xa7, 0x20, 0x0e, 0x80, 0x0a, 0x29, 0x90, 0x08, 0xc0, 0x8a, 0x8c, 0x8a, 0x64, 0x74, 0x59, 0x45, 0xc6,
0x48, 0x46, 0x32, 0x4c, 0x32, 0x4c, 0xa0, 0xa5, 0x39, 0x23, 0xff, 0xdf, 0xcd, 0xf9, 0x33, 0x93, 0x5c, 0xb4, 0xec, 0xc1, 0x06, 0xf5, 0xcc,
0x92, 0x12, 0xf4, 0xbb, 0xef, 0x15, 0x84, 0xc7, 0xf2, 0xf1, 0x48, 0xfc, 0x80, 0xb9, 0x7a, 0xab, 0x58, 0x3e, 0x79, 0x3a, 0xcf, 0x67, 0xc7,
0xd3, 0xfc, 0x80, 0x60, 0x00,
];
const LINEAR_TILE: &[u8] = &[
0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0x47, 0x8b, 0x00, 0x13, 0x08, 0x00, 0x14, 0x02, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca,
0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83,
0x84, 0x84, 0x43, 0xd8, 0x36, 0xe1, 0xbe, 0x88, 0x9b, 0x27, 0x48, 0x71, 0x04, 0xe2, 0xeb, 0x4e, 0xf9, 0x74, 0x9d, 0xe7, 0x00, 0x8d, 0x74,
0xcb, 0xc0, 0xe0, 0x5d, 0x58, 0x10, 0x80, 0x0a, 0x29, 0x90, 0x06, 0xd8, 0xac, 0x18, 0x19, 0x45, 0xc5, 0x28, 0x89, 0x8a, 0x24, 0xd3, 0xff,
0xff, 0x1c, 0xc4, 0x2f, 0xd8, 0x26, 0x80, 0x6c, 0xe1, 0xc3, 0xbe, 0xef, 0xab, 0xe1, 0x79, 0x1e, 0xff, 0x1b, 0x2e, 0x57, 0xf7, 0x2c, 0x1f,
0x1f, 0xf8, 0xec, 0xd3, 0x33, 0x21, 0x9e, 0x12, 0x22, 0x43, 0xc2, 0xfd, 0xb3, 0xf2, 0xe0, 0x40, 0xdf, 0x25, 0x0f, 0x67, 0xe7, 0xca, 0x7e,
0xa5, 0x7f, 0x1f, 0x21, 0xa4, 0x6a, 0x1e, 0xa0, 0x0e,
];
/// The samples the fixture tiles were coded from: `(x, y)` → RGB.
fn gradient(x: usize, y: usize) -> [f32; 3] {
let (x, y) = (x as f32, y as f32);
[4000.0 + x * 3500.0, 6000.0 + y * 3200.0, 30000.0 + (x - y) * 1500.0]
}
#[test]
fn lossy_tiles_decode_to_their_samples_whatever_sdr_curve_they_declare() {
for (name, tile) in [("sRGB", SRGB_TILE), ("BT.709", BT709_TILE), ("gamma 2.2", GAMMA22_TILE), ("linear", LINEAR_TILE)] {
let ChunkPx::U16(px) = decode(tile, 16, 16, 16, 16, 3, 16, false).unwrap() else { panic!("{name}: integer samples") };
let (mut sum, mut worst) = (0.0f32, 0.0f32);
for (i, p) in px.as_chunks::<3>().0.iter().enumerate() {
for (got, want) in p.iter().zip(gradient(i % 16, i / 16)) {
let err = (f32::from(*got) - want).abs();
sum += err;
worst = worst.max(err);
}
}
// the reference decoder (libjxl) lands within 0.5 % on average and 6 % at worst on these tiles
let mean = sum / px.len() as f32;
assert!(mean < 0.01 * 65535.0, "{name}: mean error {mean}");
assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}");
}
}
#[test]
fn lossy_data_that_cannot_be_restored_is_unsupported() {
let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_)));
assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey");
assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map");
assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear");
let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative);
for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] {
e.tf = tf;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "{tf:?}");
}
e.tf = TransferFunction::Linear;
e.white_point = WhitePoint::Dci;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point");
e.white_point = WhitePoint::D65;
+54 -2
View File
@@ -71,6 +71,15 @@ use serde::{Deserialize, Serialize};
/// Upper bound on decoded samples (guards allocations driven by header values).
pub const MAX_SAMPLES: usize = 1 << 30;
/// Checked product of input-derived dimensions, in u64 so it cannot wrap
/// on either 32-bit (wasm32) or 64-bit targets. The arguments are u64 too, so callers can feed
/// unchecked products of header values (row bytes, sample counts) without truncating on 32-bit.
///
/// Returns `None` on overflow, zero, or when the product exceeds [`MAX_SAMPLES`].
pub(crate) fn checked_pixel_count(width: u64, height: u64) -> Option<u64> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64)
}
/// Errors from raw decoding.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RawError {
@@ -765,8 +774,14 @@ impl RawImage {
if self.cpp == 2 {
return Err(RawError::Unsupported("2 samples per pixel".into()));
}
if samples && self.data.len() != self.width * self.height * self.cpp {
return Err(RawError::Corrupt("data length mismatch".into()));
if samples {
let expected = crate::checked_pixel_count(self.width as u64, self.height as u64)
.and_then(|n| n.checked_mul(self.cpp as u64))
.ok_or_else(|| RawError::Corrupt("data length mismatch".into()))?;
let actual = u64::try_from(self.data.len()).map_err(|_| RawError::Corrupt("data length mismatch".into()))?;
if actual != expected {
return Err(RawError::Corrupt("data length mismatch".into()));
}
}
if let Some(c) = &self.cfa
&& (!c.valid() || c.pattern.iter().any(|&p| p > 2))
@@ -1416,3 +1431,40 @@ mod tests {
assert_eq!(decode(b"junk"), Err(RawError::NotRaw));
}
}
#[cfg(test)]
mod checked_arithmetic_tests {
use super::*;
#[test]
fn checked_pixel_count_wrap_inputs_rejected() {
// 65536 × 65536 = 2^32 wraps to 0 on wasm32 usize; u64 math keeps it exact.
assert_eq!(checked_pixel_count(65536, 65536), None);
assert_eq!(checked_pixel_count(65536, 65537), None);
// u32::MAX × 2 wraps on 32-bit usize.
assert_eq!(checked_pixel_count(u64::from(u32::MAX), 2), None);
// inputs wider than a 32-bit usize reach the gate untruncated (2^40 × 2^40 overflows u64).
assert_eq!(checked_pixel_count(1 << 40, 1 << 40), None);
}
#[test]
fn checked_pixel_count_over_cap_rejected() {
// MAX_SAMPLES = 2^30; one over is rejected.
assert_eq!(checked_pixel_count((1 << 30) + 1, 1), None);
assert_eq!(checked_pixel_count(1, (1 << 30) + 1), None);
}
#[test]
fn checked_pixel_count_at_cap_accepted() {
assert_eq!(checked_pixel_count(1 << 30, 1), Some(1 << 30));
assert_eq!(checked_pixel_count(1 << 15, 1 << 15), Some(1 << 30));
}
#[test]
fn checked_pixel_count_small_accepted() {
assert_eq!(checked_pixel_count(100, 100), Some(10_000));
assert_eq!(checked_pixel_count(1, 1), Some(1));
assert_eq!(checked_pixel_count(0, 100), None);
assert_eq!(checked_pixel_count(100, 0), None);
}
}
+6 -2
View File
@@ -192,8 +192,12 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
}
comp::ADOBE_DEFLATE | comp::DEFLATE => {
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) };
let raw = inflate(src, row_bytes * ch)?;
// row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate
let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) };
// the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too
let inflate_len =
crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?;
let raw = inflate(src, inflate_len as usize)?;
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
}
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
+8 -7
View File
@@ -245,7 +245,9 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O
/// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file.
fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool {
let (w, h) = (info.width as u64, info.height as u64);
info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && strip_len == w * h * 3 / 2
// checked_mul chain: a wrapping `w · h · 3` must refuse the strip, not compare against a wrapped length
let packed_len = w.checked_mul(h).and_then(|n| n.checked_mul(3)).map(|n| n / 2);
info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && packed_len == Some(strip_len)
}
/// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being
@@ -621,9 +623,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?;
let info = raw.image()?;
let (w, h) = (info.width as usize, info.height as usize);
if w * h > crate::MAX_SAMPLES {
return Err(RawError::Limit("image too large"));
}
// the cap (≤ 2^30) keeps every downstream usize product (w·h·2, w·h·3/2, y·w) inside range on 32-bit targets
let samples = crate::checked_pixel_count(w as u64, h as u64).ok_or(RawError::Limit("image too large"))?;
let bits = info.bits() as u32;
let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR;
let chunks = info.chunks(bytes.len() as u64);
@@ -631,7 +632,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test
// archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one
// smaller), which is a different packing, so they are refused rather than read with the wrong layout.
let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64;
let one_byte_per_sample = chunks.len() == 1 && strip_len == samples;
let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len);
let (data, out_bits) = match info.compression {
7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14),
@@ -642,7 +643,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
32767 if one_byte_per_sample => {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100]));
let mut data = vec![0u16; w * h];
let mut data = vec![0u16; samples as usize];
data.par_chunks_mut(w).enumerate().for_each(|(y, out)| {
let row = src.get(y * w..((y + 1) * w).min(src.len())).unwrap_or(&[]);
if row.len() == w {
@@ -659,7 +660,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
32767 if packed12 => {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
let row_bytes = w / 2 * 3;
let mut data = vec![0u16; w * h];
let mut data = vec![0u16; samples as usize];
data.par_chunks_mut(w).enumerate().for_each(|(y, out)| {
if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) {
unpack_row12(row, out);
+12 -2
View File
@@ -161,7 +161,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
let (fw, fh, nc, prec) = ljpeg::frame_info(src)?;
let frame = match mode {
Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?),
Mode::Full => {
let total = (fw as u64)
.checked_mul(fh as u64)
.and_then(|v| v.checked_mul(nc as u64))
.filter(|t| *t > 0 && *t <= crate::MAX_SAMPLES as u64)
.ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;
Some(ljpeg::decode(src, total as usize)?)
}
Mode::Header => None,
};
let total = match &frame {
@@ -186,7 +193,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
v.push(s[2] as usize);
v
}
None => vec![fw * nc],
None => {
let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;
vec![w as usize]
}
};
let width: usize = widths.iter().sum();
if width == 0 || total % width != 0 {
+84 -26
View File
@@ -366,6 +366,39 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) {
(length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after))
}
/// The width at which high-frequency band `index` (1–9, coarsest level first) is entropy coded,
/// given its synthesis width `width`.
///
/// A tile with a neighbour on its right codes the vertical high-pass band of the level-1 and level-2
/// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd
/// width: an even synthesis width gains one column, which synthesis does not use. Every other band,
/// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane
/// that `tests/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816,
/// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5
/// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and
/// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream
/// ends exactly at that width and at no other width from `width` to `width + 2`. The width used to be
/// probed by trying `width`, `width + 1` and `width + 2` in turn, but two widths can both consume a
/// stream exactly and give different coefficients (#899), so it is now taken from the geometry.
/// No public file has a middle tile (three or more C-RAW tiles); it is assumed to follow the left tile.
fn coded_band_width(index: usize, width: usize, edges: TileEdges) -> usize {
if edges.right && matches!(index, 5 | 8) { width | 1 } else { width }
}
/// A high-frequency band entropy coded `coded_width` columns wide, cropped to its `width` synthesis columns.
fn coded_high_frequency_band(src: &[u8], width: usize, coded_width: usize, height: usize) -> Result<Vec<i32>> {
let values = high_frequency_band(src, coded_width, height)?;
if coded_width == width {
return Ok(values);
}
let mut cropped = Vec::new();
cropped.try_reserve_exact(width.saturating_mul(height)).map_err(|_| RawError::Limit("CRX cropped band allocation"))?;
for row in values.chunks_exact(coded_width) {
cropped.extend_from_slice(row.get(..width).ok_or_else(|| corrupt("band crop outside row"))?);
}
Ok(cropped)
}
fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result<Vec<i32>> {
let mut dimensions = vec![(width, height)];
let mut high_dimensions = Vec::new();
@@ -383,32 +416,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge
let mut bands = Vec::with_capacity(10);
for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() {
let bytes = band_bytes(sample, band)?;
let mut data = if i == 0 {
lossless_plane(bytes, w, h)?
} else {
// Some CRX high-frequency bands retain a redundant right column
// beyond synthesis support (LH2 in the M50/R100 fixtures). Infer
// that small extension only from a complete, correctly aligned
// entropy stream, then crop each row to the synthesis support.
let mut decoded = None;
for extra in 0..=2 {
let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?;
if let Ok(values) = high_frequency_band(bytes, coded_width, h) {
if extra == 0 {
decoded = Some(values);
} else {
let mut cropped = Vec::new();
cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?;
for row in values.chunks_exact(coded_width) {
cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?);
}
decoded = Some(cropped);
}
break;
}
}
decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))?
};
let mut data = if i == 0 { lossless_plane(bytes, w, h)? } else { coded_high_frequency_band(bytes, w, coded_band_width(i, w, edges), h)? };
for (position, value) in data.iter_mut().enumerate() {
let step = match band.quant {
Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?,
@@ -951,6 +959,56 @@ mod tests {
}
}
#[test]
fn coded_band_width_follows_the_measured_tile_geometry() {
let left_tile = TileEdges { right: true, ..TileEdges::default() };
let right_tile = TileEdges { left: true, ..TileEdges::default() };
let single = TileEdges::default();
// Bands 5 and 8 of a left tile: an even width gains a column (RP 400 -> 401, R 862 -> 863,
// 90D / M6 Mark II 892 -> 893), an odd one does not (90D / M6 Mark II band 5 at 447).
for (band, width, coded) in [(5, 394, 395), (5, 400, 401), (5, 432, 433), (5, 447, 447), (8, 862, 863), (8, 892, 893), (8, 705, 705)] {
assert_eq!(coded_band_width(band, width, left_tile), coded, "band {band} at {width}");
}
// The last level's low-pass columns are not transformed again: band 2 keeps an even width.
assert_eq!(coded_band_width(2, 198, left_tile), 198);
for band in [1, 3, 4, 6, 7, 9] {
assert_eq!(coded_band_width(band, 394, left_tile), 394, "band {band}");
}
for band in 1..=9 {
assert_eq!(coded_band_width(band, 446, right_tile), 446, "band {band}");
assert_eq!(coded_band_width(band, 446, single), 446, "band {band}");
}
}
#[test]
fn a_band_that_decodes_at_two_widths_uses_the_coded_width() {
// #899: entropy alignment alone does not identify the coded width. This 2-row stream is
// consumed exactly both 2 and 3 columns wide, with different retained coefficients.
let src = [0xc0, 0x0f];
assert_eq!(high_frequency_band(&src, 2, 2).unwrap(), [0, 0, 5, -2]);
assert_eq!(high_frequency_band(&src, 3, 2).unwrap(), [0, 0, 5, 0, 0, 0]);
// The width probe took the first width that decoded (2). Band 8 of a left tile at an even
// width is coded one column wider, so the geometry selects 3 and crops it.
let coded = coded_band_width(8, 2, TileEdges { right: true, ..TileEdges::default() });
assert_eq!(coded, 3);
assert_eq!(coded_high_frequency_band(&src, 2, coded, 2).unwrap(), [0, 0, 0, 0]);
// Elsewhere the same stream is read at its synthesis width.
assert_eq!(coded_high_frequency_band(&src, 2, coded_band_width(7, 2, TileEdges::default()), 2).unwrap(), [0, 0, 5, -2]);
// The #899 reproducer: an odd width is never extended, so the narrow reading is kept.
let src = [0x00, 0x33];
assert_eq!(coded_band_width(5, 3, TileEdges { right: true, ..TileEdges::default() }), 3);
assert_eq!(coded_high_frequency_band(&src, 3, 3, 1).unwrap(), [5, 0, -1]);
assert_eq!(high_frequency_band(&src, 4, 1).unwrap(), [5, 0, 0, 0]);
}
#[test]
fn a_band_that_fails_at_its_coded_width_is_an_error() {
// No fallback to another width: a stream that only fits 2 columns is corrupt where 3 are coded.
let src = [0xc0, 0x0f];
assert!(high_frequency_band(&src, 4, 2).is_err());
assert!(coded_high_frequency_band(&src, 3, 4, 2).is_err());
}
#[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
+13 -1
View File
@@ -250,6 +250,7 @@ fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec<u8> {
// --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) ---
#[derive(Clone)]
struct Band {
width: usize,
height: usize,
@@ -345,6 +346,17 @@ fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: boo
[(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)]
}
/// The band as the camera codes it (crx.rs `coded_band_width`): with a tile on the right, bands 5 and 8
/// are coded at an odd width, so an even band gains a column that synthesis ignores (zero here).
fn coded_band(band: &Band, index: usize, after: bool) -> Band {
if !(after && matches!(index, 5 | 8) && band.width.is_multiple_of(2)) {
return band.clone();
}
let width = band.width + 1;
let data = band.data.chunks_exact(band.width).flat_map(|row| row.iter().copied().chain([0])).collect();
Band { width, height: band.height, data }
}
fn encode_band(band: &Band, lowpass: bool) -> Vec<u8> {
let mut bits = BitWriter::default();
if lowpass {
@@ -568,7 +580,7 @@ fn encode_tile_planes(
*plane_bytes = vec![encode_band(&band, true)];
} else {
let bands = forward_bands(values, plane_w, plane_h, before, after, halo);
*plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect();
*plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(&coded_band(band, i, after), i == 0)).collect();
}
}
encoded
+89 -9
View File
@@ -41,6 +41,9 @@ pub struct Output {
pub boxes: Vec<[f32; 4]>,
/// The text features `[1, 32, 256]` (tests).
pub text: Tensor,
/// Semantic logits `[288, 288]` flattened: everything the prompt names, one mask without
/// boundaries between instances.
pub semantic: Vec<f32>,
}
struct EncoderLayer {
@@ -91,6 +94,7 @@ pub struct Detector {
pixel_convs: Vec<Conv2d>,
pixel_norms: Vec<GroupNorm>,
instance_proj: Conv2d,
semantic_proj: Conv2d,
mask_embedder: Vec<Linear>,
}
@@ -155,6 +159,7 @@ impl Detector {
pixel_convs: (0..3).map(|i| Ok(candle_nn::conv2d(HIDDEN, HIDDEN, 3, conv3, p.pp(format!("conv_layers.{i}")))?)).collect::<Result<_>>()?,
pixel_norms: (0..3).map(|i| Ok(candle_nn::group_norm(8, HIDDEN, 1e-5, p.pp(format!("norms.{i}")))?)).collect::<Result<_>>()?,
instance_proj: candle_nn::conv2d(HIDDEN, HIDDEN, 1, Conv2dConfig::default(), m.pp("instance_projection"))?,
semantic_proj: candle_nn::conv2d(HIDDEN, 1, 1, Conv2dConfig::default(), m.pp("semantic_projection"))?,
mask_embedder: (0..3).map(|i| linear(HIDDEN, HIDDEN, m.pp(format!("mask_embedder.layers.{i}")))).collect::<Result<_>>()?,
})
}
@@ -268,6 +273,7 @@ impl Detector {
prev = norm.forward(&conv.forward(&prev)?)?.relu()?;
}
let inst = self.instance_proj.forward(&prev)?; // [1, 256, 288, 288]
let semantic = to_vec(&self.semantic_proj.forward(&prev)?)?;
let (_, c, mh, mw) = inst.dims4()?;
let mut e = queries.clone();
for (i, l) in self.mask_embedder.iter().enumerate() {
@@ -285,24 +291,98 @@ impl Detector {
presence: to_vec(&presence)?.first().copied().unwrap_or(-10.0),
boxes: bv.as_chunks::<4>().0.to_vec(),
text,
semantic,
})
}
}
/// How far below 0 the strongest instance may be where it still takes its share of the semantic mask.
const NEAR: f32 = 4.0;
/// Gives each of `instances` its share of `semantic` (everything the prompt names, one mask): where an
/// instance is the strongest and near (above −[`NEAR`]), the semantic logits replace its own (it keeps
/// its own where it is surer of itself). An instance's own logits stay close to 0 even well inside it
/// (a person comes out about 90 % selected) and both fade where two instances touch, which leaves a gap
/// between them; the semantic mask is surer inside and runs on across that boundary. Cells far from
/// every instance keep their logits, so things no instance found don't join one.
pub fn share(instances: &mut [Vec<f32>], semantic: &[f32]) {
let n = semantic.len();
if instances.is_empty() || instances.iter().any(|l| l.len() != n) {
return;
}
for (i, s) in semantic.iter().enumerate() {
let Some((k, best)) = instances.iter().enumerate().map(|(k, l)| (k, l[i])).max_by(|a, b| a.1.total_cmp(&b.1)) else { return };
if best > -NEAR {
let l = &mut instances[k][i];
*l = if *l > 0.0 { l.max(*s) } else { *s };
}
}
}
/// The instances of `out` scoring above `threshold` (sigmoid(logit) × sigmoid(presence)),
/// merged into one mask (the per-pixel maximum of their probabilities, as logits).
/// merged into one mask: each its share of the semantic mask ([`share`]), then the per-pixel
/// maximum of their probabilities, as logits.
pub fn merge(out: &Output, threshold: f32) -> Option<Prediction> {
let s = |x: f32| 1.0 / (1.0 + (-x).exp());
let presence = s(out.presence);
let keep: Vec<(usize, f32)> = out.logits.iter().enumerate().map(|(i, l)| (i, s(*l) * presence)).filter(|(_, p)| *p > threshold).collect();
let best = keep.iter().map(|k| k.1).fold(0f32, f32::max);
let mut merged: Option<Vec<f32>> = None;
for (i, _) in &keep {
let m = out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())?;
merged = Some(match merged {
None => m,
Some(acc) => acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect(),
});
}
let mut masks: Vec<Vec<f32>> = keep.iter().map(|(i, _)| out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())).collect::<Option<_>>()?;
share(&mut masks, &out.semantic);
let merged = masks.into_iter().reduce(|acc, m| acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect());
merged.map(|logits| Prediction { logits, score: best })
}
#[cfg(test)]
mod tests {
use super::*;
/// Two people side by side on a row of cells, each only weakly sure of themself and both fading where
/// they touch; the semantic mask is sure of both and runs across. Each takes the semantic logits where
/// it is the strongest instance, a third region nobody is near keeps its own.
#[test]
fn instances_share_the_semantic_mask() {
// cells: a (0..3), the seam (3), b (4..7), floor beside b (7), someone neither found (8), background
let a = vec![2.0, 2.5, 1.0, -0.5, -3.0, -5.0, -6.0, -3.5, -9.0, -9.0];
let b = vec![-6.0, -5.0, -3.0, -1.0, 1.0, 2.5, 2.0, -3.0, -9.0, -9.0];
let mut inst = vec![a, b];
let semantic = [5.0, 6.0, 5.0, 4.0, 5.0, 6.0, 5.0, -7.0, 5.0, -8.0];
share(&mut inst, &semantic);
assert_eq!(inst[0][..4], [5.0, 6.0, 5.0, 4.0], "a's share, the seam included (a is the stronger there)");
assert_eq!(inst[1][4..7], [5.0, 6.0, 5.0], "b's share");
assert_eq!(inst[0][4..7], [-3.0, -5.0, -6.0], "a stays out of b");
assert_eq!(inst[1][7], -7.0, "the floor near b is surely background");
assert_eq!([inst[0][8], inst[1][8]], [-9.0, -9.0], "someone neither instance is near joins neither");
// an instance surer than the semantic mask keeps its own logits
let mut sure = vec![vec![9.0, 1.0]];
share(&mut sure, &[5.0, 5.0]);
assert_eq!(sure[0], [9.0, 5.0]);
// mismatched sizes (no semantic mask) leave the instances alone
let mut none = vec![vec![1.0, 2.0]];
share(&mut none, &[]);
assert_eq!(none[0], [1.0, 2.0]);
}
/// Describe: the instances above the threshold, each with its share of the semantic mask, merged;
/// a query below the threshold adds nothing, and nothing found is `None`.
#[test]
fn describe_merges_the_shares_of_what_it_found() {
// two instances, weak inside and both fading where they touch (cell 2); a third query scores low
let masks = [[2.0f32, 1.0, -0.5, -3.0, -6.0], [-6.0, -3.0, -1.0, 1.0, 2.0], [-9.0, -9.0, -9.0, -9.0, 3.0]];
let output = |logits: [f32; 3], semantic: &[f32]| Output {
masks: Tensor::from_vec(masks.concat(), (3, 1, 5), &Device::Cpu).unwrap(),
logits: logits.to_vec(),
presence: 8.0,
boxes: vec![[0.0; 4]; 3],
text: Tensor::zeros(1, candle_core::DType::F32, &Device::Cpu).unwrap(),
semantic: semantic.to_vec(),
};
let semantic = [5.0, 6.0, 4.0, 6.0, 5.0];
let merged = merge(&output([4.0, 4.0, -6.0], &semantic), 0.5).unwrap();
assert_eq!(merged.logits, semantic, "sure inside both and across where they touch");
// without a semantic mask: the per-pixel maximum of the instances, as before
let plain = merge(&output([4.0, 4.0, -6.0], &[]), 0.5).unwrap();
assert_eq!(plain.logits, [2.0, 1.0, -0.5, 1.0, 2.0]);
assert!(merge(&output([-6.0; 3], &semantic), 0.5).is_none(), "nothing found");
}
}
+10
View File
@@ -87,6 +87,11 @@ pub fn is_model_dir(dir: &Path) -> bool {
dir.join(WEIGHTS_FILE).is_file() && dir.join("vocab.json").is_file() && dir.join("merges.txt").is_file()
}
#[cfg(not(target_arch = "wasm32"))]
/// Whether [`best_device`] can be a GPU in this build (Metal on macOS). Where it can, a caller that
/// sees the model fail there can load it again with [`Sam3::load_cpu`].
pub const GPU_BUILD: bool = cfg!(target_os = "macos");
#[cfg(not(target_arch = "wasm32"))]
/// The best device here: Metal on macOS (when a GPU is available), else the CPU.
pub fn best_device() -> Device {
@@ -134,6 +139,11 @@ impl Sam3 {
Self::load_on(dir, best_device())
}
/// [`Sam3::load`] on the CPU, whatever GPU there is.
pub fn load_cpu(dir: &Path) -> Result<Self> {
Self::load_on(dir, Device::Cpu)
}
pub fn load_on(dir: &Path, device: Device) -> Result<Self> {
if !is_model_dir(dir) {
return Err(Error::Missing(dir.to_path_buf()));
+3 -3
View File
@@ -108,10 +108,10 @@ impl Client {
}
}
pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result<Option<Vec<f32>>, String> {
pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> crate::Result<Option<Vec<f32>>> {
let mut merged: Option<Vec<f32>> = None;
for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? {
if let Some(p) = model.segment_text(enc, phrase, 0.5)? {
if let Some(m) = &mut merged {
for (a, b) in m.iter_mut().zip(p.logits) {
*a = a.max(b);
@@ -130,7 +130,7 @@ fn predict(model: &mut Sam3, enc: &mut Encoded, operation: Operation) -> Result<
model.prepare_clicks(enc).map_err(|e| e.to_string())?;
Ok(None)
}
Operation::Text(text) => text_logits(model, enc, &text),
Operation::Text(text) => text_logits(model, enc, &text).map_err(|e| e.to_string()),
Operation::Clicks(points) => {
let clicks: Vec<_> = points.into_iter().map(|p| Click { x: p[0], y: p[1], positive: p[2] == 1.0 }).collect();
Ok(Some(model.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?.logits))
+7
View File
@@ -93,5 +93,12 @@ fn matches_reference() {
let want = vec(&r[&format!("det{i}_masks")].i((0, q)).unwrap());
assert!(rel_err("best mask", &ours, &want) < 2e-2);
assert!(mask_disagreement(&ours, &want) < 2e-3);
// the semantic mask (all instances as one; Subject's people take their share of it); reference
// files made before it was written lack it
if let Some(want) = r.get(&format!("det{i}_semantic")) {
let want = vec(want);
assert!(rel_err("semantic mask", &out.semantic, &want) < 2e-2);
assert!(mask_disagreement(&out.semantic, &want) < 2e-3);
}
}
}
+6 -3
View File
@@ -29,9 +29,12 @@ later the TIFF/DNG exporters.
The reader never panics on malformed input (proptests feed random and mutated files): offsets are
bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count,
nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was
read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't
pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at
nesting depth and entries per IFD are limited; at most 65,536 directory entry slots
(`ParseOptions::max_entries_total`) are accepted across the main IFD chain and all child IFDs, even when tables
overlap at distinct offsets, and an IFD past that bound ends the chain or is skipped like any other bad IFD after
the first; a next-IFD pointer is followed only when every declared entry was read, with the counts compared as
64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't pass for a whole directory on a
32-bit target); the total bytes of decoded values are capped at
`8 × input + 1 MiB` so a file cannot make us allocate far more than its own size.
## Sources
+3 -2
View File
@@ -11,8 +11,9 @@
//! either byte order, classic or BigTIFF.
//!
//! The reader never panics on malformed input: every offset is bounds-checked, IFD loops are
//! detected, nesting depth / IFD count are limited and the total amount of decoded value bytes is
//! capped relative to the input size (so a hostile file cannot make us allocate unboundedly).
//! detected, nesting depth / IFD count are limited, at most 65,536 directory entry slots are read
//! across the main chain and child IFDs, and decoded value bytes are capped relative to the input
//! size (so overlapping tables cannot multiply entry-vector allocations without bound).
//!
//! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF
//! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32).
+111 -2
View File
@@ -12,6 +12,15 @@ pub struct ParseOptions {
pub max_depth: usize,
/// Maximum number of entries read from one IFD.
pub max_entries: usize,
/// Maximum total number of entries read across all IFDs (chain + children).
///
/// Hostile files can reuse the same bytes for up to `max_ifds` tables of `max_entries`
/// entries each (overlapping IFD tables via SubIFD pointers shifted by one entry), which can
/// allocate millions of `Entry`s from a small input (~100 KB → ~400 MB). This hard cap bounds
/// that amplification regardless of entry size. 65 536 is generous for real files — a 50 MB
/// scanned TIFF with thousands of entries across chain + children stays well under it — yet
/// stops a small file from allocating hundreds of megabytes of `Entry`s.
pub max_entries_total: usize,
/// Maximum total bytes of decoded values; `None` = `8 × input length + 1 MiB`.
pub value_budget: Option<u64>,
/// Follow `SubIFDs` / Exif / GPS / Interop pointers.
@@ -22,7 +31,15 @@ pub struct ParseOptions {
impl Default for ParseOptions {
fn default() -> Self {
Self { max_ifds: 1024, max_depth: 10, max_entries: 8192, value_budget: None, follow_children: true, accept_raw_magic: true }
Self {
max_ifds: 1024,
max_depth: 10,
max_entries: 8192,
max_entries_total: 65536,
value_budget: None,
follow_children: true,
accept_raw_magic: true,
}
}
}
@@ -195,12 +212,13 @@ struct Ctx<'a> {
opts: &'a ParseOptions,
visited: HashSet<u64>,
budget: u64,
entries_total: usize,
}
impl<'a> Ctx<'a> {
fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self {
let budget = opts.budget_for(data.len());
Self { data, order, big, opts, visited: HashSet::new(), budget }
Self { data, order, big, opts, visited: HashSet::new(), budget, entries_total: 0 }
}
fn read_off(&self, pos: usize) -> Option<u64> {
@@ -219,6 +237,12 @@ impl<'a> Ctx<'a> {
return Err(TiffError::Invalid(format!("IFD loop at {offset}")));
}
let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?;
// Bound total entries across the whole parse so overlapping IFD tables (SubIFD pointers
// shifted by one entry) cannot allocate millions of `Entry`s from a small input.
self.entries_total = self.entries_total.saturating_add(dir.read);
if self.entries_total > self.opts.max_entries_total {
return Err(TiffError::Limit("entry count"));
}
let mut entries = Vec::with_capacity(dir.read);
for p in dir.positions() {
if let Some(e) = self.entry(p, base) {
@@ -405,4 +429,89 @@ mod tests {
assert_eq!(tiff.ifds.len(), 1);
assert_eq!(tiff.ifds[0].u64(0x0100), Some(8));
}
#[test]
fn overlapping_ifd_tables_hit_entry_cap_instead_of_amplifying() {
// classic TIFF; IFDs at 8, 20, 32, ... each declaring 8192 entries whose tables overlap
// (next table starts one entry after the previous table's start). Without the cap, a single
// `Tiff::parse` would run `Vec::with_capacity(8192)` per IFD inside one `Ctx`, amplifying a
// small file into millions of `Entry`s; the per-`Ctx` `max_entries_total` cap rejects the
// 9th IFD before any allocation.
let mut data = vec![0u8; 200_000];
data[0..2].copy_from_slice(b"II");
data[2..4].copy_from_slice(&42u16.to_le_bytes());
data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8
for k in 0..9u64 {
let ifd_off = (8 + 12 * k) as usize;
data[ifd_off..ifd_off + 2].copy_from_slice(&8192u16.to_le_bytes()); // count
let table = ifd_off + 2;
let ptr = table + 8192 * 12; // next-IFD pointer lives right after the entries
let next = if k + 1 < 9 { (8 + 12 * (k + 1)) as u32 } else { 0 };
data[ptr..ptr + 4].copy_from_slice(&next.to_le_bytes());
}
let tiff = Tiff::parse(&data).unwrap();
assert_eq!(tiff.ifds.len(), 8, "8 IFDs parsed, 9th rejected by max_entries_total before allocation");
}
#[test]
fn a_legitimate_multi_ifd_file_still_parses_under_the_entry_cap() {
// classic TIFF: IFD0 @8 (3 entries: value + Exif + GPS child pointers) -> IFD1 @58 chain,
// plus Exif child @60 and GPS child @90. Total entries = 6, far under max_entries_total.
let mut data = vec![0u8; 128];
data[0..2].copy_from_slice(b"II");
data[2..4].copy_from_slice(&42u16.to_le_bytes());
data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8
// IFD0 @8: count 3, table @10, next-IFD @46 -> IFD1 @58
data[8..10].copy_from_slice(&3u16.to_le_bytes());
// entry0 @10: tag 0x0100, SHORT, count 1, value 8 (inline @18..22)
data[10..12].copy_from_slice(&0x0100u16.to_le_bytes());
data[12..14].copy_from_slice(&3u16.to_le_bytes());
data[14..18].copy_from_slice(&1u32.to_le_bytes());
data[18..22].copy_from_slice(&8u32.to_le_bytes());
// entry1 @22: tag 0x8769 ExifIFD, LONG, count 1, value 60 (inline @30..34)
data[22..24].copy_from_slice(&0x8769u16.to_le_bytes());
data[24..26].copy_from_slice(&4u16.to_le_bytes());
data[26..30].copy_from_slice(&1u32.to_le_bytes());
data[30..34].copy_from_slice(&60u32.to_le_bytes());
// entry2 @34: tag 0x8825 GPSIFD, LONG, count 1, value 90 (inline @42..46)
data[34..36].copy_from_slice(&0x8825u16.to_le_bytes());
data[36..38].copy_from_slice(&4u16.to_le_bytes());
data[38..42].copy_from_slice(&1u32.to_le_bytes());
data[42..46].copy_from_slice(&90u32.to_le_bytes());
data[46..50].copy_from_slice(&58u32.to_le_bytes()); // next IFD
// IFD1 @58: count 1, table @60, next 0
data[58..60].copy_from_slice(&1u16.to_le_bytes());
data[60..62].copy_from_slice(&0x0100u16.to_le_bytes());
data[62..64].copy_from_slice(&3u16.to_le_bytes());
data[64..68].copy_from_slice(&1u32.to_le_bytes());
data[68..72].copy_from_slice(&8u32.to_le_bytes());
data[72..76].copy_from_slice(&0u32.to_le_bytes());
// Exif child IFD @60: count 1, entry tag 0x9000 value 1, next 0
data[60..62].copy_from_slice(&1u16.to_le_bytes());
data[62..64].copy_from_slice(&0x9000u16.to_le_bytes());
data[64..66].copy_from_slice(&3u16.to_le_bytes());
data[66..70].copy_from_slice(&1u32.to_le_bytes());
data[70..74].copy_from_slice(&1u32.to_le_bytes());
data[74..78].copy_from_slice(&0u32.to_le_bytes());
// GPS child IFD @90: count 1, entry tag 0 value 1, next 0
data[90..92].copy_from_slice(&1u16.to_le_bytes());
data[92..94].copy_from_slice(&0u16.to_le_bytes());
data[94..96].copy_from_slice(&3u16.to_le_bytes());
data[96..100].copy_from_slice(&1u32.to_le_bytes());
data[100..104].copy_from_slice(&1u32.to_le_bytes());
data[104..108].copy_from_slice(&0u32.to_le_bytes());
let tiff = Tiff::parse(&data).unwrap();
assert_eq!(tiff.ifds.len(), 2, "chain IFDs parse fully");
assert_eq!(tiff.ifds[0].u64(0x0100), Some(8));
let exif = tiff.ifds[0].exif.as_ref().expect("Exif child present");
assert_eq!(exif.u64(0x9000), Some(1));
let gps = tiff.ifds[0].gps.as_ref().expect("GPS child present");
assert_eq!(gps.u64(0x0000), Some(1));
}
}
+45 -44
View File
@@ -248,50 +248,51 @@
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?",
"• {assumption}": "• {assumption}",
"… and {more} more": "… a dalších {more}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
"Create the keyword “{name}”": "Create the keyword “{name}”",
"new, inside {parent}": "new, inside {parent}",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
" in {g} group{}": " in {g} group{}",
" · kept, not applied: {}": " · kept, not applied: {}",
" — kept but not applied: {}": " — kept but not applied: {}",
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
"Effect: {name}": "Effect: {name}",
"Exported {} effect preset{}": "Exported {} effect preset{}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
"{n} preset{}": "{n} preset{}",
"{count} more warnings.": "{count} more warnings.",
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"Applied {name}": "Applied {name}",
"Delete “{name}”?": "Delete “{name}”?",
"Updated {name}": "Updated {name}",
"Exported to {path}": "Exported to {path}",
"Saved template {name}": "Saved template {name}",
"Imported {name}": "Imported {name}",
"Skipped {problem}": "Skipped {problem}",
"…and {more} more": "…and {more} more",
"Template preview: {name}": "Template preview: {name}",
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
"{n} pages": "{n} pages",
"{n} JPEG files": "{n} JPEG files",
"{s} s of AV1 video": "{s} s of AV1 video",
"Slideshow exported: {what}": "Slideshow exported: {what}",
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nepodařilo se zapsat přidružené soubory XMP u fotografií v počtu {n} (není jejich složka offline?). LightCraft to bude zkoušet dál.",
"Create the keyword “{name}”": "Vytvořit klíčové slovo „{name}“",
"new, inside {parent}": "nové, pod {parent}",
"Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.",
" in {g} group{}": " ve skupinách: {g}{:.0}",
" · kept, not applied: {}": " · zachováno, nepoužito: {}",
" — kept but not applied: {}": " — zachováno, ale nepoužito: {}",
"Apply to “{mask}” on Selected Photos": "Použít na masku „{mask}“ u vybraných fotografií",
"Delete the effect preset “{name}”?": "Odstranit přednastavení efektu „{name}“?",
"Delete these {n} effect presets?": "Odstranit tato přednastavení efektů (počet: {n})?",
"Delete {n} effect preset{}? This can't be undone.": "Odstranit přednastavení efektů v počtu {n}{:.0}? Tuto akci nelze vrátit zpět.",
"Effect: {name}": "Efekt: {name}",
"Exported {} effect preset{}": "Exportováno přednastavení efektů: {0}{1:.0}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Importováno efektů masek: {e}{:.0} (Maskování → Efekt)",
"Update “{name}” with Current Settings": "Aktualizovat „{name}“ podle aktuálního nastavení",
"{n} preset{}": "Přednastavení: {n}{:.0}",
"{count} more warnings.": "Další varování: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Neimportované obrázky katalogu: {skipped}. Jejich záznamy v katalogu jsou neúplné.",
"{photos} catalog records processed; {collections} collections.": "Zpracováno záznamů katalogu: {photos}; kolekcí: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Chybějící originály: {missing}. Jejich záznamy jsou v katalogu dostupné; použijte Najít chybějící fotografie a znovu je propojte.",
"Applied {name}": "Použito: {name}",
"Delete “{name}”?": "Odstranit „{name}“?",
"Updated {name}": "Aktualizováno: {name}",
"Exported to {path}": "Exportováno do {path}",
"Saved template {name}": "Uložena šablona {name}",
"Imported {name}": "Importováno: {name}",
"Skipped {problem}": "Přeskočeno: {problem}",
"…and {more} more": "…a další: {more}",
"Template preview: {name}": "Náhled šablony: {name}",
"{n} photos in view · {slides} slides · {time}": "Fotografie v zobrazení: {n} · snímky: {slides} · {time}",
"{n} pages": "Stránky: {n}",
"{n} JPEG files": "Soubory JPEG: {n}",
"{s} s of AV1 video": "Video AV1: {s} s",
"Slideshow exported: {what}": "Prezentace byla exportována: {what}",
" · {n} skipped (could not be rendered)": " · přeskočeno: {n} (nepodařilo se vykreslit)",
"{n} raws can now be developed from their raw data": "Soubory RAW, které lze nyní vyvolat z dat RAW: {n}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se neočekávaně ukončil: {e}\n\nPodrobnosti najdete v souboru protokolu: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft se neočekávaně ukončil: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Přejmenuje obličeje v celé knihovně (počet: {n}{:.0}). Soubory se nezmění.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "Osoba „{to}“ již existuje. Sloučením vznikne jedna osoba; příkaz Zpět je znovu oddělí.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Přidáno sloučení {what} v rozlišení {mp}: plné rozlišení {full} vyžaduje více paměti, než má tento počítač",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Přidáno sloučení {what} v rozlišení {mp}, což je nejvyšší možné rozlišení panoramatu (plné rozlišení je {full})",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Barevný prostor: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "Vybrané fotografie: {n} · stránky: {pages}",
"Already in “{}”": "Již v „{}“",
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
+330 -1
View File
@@ -11,6 +11,7 @@
", rejected": ", odmítnuto",
"0: nothing is rejected.": "0: nic se neodmítá.",
"1 day": "1 den",
"150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuální úpravy · přizpůsobit bez ořezu",
"2026-04 or 2026-04-12": "2026-04 nebo 2026-04-12",
"= exactly": "= přesně",
"A blank template keeps the original names.": "Prázdná šablona zachová původní názvy.",
@@ -19,6 +20,7 @@
"A folder is being synchronized": "Probíhá synchronizace složky",
"A keyword set needs a name": "Sada klíčových slov musí mít název",
"A one-time download of about": "Jednorázové stažení přibližně",
"A pen's pressure sets the size and flow of the brush": "Přítlak pera určuje velikost a tok štětce",
"AI Denoise": "Redukce šumu pomocí AI",
"AI Denoise Model": "Model pro redukci šumu pomocí AI",
"AI Denoise is enabled on the requesting photo": "Redukce šumu pomocí AI je u požadované fotografie zapnutá",
@@ -53,6 +55,9 @@
"Add to Favorites": "Přidat do oblíbených",
"Add to Local": "Přidat do místních souborů",
"Add to My Photos": "Přidat do mých fotografií",
"Can't be moved here": "Sem nelze přesunout",
"This row covers more than one disk: drop on a folder inside it": "Tento řádek zahrnuje více disků: přetáhněte položky na některou ze složek v něm",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Místní fotografie zatím nejsou v knihovně: nejprve použijte Přidat do mých fotografií a pak je přesuňte",
"Add to My Photos First": "Nejprve přidat do mých fotografií",
"Add to Selected Photos": "Přidat k vybraným fotografiím",
"Add to Target Album": "Přidat do cílového alba",
@@ -66,6 +71,108 @@
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nebyl nalezen grafický procesor: LightCraft vykresluje na CPU, což je mnohem pomalejší. Podrobnosti najdete v nabídce Nápověda → Informace o systému.",
"On NixOS, start it from `nix develop`.": "V NixOS ho spusťte z `nix develop`.",
"Files with the same content as another file in this import: only the first is imported.": "Soubory se stejným obsahem jako jiný soubor v tomto importu: importuje se jen první z nich.",
"Lightroom Catalog Import": "Import katalogu Lightroom",
"Destination library": "Cílová knihovna",
"Show in Library": "Zobrazit v knihovně",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Katalogy Lightroom se importují do otevřené knihovny; samostatná složka katalogu se nevytváří.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Tyto fotografie jsou pouze v paměti. Chcete-li je uložit, otevřete knihovnu na disku a importujte katalog znovu.",
"Playback": "Přehrávání",
"Layout": "Rozložení",
"Link all margins": "Propojit všechny okraje",
"Margins": "Okraje",
"Left": "Vlevo",
"Right": "Vpravo",
"Top": "Nahoře",
"Bottom": "Dole",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Okraje se zadávají jako podíl kratší strany snímku, takže snímky vypadají na každé obrazovce stejně.",
"Show guides": "Zobrazit vodítka",
"Aspect preview": "Poměr stran náhledu",
"Zoom to fill frame": "Přiblížit pro vyplnění rámečku",
"Stroke border": "Obrysový rámeček",
"Colour": "Barva",
"Cast shadow": "Vržený stín",
"Offset": "Posun",
"Backdrop": "Pozadí",
"Background colour": "Barva pozadí",
"Colour wash": "Barevný přechod",
"Background image": "Obrázek na pozadí",
"(not in the library)": "(není v knihovně)",
"Use selected photo": "Použít vybranou fotografii",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Vyberte fotografii a zapněte tuto volbu, aby vyplnila pozadí snímku za každou fotografií.",
"Reset layout": "Obnovit rozložení",
"Select a photo to preview its slide": "Vyberte fotografii pro náhled jejího snímku",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Vybraná fotografie jako snímek. Přehrávání vyplní obrazovku; poměr stran náhledu mění pouze tento náhled.",
"Overlays": "Překrytí",
"Identity plate": "Označení autora",
"Your name or studio": "Vaše jméno nebo studio",
"Anchored to": "Ukotveno k",
"Slide": "Snímek",
"Render behind image": "Vykreslit za obrázkem",
"Rating stars": "Hvězdičky hodnocení",
"Text overlays": "Textová překrytí",
"Add text overlay": "Přidat textové překrytí",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Značky: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Ostatní text se zobrazí tak, jak jej zadáte.",
"Use export watermark": "Použít exportní vodoznak",
"The export watermark, drawn on the photo and sized relative to it.": "Exportní vodoznak, vykreslený na fotografii s velikostí vztaženou k ní.",
"Titles": "Titulky",
"Intro screen": "Úvodní obrazovka",
"Ending screen": "Závěrečná obrazovka",
"Shows": "Obsah",
"Colour only": "Pouze barva",
"Override colour": "Vlastní barva",
"Own duration (off: the slide duration)": "Vlastní délka (vypnuto: délka snímku)",
"Duration": "Délka",
"Title text": "Text titulku",
"Intro": "Úvod",
"Ending": "Závěr",
"End": "Závěr",
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Úvod se přehraje jednou před první fotografií; závěr po poslední fotografii, pokud je vypnuto opakování. Označení autora se přebírá z nastavení Překrytí. Titulky se prolínají jako snímky a náhodné pořadí je nikdy nepřesouvá.",
"This screen is off: turn it on under Titles to play it.": "Tato obrazovka je vypnutá: chcete-li ji přehrát, zapněte ji v části Titulky.",
"The intro screen, before the first photo.": "Úvodní obrazovka, před první fotografií.",
"The ending screen, after the last photo.": "Závěrečná obrazovka, po poslední fotografii.",
"Templates": "Šablony",
"Built-in": "Vestavěné",
"Your templates": "Vaše šablony",
"None yet: save the current settings as a template below.": "Zatím žádné: níže uložte aktuální nastavení jako šablonu.",
"Before the last template": "Před použitím poslední šablony",
"Previous settings": "Předchozí nastavení",
"New template name": "Název nové šablony",
"Save as Template": "Uložit jako šablonu",
"Import…": "Importovat…",
"Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Vlastní šablony vyžadují složku nastavení, kterou toto sestavení nemá; vestavěné šablony fungují.",
"Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Kliknutím šablonu použijete; najetím ukazatele zobrazíte její náhled na vybrané fotografii. Vlastní šablonu můžete kliknutím pravým tlačítkem aktualizovat, přejmenovat, exportovat nebo odstranit.",
"Import Slideshow Templates": "Importovat šablony prezentace",
"Export Slideshow Template": "Exportovat šablonu prezentace",
"Slideshow Templates": "Šablony prezentace",
"Plain Black": "Prostá černá",
"Gallery Wall": "Stěna galerie",
"Edge to Edge": "Od kraje ke kraji",
"Cinema Wide": "Širokoúhlé kino",
"Shooting Data": "Údaje o pořízení",
"Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Každá fotografie co největší na černém pozadí, s krátkým prolnutím. Nic se přes ni nekreslí.",
"A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Světle šedá stěna s bílou paspartou a jemným stínem; popisek a hodnocení pod fotografií.",
"Every photo cropped to fill the whole screen, with slow crossfades.": "Každá fotografie oříznutá tak, aby vyplnila celou obrazovku, s pomalým prolnutím.",
"A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Rám 16:9 s černými pruhy nahoře a dole a titulem v rohu; přechody přes černou a na konci závěrečná obrazovka.",
"A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Tmavé břidlicové pozadí; pod každou fotografií expozice, ISO a ohnisková vzdálenost a dále fotoaparát a objektiv.",
"Exporting slideshow": "Exportování prezentace",
"Export Slideshow…": "Exportovat prezentaci…",
"Export Slideshow": "Exportovat prezentaci",
"Export Slideshow: folder for the JPEG files": "Exportovat prezentaci: složka pro soubory JPEG",
"Frame rate": "Snímková frekvence",
"Container": "Kontejner",
"Export PDF…": "Exportovat PDF…",
"Export JPEG…": "Exportovat JPEG…",
"Export Video…": "Exportovat video…",
"Automatically show full screen": "Automaticky zobrazit na celou obrazovku",
"One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Jeden snímek na stránku (delší strana v pixelech = body), s délkou zobrazení snímku a prolnutím pro režim prezentace v prohlížeči PDF.",
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Číslované soubory JPEG (name-001.jpg…) v nové složce, kterou pojmenujete v dalším kroku.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, každý snímek se zobrazuje po nastavenou dobu včetně prolnutí. Bez zvuku: hudba se zatím neexportuje.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Snímky používají rozložení, překrytí a úvodní i závěrečnou obrazovku prezentace a řadí se podle aktuálního zobrazení.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Fotografie v aktuálním zobrazení jako snímky s výše uvedeným nastavením. Průběh se zobrazuje v seznamu úloh na pozadí.",
"Checking raw files": "Kontrola souborů RAW",
"1 raw can now be developed from its raw data": "1 soubor RAW lze nyní vyvolat z dat RAW",
"This raw is now developed from its raw data": "Tento soubor RAW se nyní vyvolává z dat RAW",
"This raw variant still can't be decoded": "Tuto variantu RAW stále nelze dekódovat",
"Adding to Apple Photos…": "Přidávání do Apple Photos…",
"Advanced search…": "Rozšířené hledání…",
"After": "Po",
@@ -324,7 +431,14 @@
"Concrete Cool": "Chladný beton",
"Confirm before deleting": "Potvrdit před odstraněním",
"Confirm before moving photos to Recently Deleted": "Potvrdit před přesunem fotografií do nedávno odstraněných",
"Move Photos": "Přesunout fotografie",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Jejich soubory a přidružené soubory XMP se přesunou na disku. Úpravy → Zpět je přesune zpět.",
"Don't ask again": "Znovu se neptat",
"Another library was opened since the photos were dropped: nothing was moved": "Od přetažení fotografií byla otevřena jiná knihovna: nic nebylo přesunuto",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Znovu zapnete v Nastavení → Potvrdit před přesunem fotografií do jiné složky na disku",
"Confirm before moving photos to another folder on disk": "Potvrdit před přesunem fotografií do jiné složky na disku",
"Constrain Crop": "Omezit oříznutí",
"Contact Sheet PDF": "Přehled snímků PDF",
"Content-Aware": "Podle obsahu",
"Continue": "Pokračovat",
"Continue Without Saving": "Pokračovat bez uložení",
@@ -603,6 +717,7 @@
"File name": "Název výstupního souboru",
"File types:": "Typy souborů:",
"Filename": "Jméno souboru",
"Filename captions": "Popisky s názvy souborů",
"Fill": "Vyplnit",
"Fill Edges": "Vyplnit okraje",
"Fill Location": "Doplnit místo",
@@ -671,6 +786,7 @@
"Friday": "Pátek",
"Full": "Plné",
"Full Screen Preview": "Náhled na celé obrazovce",
"Hold View Until Refreshed": "Ponechat zobrazení do obnovení",
"Full screen mode": "Režim celé obrazovky",
"Full size": "Plná velikost",
"GPS Track Log": "Záznam trasy GPS",
@@ -728,6 +844,7 @@
"HDR panorama": "Panorama HDR",
"HDR panoramas": "Panoramata HDR",
"HDR with Last Settings": "HDR s posledním nastavením",
"HDR for Each Bracket Set": "Sloučit každou expoziční řadu do HDR",
"HDR…": "HDR…",
"Has Edits": "Obsahuje úpravy",
"Has GPS": "Obsahuje GPS",
@@ -800,6 +917,7 @@
"In memory — nothing is saved": "V paměti — nic není uloženo",
"In use": "Používané",
"In-memory session: these settings last until LightCraft quits.": "Relace v paměti: tato nastavení platí do ukončení LightCraft.",
"Include selected photos": "Zahrnout vybrané fotografie",
"Include on Export": "Zahrnout do exportu",
"Include subfolders": "Zahrnout podsložky",
"Increase Brush Feather": "Zvětšit prolnutí štětce",
@@ -1096,12 +1214,26 @@
"No edits yet.": "Zatím žádné úpravy.",
"No exposure information": "Žádné informace o expozici",
"No filter presets yet": "Zatím žádná přednastavení filtrů",
"No lens data embedded in this file.": "V tomto souboru nejsou vložena data objektivu.",
"No folders to browse": "Žádné složky k procházení",
"No imported folders yet": "Zatím žádné importované složky",
"No keywords are exported": "Neexportují se žádná klíčová slova",
"No keywords match": "Žádné klíčové slovo neodpovídá",
"No keywords yet": "Zatím žádná klíčová slova",
"Show fewer": "Zobrazit méně",
"Whole names": "Úplné názvy",
"Grouped by parent": "Seskupené podle nadřazeného slova",
"Keywords of the selection": "Klíčová slova výběru",
"Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Seskupené: klíčová slova se stejným nadřazeným slovem sdílejí řádek s jeho názvem; dlouhé seznamy a návrhy se zkracují.",
"Show every keyword of the selected photos": "Zobrazit všechna klíčová slova vybraných fotografií",
"new keyword": "nové klíčové slovo",
"Return creates this keyword and gives it to the selected photos": "Klávesou Return vytvoříte toto klíčové slovo a přiřadíte je vybraným fotografiím",
"Separate several keywords with commas. Return gives them to the selected photos.": "Více klíčových slov oddělte čárkami. Klávesou Return je přiřadíte vybraným fotografiím.",
"No lens data embedded in this file (DNG lens opcodes only).": "V tomto souboru nejsou vložena data objektivu (pouze operační kódy objektivu DNG).",
"Export to": "Exportovat do",
"Specific folder": "Konkrétní složka",
"Same folder as original photo": "Stejná složka jako původní fotografie",
"Tidying the thumbnail cache": "Úklid mezipaměti miniatur",
"No masks yet. Choose a mask type above.": "Zatím žádné masky. Vyberte typ masky nahoře.",
"No matching commands": "Žádné odpovídající příkazy",
"No matching photos": "Žádné odpovídající fotografie",
@@ -1113,6 +1245,11 @@
"No photo has it; the keywords below it go too.": "Není přiřazeno žádné fotografii; odstraní se i podřízená klíčová slova.",
"No photo selected": "Není vybrána fotografie",
"No photos": "Žádné fotografie",
"Your library is empty": "Vaše knihovna je prázdná",
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Zde se zobrazí importované fotografie. Nic se nekopíruje ani nepřesouvá, pokud to sami nezvolíte.",
"or drop photos and folders into this window": "nebo do tohoto okna přetáhněte fotografie a složky",
"Import photos to get started": "Začněte importem fotografií",
"or drop photos into this window": "nebo do tohoto okna přetáhněte fotografie",
"No photos found": "Nebyly nalezeny fotografie",
"No photos in this folder": "V této složce nejsou fotografie",
"No photos match the active filters": "Žádné fotografie neodpovídají aktivním filtrům",
@@ -1203,6 +1340,7 @@
"Paste from Previous": "Vložit z předchozí fotografie",
"Paste pages": "Vložit stránky",
"Pastel Haze": "Pastelový opar",
"Pen Pressure": "Přítlak pera",
"Pending photo action cancelled": "Čekající akce s fotografií byla zrušena",
"Pending photo action cancelled because its library or photo changed": "Čekající akce byla zrušena, protože se změnila knihovna nebo fotografie",
"People": "Lidé",
@@ -1223,6 +1361,7 @@
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Fotografie → Upravit v externím editoru (⇧⌘E) vykreslí 16bitovou kopii TIFF, přidá ji do sady s originálem a otevře v této aplikaci (název aplikace v macOS, jinde cesta k programu).",
"PhotoCraft": "PhotoCraft",
"Photos": "Fotografie",
"Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotografie upravené v HDR: JPEG s mapou zisku HDR (správné zobrazení všude, jasnější světla na displejích HDR), AVIF jako 10bitový, PNG a JPEG XL jako 16bitové Rec. 2020 PQ; TIFF vyžaduje 32bitová čísla s plovoucí řádovou čárkou",
"Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotografie se zobrazují přes ICC profil monitoru, aby byly barvy správné na kalibrovaných displejích i displejích se širokým gamutem. Detail využívá celý gamut displeje. Histogram, mezipaměti náhledů ani export se nemění.",
"Photos per bracket (0 = auto)": "Fotografií na expoziční řadu (0 = automaticky)",
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotografie prohlížené v místních souborech, které nebyly přidány ani změněny, se odeberou z katalogu, pokud se jejich složka po tuto dobu neprocházela (kontroluje se při otevření knihovny). Soubory zůstanou na disku; procházením složky se znovu zobrazí.",
@@ -1368,6 +1507,7 @@
"Refining the mask's detail…": "Zpřesňování detailů masky…",
"Refresh Source": "Obnovit zdroj",
"Refresh Source (/)": "Obnovit zdroj (/)",
"Refresh View": "Obnovit zobrazení",
"Reject": "Odmítnout",
"Reject below focus": "Odmítnout pod limitem zaostření",
"Rejected": "Odmítnuto",
@@ -1440,7 +1580,38 @@
"Reset Sidebar Sections": "Obnovit sekce postranního panelu",
"Reset Tone Curve": "Obnovit tónovou křivku",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Obnoví všechny křivky: bodové (všechny kanály) i parametrickou (dvojklik na kanál obnoví pouze tento kanál)",
"Reset all edits": "Obnovit všechny úpravy",
"Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii",
"LightCraft stopped": "LightCraft se neočekávaně ukončil",
"Show Focus Point": "Zobrazit zaostřovací bod",
"Zoom to Focus Point": "Přiblížit na zaostřovací bod",
"Focus": "Zaostření",
"Focus point": "Zaostřovací bod",
"Wide": "Široká oblast",
"Center": "Střed",
"Flexible Spot": "Pohyblivý bod",
"Zone": "Zóna",
"Expanded Flexible Spot": "Rozšířený pohyblivý bod",
"Custom AF Area": "Vlastní oblast AF",
"Face tracking": "Sledování obličeje",
"Lock On AF": "AF se sledováním objektu",
"Multi": "Vícebodové",
"Spot": "Bod",
"Tracking": "Sledování",
"Face Tracking": "Sledování obličeje",
"Animal Eye Tracking": "Sledování zvířecích očí",
"Human Eye Tracking": "Sledování lidských očí",
"Read Focus Points": "Načíst zaostřovací body",
"Reading focus points": "Načítání zaostřovacích bodů",
"Run face models on the CPU only": "Spouštět modely rozpoznávání obličejů pouze na CPU",
"Detection": "Detekce",
"Recognition": "Rozpoznávání",
"Not loaded yet": "Zatím nenačteno",
"Checking the GPU…": "Kontrola GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Model rozpoznávání obličejů používá GPU pouze tehdy, když kontrola prokáže, že na něm dosahuje stejných výsledků rychleji.",
"Checking the GPU for faces": "Kontrola GPU pro rozpoznávání obličejů",
"Reshuffle": "Znovu promíchat",
"Resize Face Box": "Změnit velikost rámečku obličeje",
"Resize panel window": "Změnit velikost okna panelu",
@@ -1611,6 +1782,7 @@
"Show overlay (O)": "Zobrazit překrytí (O)",
"Show ratings, flags and edit badges": "Zobrazit hodnocení, příznaky a odznaky úprav",
"Show the Navigator while zoomed in": "Zobrazit navigátor při přiblížení",
"Show the import review when a camera or card is connected": "Zobrazit kontrolu importu při připojení fotoaparátu nebo karty",
"Show the template tags; click one to insert it": "Zobrazit značky šablony; kliknutím jednu vložíte",
"Show/Hide Mask": "Zobrazit/Skrýt masku",
"Show/hide My Photos panel": "Zobrazit/Skrýt panel mých fotografií",
@@ -1644,6 +1816,7 @@
"Soft Pastel": "Jemné pastelové",
"Soft Proofing": "Měkký nátisk",
"Soft Skin": "Jemná pleť",
"Solo Mode": "Režim jedné sekce",
"Some rules need fixing (Edit Smart Album…):": "Některá pravidla je třeba opravit (Upravit inteligentní album…):",
"Sort": "Seřadit",
"Sort Albums A–Z": "Seřadit alba A–Z",
@@ -1879,6 +2052,7 @@
"Will be restored": "Bude obnoveno",
"Window": "Okno",
"Winter Blue": "Zimní modř",
"Wrote the XMP sidecars that were waiting": "Čekající přidružené soubory XMP byly zapsány",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funguje se soubory RAW z fotoaparátů se standardním snímačem (Bayer). Ostatní soubory si zachovají běžnou redukci šumu.",
"XMP Sidecar Preferences": "Předvolby přidružených souborů XMP",
"XMP sidecars": "Přidružené soubory XMP",
@@ -2052,6 +2226,8 @@
"SDR Shadows": "Stíny SDR",
"SDR Whites": "Bílé SDR",
"HDR TIFF needs 32-bit float.": "HDR TIFF vyžaduje 32bitové hodnoty s plovoucí řádovou čárkou.",
"HDR output": "Výstup HDR",
"Letter": "Letter",
"Color space: Rec. 2020 HLG (HDR AVIF)": "Barevný prostor: Rec. 2020 HLG (HDR AVIF)",
"Color space: Rec. 2020 PQ (HDR AVIF)": "Barevný prostor: Rec. 2020 PQ (HDR AVIF)",
"Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Barevný prostor: Rec. 2020 PQ, 16 bitů (HDR PNG)",
@@ -2063,5 +2239,158 @@
"Open Settings": "Otevřít nastavení",
"Select none": "Zrušit celý výběr",
"Select all": "Vybrat vše",
"Visualize HDR": "Zobrazit rozsah HDR"
"Visualize HDR": "Zobrazit rozsah HDR",
"Add a Face Recognition Model": "Přidat model rozpoznávání obličejů",
"Choose Monitor Profile": "Vybrat profil monitoru",
"Display profile not used": "Profil displeje nebyl použit",
"Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Tažením nastavíte odstín a sytost. Podržením Shift zachováte odstín, Cmd/Ctrl zachováte sytost, Alt/Option umožní jemné doladění. Dvojitým kliknutím obnovíte výchozí hodnoty.",
"Edit In": "Upravit v",
"Face models (ONNX)": "Modely rozpoznávání obličejů (ONNX)",
"ICC Profiles": "Profily ICC",
"Next History Step": "Další krok historie",
"Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) používá kompresi intoPIX TicoRAW. LightCraft pro tento kodek v současnosti nemá licenci, takže je k dispozici pouze vložený náhled JPEG z fotoaparátu, nikoli data RAW ze snímače. Chcete-li upravovat data RAW, fotografujte do bezeztrátově komprimovaného NEF nebo převeďte stávající soubory HE do DNG pomocí licencovaného převodníku.",
"Open Original in Other Editor (RAW, no copy)": "Otevřít originál v jiném editoru (RAW, bez kopie)",
"Photo Above": "Fotografie nad aktuální",
"Photo Below": "Fotografie pod aktuální",
"Previous History Step": "Předchozí krok historie",
"Select Only Active Photo": "Vybrat pouze aktivní fotografii",
"Show this wheel's effect": "Zobrazit účinek tohoto barevného kruhu",
"Targeted adjustment on: drag up or down on the photo · Esc to stop": "Cílená úprava je zapnutá: táhněte po fotografii nahoru nebo dolů · Esc ukončí úpravu",
"To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Chcete-li tomuto editoru předat přímo původní soubor (RAW bez kopie, například pro DxO PhotoLab), klikněte pravým tlačítkem na fotografii → Upravit v → Otevřít originál v jiném editoru (RAW, bez kopie).",
"A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Snímek se zobrazuje po nastavenou dobu včetně úvodního prolnutí. Prolnutí o délce 0 znamená střih.",
"Add Print Cell": "Přidat tiskovou buňku",
"Add a cell": "Přidat buňku",
"Also the masks of the same name on the other selected photos": "Také masky se stejným názvem na ostatních vybraných fotografiích",
"Apply Mask Effect": "Použít efekt masky",
"Apply Print Template": "Použít tiskovou šablonu",
"Apply Slideshow Template": "Použít šablonu prezentace",
"Apply to All Masks": "Použít na všechny masky",
"Arrange Print Cell": "Změnit pořadí tiskové buňky",
"Auto Layout": "Automaticky rozmístit",
"Choose Layout Image…": "Vybrat obrázek rozložení…",
"Choose a group": "Vyberte skupinu",
"Choose at least one setting to include": "Vyberte alespoň jedno nastavení, které chcete zahrnout",
"Clear Print Layout": "Vyprázdnit tiskové rozložení",
"Click a cell to move, resize or arrange it": "Kliknutím vyberte buňku, kterou chcete přesunout, změnit její velikost nebo pořadí",
"Color wheel": "Barevný kruh",
"Combine with the existing person": "Sloučit s existující osobou",
"Copy Settings…": "Kopírovat nastavení…",
"Curve channel": "Kanál křivky",
"Cycle Flag": "Přepnout příznak",
"Decrease Flag Status": "Snížit stav příznaku",
"Delete Cell": "Odstranit buňku",
"Delete Mask Effect": "Odstranit efekt masky",
"Delete Print Cell": "Odstranit tiskovou buňku",
"Delete Print Template": "Odstranit tiskovou šablonu",
"Delete Slideshow Template": "Odstranit šablonu prezentace",
"Delete…": "Odstranit…",
"Duplicate Mask Effect": "Duplikovat efekt masky",
"Export Effect Presets": "Exportovat přednastavení efektů",
"Export Mask Effects": "Exportovat efekty masek",
"Face Models on the GPU": "Modely rozpoznávání obličejů na GPU",
"Fade": "Prolnutí",
"Fade duration": "Délka prolnutí",
"Favorite": "Oblíbené",
"Favorite Mask Effect": "Přidat efekt masky mezi oblíbené",
"Filters": "Filtry",
"Group (optional)": "Skupina (nepovinná)",
"Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Zvýrazněné jsou posuvníky, které tato maska mění. Nezahrnuté posuvníky si při použití přednastavení ponechají své hodnoty.",
"Import Mask Effects": "Importovat efekty masek",
"Import Slideshow Template": "Importovat šablonu prezentace",
"Include Amount": "Zahrnout míru",
"Include brush size, feather and flow": "Zahrnout velikost štětce, prolnutí okrajů a tok",
"Increase Flag Status": "Zvýšit stav příznaku",
"LightCraft Effect Presets": "Přednastavení efektů LightCraft",
"Manage Presets…": "Spravovat přednastavení…",
"Managed by Printer (Adobe RGB)": "Spravováno tiskárnou (Adobe RGB)",
"Managed by Printer (sRGB)": "Spravováno tiskárnou (sRGB)",
"Mask": "Maska",
"Mask Effect Presets": "Přednastavení efektů masek",
"Masks keep their sliders. Export presets first to keep a copy.": "Masky si ponechají hodnoty svých posuvníků. Chcete-li si ponechat kopii přednastavení, nejprve je exportujte.",
"Move Mask Effect to Group": "Přesunout efekt masky do skupiny",
"Move Print Cell": "Přesunout tiskovou buňku",
"Move Print Cell to Page": "Přesunout tiskovou buňku na stránku",
"Move to Group…": "Přesunout do skupiny…",
"Name the preset": "Pojmenujte přednastavení",
"New Print Page": "Přidat tiskovou stránku",
"New group — Return": "Nová skupina — Return",
"New spot (Return adds it here)": "Nová retušovaná oblast (Return ji zde přidá)",
"Next page": "Další stránka",
"No Matte": "Bez ztmavení",
"No change": "Beze změny",
"No effect matches": "Žádný odpovídající efekt",
"No effect presets yet": "Zatím žádná přednastavení efektů",
"One photo fills each page inside the margins.": "Každou stránku uvnitř okrajů vyplní jedna fotografie.",
"Overlay color": "Barva překrytí",
"Page": "Stránka",
"Page Options": "Možnosti stránky",
"Previous page": "Předchozí stránka",
"Print Settings": "Nastavení tisku",
"Print Templates": "Tiskové šablony",
"Print to PDF": "Tisknout do PDF",
"Read Focus Points in the Background": "Načíst zaostřovací body na pozadí",
"Recheck Preview-Only Raws": "Znovu zkontrolovat soubory RAW dostupné jen jako náhled",
"Remove region": "Odebrat oblast",
"Rename Mask Effect": "Přejmenovat efekt masky",
"Rename Mask Effect Group": "Přejmenovat skupinu efektů masek",
"Rename Person…": "Přejmenovat osobu…",
"Rename Slideshow Template": "Přejmenovat šablonu prezentace",
"Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).": "Další snímky se vykreslují během zobrazení aktuálního, takže prolnutí nikdy nečeká. Vypnuto: každý snímek se vykreslí, až na něj přijde řada (nižší spotřeba paměti a méně výpočtů).",
"Reset Print Settings": "Obnovit nastavení tisku",
"Reset Sliders": "Obnovit posuvníky",
"Resize Print Cell": "Změnit velikost tiskové buňky",
"Resize region": "Změnit velikost oblasti",
"Save Current Settings as New Preset…": "Uložit aktuální nastavení jako nové přednastavení…",
"Save Mask Effect": "Uložit efekt masky",
"Save Print Template": "Uložit tiskovou šablonu",
"Save Slideshow Template": "Uložit šablonu prezentace",
"Search effects": "Hledat efekty",
"Select a mask to apply or update presets.": "Vyberte masku, abyste mohli použít nebo aktualizovat přednastavení.",
"Set Effect for New Masks": "Nastavit efekt pro nové masky",
"Set Print Cell Photo": "Nastavit fotografii tiskové buňky",
"Set Print Settings": "Upravit nastavení tisku",
"Show / hide": "Zobrazit / skrýt",
"Show mask": "Zobrazit masku",
"Slide duration": "Délka snímku",
"Slideshow Export Status": "Stav exportu prezentace",
"Slideshow Layout Settings": "Nastavení rozložení prezentace",
"Slideshow Overlay Settings": "Nastavení překrytí prezentace",
"Slideshow Playback Settings": "Nastavení přehrávání prezentace",
"Slideshow Slide Preview": "Náhled snímku prezentace",
"Slideshow Title Settings": "Nastavení titulků prezentace",
"Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "Položka Prezentace v nabídce Zobrazení přehraje fotografie v aktuálním zobrazení od vybrané fotografie. Tato nastavení se ukládají s knihovnou.",
"Template name": "Název šablony",
"The layout image can't be shown": "Obrázek rozložení nelze zobrazit",
"This page setup is not valid": "Toto nastavení stránky není platné",
"Thumbnail size": "Velikost miniatur",
"Title bar": "Záhlaví okna",
"Tool Overlay": "Překrytí nástroje",
"Ungrouped": "Bez skupiny",
"Unknown date": "Neznámé datum",
"Unnamed face": "Nepojmenovaný obličej",
"Update Mask Effect": "Aktualizovat efekt masky",
"Update Slideshow Template": "Aktualizovat šablonu prezentace",
"When pins show on the photo (Shift+H cycles)": "Kdy se na fotografii zobrazují body (Shift+H přepíná)",
"Write Waiting XMP Sidecars": "Zapsat čekající přidružené soubory XMP",
"cells": "× buňka",
"cells overlap": "buňky se překrývají",
"current edits": "aktuální úpravy",
"size": "velikost",
"source": "zdroj",
"★ Favorites": "★ Oblíbené",
"Rename Person": "Přejmenovat osobu",
"Increase Grid Thumbnail Size": "Zvětšit miniatury v mřížce",
"Decrease Grid Thumbnail Size": "Zmenšit miniatury v mřížce",
"Cycle Tool Overlay": "Přepnout překrytí nástroje",
"Show Layout Image": "Zobrazit obrázek rozložení",
"Layout Image Opacity": "Krytí obrázku rozložení",
"Layout Image Matte": "Ztmavení kolem obrázku rozložení",
"Slideshow Options…": "Možnosti prezentace…",
"Pause / Resume Slideshow": "Pozastavit / obnovit prezentaci",
"Next Slide": "Další snímek",
"Previous Slide": "Předchozí snímek",
"End Slideshow": "Ukončit prezentaci",
"Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…",
"Manage Effect Presets…": "Spravovat přednastavení efektů…",
"Export Effect Presets…": "Exportovat přednastavení efektů…"
}
+1
View File
@@ -268,6 +268,7 @@
"{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",
"· {in_source} identical copies": "· {in_source} identische Kopien",
"{count} more warnings.": "{count} weitere Warnungen.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} Katalogbilder wurden nicht importiert, weil ihre Einträge im Katalog unvollständig sind.",
"{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.",
"Applied {name}": "{name} angewendet",
+1
View File
@@ -268,6 +268,7 @@
"{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",
"· {in_source} identical copies": "· {in_source} copias idénticas",
"{count} more warnings.": "{count} advertencias más.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "No se importaron {skipped} imágenes del catálogo porque sus registros en el catálogo están incompletos.",
"{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.",
"Applied {name}": "Se aplicó {name}",
+1
View File
@@ -268,6 +268,7 @@
"{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",
"· {in_source} identical copies": "· {in_source} copies identiques",
"{count} more warnings.": "{count} avertissements supplémentaires.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} images du catalogue n’ont pas été importées, car leurs entrées dans le catalogue sont incomplètes.",
"{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.",
"Applied {name}": "{name} appliqué",
+50 -49
View File
@@ -245,55 +245,56 @@
"{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}",
"{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}",
"{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"{name} opened in the other editor": "{name} opened in the other editor",
"{s} s of AV1 video": "{s} s of AV1 video",
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
"{count} more warnings.": "{count} more warnings.",
"· {in_source} identical copies": "· {in_source} identical copies",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"{n} preset{}": "{n} preset{}",
" — kept but not applied: {}": " — kept but not applied: {}",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"Saved template {name}": "Saved template {name}",
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
"Delete “{name}”?": "Delete “{name}”?",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"new, inside {parent}": "new, inside {parent}",
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Originali mancanti: {missing}. I loro dati nel catalogo sono disponibili; usa Trova foto mancanti per ricollegarli.",
"{name} opened in the other editor": "{name} aperto nell'altro editor",
"{s} s of AV1 video": "{s} s di video AV1",
"Delete {n} effect preset{}? This can't be undone.": "Eliminare i preset effetto ({n})?{:.0} L'operazione non si può annullare.",
"{count} more warnings.": "Altri avvisi: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} immagini del catalogo non sono state importate perché i loro record nel catalogo sono incompleti.",
"· {in_source} identical copies": "· copie identiche: {in_source}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Unione {what} aggiunta a {mp}, la dimensione massima di un panorama (la dimensione piena è {full})",
"{n} preset{}": "Preset: {n}{:.0}",
" — kept but not applied: {}": " — conservati ma non applicati: {}",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Unione {what} aggiunta a {mp}: la dimensione piena, {full}, richiede più memoria di quella disponibile su questo computer",
"Saved template {name}": "Modello {name} salvato",
"LightCraft stopped unexpectedly: {e}": "LightCraft si è chiuso in modo imprevisto: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Rinomina i volti in tutta la libreria ({n}){:.0}. I file non vengono modificati.",
" · {n} skipped (could not be rendered)": " · saltate: {n} (non è stato possibile eseguire il rendering)",
"Delete “{name}”?": "Eliminare “{name}”?",
"Keywords of {n} selected photos": "Parole chiave delle foto selezionate: {n}",
"new, inside {parent}": "nuova, dentro {parent}",
"Delete these {n} effect presets?": "Eliminare questi preset effetto ({n})?",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
"Skipped {problem}": "Skipped {problem}",
"Updated {name}": "Updated {name}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
"Slideshow exported: {what}": "Slideshow exported: {what}",
"Exported to {path}": "Exported to {path}",
"…and {more} more": "…and {more} more",
"Create the keyword “{name}”": "Create the keyword “{name}”",
"{n} JPEG files": "{n} JPEG files",
"Exported {} effect preset{}": "Exported {} effect preset{}",
"Imported {name}": "Imported {name}",
"{n} pages": "{n} pages",
" · kept, not applied: {}": " · kept, not applied: {}",
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
"{photos} catalog records processed; {collections} collections.": "Record del catalogo elaborati: {photos}; raccolte: {collections}.",
"Skipped {problem}": "Saltato: {problem}",
"Updated {name}": "{name} aggiornato",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Non è stato possibile scrivere i file XMP di alcune foto ({n}): la loro cartella è scollegata? LightCraft continua a riprovare.",
"Apply to “{mask}” on Selected Photos": "Applica a “{mask}” sulle foto selezionate",
"Update “{name}” with Current Settings": "Aggiorna “{name}” con le impostazioni attuali",
"Slideshow exported: {what}": "Presentazione esportata: {what}",
"Exported to {path}": "Esportato in {path}",
"…and {more} more": "…e altri {more}",
"Create the keyword “{name}”": "Crea la parola chiave “{name}”",
"{n} JPEG files": "File JPEG: {n}",
"Exported {} effect preset{}": "Preset effetto esportati: {}{:.0}",
"Imported {name}": "{name} importato",
"{n} pages": "Pagine: {n}",
" · kept, not applied: {}": " · conservato, non applicato: {}",
"Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?",
"{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Template preview: {name}": "Template preview: {name}",
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
"Applied {name}": "Applied {name}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"{n} originals opened in the other editor": "{n} originals opened in the other editor",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"Effect: {name}": "Effect: {name}",
" in {g} group{}": " in {g} group{}",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"Already in “{}”": "Already in “{}”",
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
"Template preview: {name}": "Anteprima del modello: {name}",
"{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}",
"Applied {name}": "{name} applicato",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” è già una persona. Unirle ne crea una sola; Annulla le separa di nuovo.",
"{n} originals opened in the other editor": "Originali aperti nell'altro editor: {n}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft si è chiuso in modo imprevisto: {e}\n\nI dettagli sono nel file di log: {path}",
"Effect: {name}": "Effetto: {name}",
" in {g} group{}": " in gruppi: {g}{:.0}",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Spazio colore: Rec. 2020 {curve} (AVIF HDR)",
"{n} selected photos · {pages} pages": "Foto selezionate: {n} · pagine: {pages}",
"Already in “{}”": "Già in “{}”",
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro."
}
+352 -8
View File
@@ -8,11 +8,13 @@
"+ Rule": "+ Regola",
"0: nothing is rejected.": "0: non viene scartato niente.",
"1 day": "1 giorno",
"150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · modifiche attuali · adatta senza ritagliare",
"2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12",
"= exactly": "= esattamente",
"A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)",
"A one-time download of about": "Un download unico di circa",
"A pen's pressure sets the size and flow of the brush": "La pressione della penna regola dimensione e flusso del pennello",
"AI Mask Model Status": "Stato del modello IA per le maschere",
"About": "Informazioni",
"About LightCraft": "Informazioni su LightCraft",
@@ -38,6 +40,9 @@
"Add to Favorites": "Aggiungi ai preferiti",
"Add to Local": "Aggiungi a Locale",
"Add to My Photos": "Aggiungi a Le mie foto",
"Can't be moved here": "Non si possono spostare qui",
"This row covers more than one disk: drop on a folder inside it": "Questa riga comprende più di un disco: rilascia su una cartella al suo interno",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Le foto locali non sono ancora nella libreria: prima Aggiungi a Le mie foto, poi spostale",
"Add to Selected Photos": "Aggiungi alle foto selezionate",
"Add to Target Album": "Aggiungi all'album di destinazione",
"Add to this mask": "Aggiungi a questa maschera",
@@ -153,7 +158,7 @@
"Blue channel": "Canale blu",
"Blue label": "Etichetta blu",
"Blue/Yellow Fringe": "Frange blu/gialle",
"Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.",
"Blurry photos below the score are flagged as rejects.": "Le foto sfocate sotto il punteggio vengono contrassegnate come scartate.",
"Boundary Warp": "Deformazione dei bordi",
"Bright & Airy": "Luminoso e arioso",
"Bring All to Front": "Porta tutto in primo piano",
@@ -198,6 +203,7 @@
"Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare",
"Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…",
"Choose Folder…": "Scegli cartella…",
"Choose a PDF destination after confirming. Print the PDF from your viewer.": "Scegli dove salvare il PDF dopo la conferma. Stampa il PDF dal tuo visualizzatore.",
"Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia",
"Choose…": "Scegli…",
"Cinematic": "Cinematografico",
@@ -248,6 +254,9 @@
"Color Range": "Intervallo colori",
"Color label cleared": "Etichetta colore tolta",
"Color space": "Spazio colore",
"Color space: Rec. 2020 PQ (HDR AVIF)": "Spazio colore: Rec. 2020 PQ (AVIF HDR)",
"Color space: Rec. 2020 PQ (HDR JPEG XL)": "Spazio colore: Rec. 2020 PQ (JPEG XL HDR)",
"Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Spazio colore: Rec. 2020 PQ, 16 bit (PNG HDR)",
"Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)",
"Colors": "Colori",
"Combine files…": "Combina file…",
@@ -264,7 +273,14 @@
"Concrete Cool": "Cemento freddo",
"Confirm before deleting": "Chiedi conferma prima di eliminare",
"Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente",
"Move Photos": "Sposta foto",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "I loro file e i file XMP collegati vengono spostati sul disco. Modifica → Annulla li riporta indietro.",
"Don't ask again": "Non chiedere più",
"Another library was opened since the photos were dropped: nothing was moved": "Dopo il trascinamento delle foto è stata aperta un'altra libreria: non è stato spostato nulla",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Impostazioni → Chiedi conferma prima di spostare le foto in un'altra cartella sul disco la riattiva",
"Confirm before moving photos to another folder on disk": "Chiedi conferma prima di spostare le foto in un'altra cartella sul disco",
"Constrain Crop": "Vincola il ritaglio",
"Contact Sheet PDF": "PDF provino a contatto",
"Continue": "Continua",
"Continue Without Saving": "Continua senza salvare",
"Contrast": "Contrasto",
@@ -457,7 +473,7 @@
"Export Now": "Esporta ora",
"Export Point Curve Presets": "Esporta preset di curva a punti",
"Export Point Curve Presets…": "Esporta preset di curva a punti…",
"Export Presets": "Esporta predefiniti",
"Export Presets": "Esporta preset",
"Export Presets…": "Esporta preset…",
"Export User Presets…": "Esporta preset utente…",
"Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto",
@@ -465,7 +481,7 @@
"Export with Previous": "Esporta con le impostazioni precedenti",
"Exporting": "Esportazione in corso",
"Export…": "Esporta…",
"Exposure": "Tempo di esposizione",
"Exposure": "Esposizione",
"Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)",
"Extended Description": "Descrizione estesa",
"External Editor": "Editor esterno",
@@ -489,6 +505,7 @@
"File name": "Nome file",
"File types:": "Tipi di file:",
"Filename": "Nome file",
"Filename captions": "Didascalie con il nome del file",
"Fill": "Riempi",
"Fill Edges": "Riempi i bordi",
"Film": "Pellicola",
@@ -548,6 +565,10 @@
"Friday": "Venerdì",
"Full": "Completo",
"Full Screen Preview": "Anteprima a schermo intero",
"Hold View Until Refreshed": "Mantieni la vista fino all'aggiornamento",
"HDR TIFF needs 32-bit float.": "Il TIFF HDR richiede virgola mobile a 32 bit.",
"HDR output": "Uscita HDR",
"Letter": "Lettera",
"Lights Out": "Luci spente",
"Full screen mode": "Modalità schermo intero",
"Full size": "Dimensione piena",
@@ -603,6 +624,7 @@
"HDR panorama": "Panorama HDR",
"HDR panoramas": "Panorami HDR",
"HDR with Last Settings": "HDR con le ultime impostazioni",
"HDR for Each Bracket Set": "HDR per ogni serie a forcella",
"HDR…": "HDR…",
"Has Edits": "Con modifiche",
"Has GPS": "Con GPS",
@@ -646,7 +668,7 @@
"Import Photos…": "Importa foto…",
"Import Point Curve Presets": "Importa preset di curva a punti",
"Import Point Curve Presets…": "Importa preset di curva a punti…",
"Import Presets": "Importa predefiniti",
"Import Presets": "Importa preset",
"Import Presets…": "Importa preset…",
"Import Profiles": "Importa profili",
"Import Profiles & Presets…": "Importa profili e preset…",
@@ -764,6 +786,7 @@
"Local folders": "Cartelle locali",
"Local library — no cloud account needed": "Libreria locale: non serve un account cloud",
"Locate Missing File…": "Trova file mancante…",
"Locate Missing Files…": "Individua file mancanti…",
"Locate Photo": "Trova foto",
"Location": "Posizione",
"Lock": "Blocca",
@@ -900,7 +923,12 @@
"No edits yet.": "Ancora nessuna modifica.",
"No exposure information": "Nessuna informazione di esposizione",
"No filter presets yet": "Ancora nessun preset di filtro",
"No lens data embedded in this file.": "Questo file non contiene dati dell'obiettivo.",
"No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).",
"Export to": "Esporta in",
"Specific folder": "Cartella specifica",
"Same folder as original photo": "Stessa cartella della foto originale",
"Tidying the thumbnail cache": "Riordino della cache delle miniature",
"No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.",
"No matching commands": "Nessun comando corrispondente",
"No matching photos": "Nessuna foto corrispondente",
@@ -911,6 +939,11 @@
"No new presets": "Nessun nuovo preset",
"No photo selected": "Nessuna foto selezionata",
"No photos": "Nessuna foto",
"Your library is empty": "La tua libreria è vuota",
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Le foto che importi compaiono qui. Nulla viene copiato o spostato, a meno che tu non lo scelga.",
"or drop photos and folders into this window": "oppure trascina foto e cartelle in questa finestra",
"Import photos to get started": "Importa delle foto per iniziare",
"or drop photos into this window": "oppure trascina foto in questa finestra",
"No photos found": "Nessuna foto trovata",
"No photos in this folder": "Nessuna foto in questa cartella",
"No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi",
@@ -986,6 +1019,7 @@
"Paste from Previous": "Incolla dalla precedente",
"Paste pages": "Incolla pagine",
"Pastel Haze": "Foschia pastello",
"Pen Pressure": "Pressione della penna",
"People": "Persone",
"Percent": "Percentuale",
"Percentage": "Percentuale",
@@ -1000,6 +1034,7 @@
"Photo Merge": "Unione di foto",
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).",
"Photos": "Foto",
"Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Foto modificate in HDR: JPEG con gain map HDR (si vede bene ovunque, luci più brillanti sugli schermi HDR), AVIF a 10 bit, PNG e JPEG XL a 16 bit Rec. 2020 PQ; il TIFF richiede virgola mobile a 32 bit",
"Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)",
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.",
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.",
@@ -1020,9 +1055,9 @@
"Preferences": "Preferenze",
"Preferences…": "Preferenze…",
"Prepare AI Masks": "Prepara le maschere IA",
"Preset": "Predefinito",
"Preset": "Preset",
"Preset name": "Nome del preset",
"Presets": "Predefiniti",
"Presets": "Preset",
"Presets & Profiles": "Preset e profili",
"Presets (Shift+P)": "Preset (Maiusc+P)",
"Presets and profiles": "Preset e profili",
@@ -1123,6 +1158,7 @@
"Refining the mask's detail…": "Rifinitura del dettaglio della maschera…",
"Refresh Source": "Aggiorna l'origine",
"Refresh Source (/)": "Aggiorna l'origine (/)",
"Refresh View": "Aggiorna vista",
"Reject": "Scarta",
"Reject below focus": "Scarta sotto la soglia di messa a fuoco",
"Rejected": "Rifiutato",
@@ -1188,7 +1224,30 @@
"Reset Section": "Reimposta sezione",
"Reset Tone Curve": "Reimposta curva dei toni",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)",
"Reset all edits": "Ripristina tutte le modifiche",
"Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto",
"LightCraft stopped": "LightCraft si è chiuso",
"Show Focus Point": "Mostra punto di messa a fuoco",
"Zoom to Focus Point": "Zoom sul punto di messa a fuoco",
"Focus": "Messa a fuoco",
"Focus point": "Punto di messa a fuoco",
"Wide": "Ampio",
"Center": "Centro",
"Flexible Spot": "Spot flessibile",
"Zone": "Zona",
"Expanded Flexible Spot": "Spot flessibile espanso",
"Custom AF Area": "Area AF personalizzata",
"Face tracking": "Tracciamento volti",
"Lock On AF": "AF con aggancio",
"Multi": "Multi",
"Spot": "Spot",
"Tracking": "Tracciamento",
"Face Tracking": "Tracciamento volti",
"Animal Eye Tracking": "Tracciamento occhi degli animali",
"Human Eye Tracking": "Tracciamento occhi umani",
"Read Focus Points": "Leggi punti di messa a fuoco",
"Reading focus points": "Lettura dei punti di messa a fuoco",
"Reshuffle": "Rimescola",
"Resize Face Box": "Ridimensiona riquadro del volto",
"Resize panel window": "Ridimensiona la finestra del pannello",
@@ -1314,7 +1373,7 @@
"Shadows Tint": "Tinta delle ombre",
"Shadows split": "Divisione delle ombre",
"Sharpen": "Nitidezza",
"Sharpening": "Contrasto",
"Sharpening": "Nitidezza",
"Sharpness": "Nitidezza",
"Shift": "Maiusc",
"Shift by": "Sposta di",
@@ -1327,6 +1386,8 @@
"Show Luminance Map": "Mostra mappa di luminanza",
"Show Mask": "Mostra maschera",
"Show Mask Overlay": "Mostra sovrapposizione maschera",
"Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera",
"Show all overlays": "Mostra tutte le sovrapposizioni",
"Show Mask Pins": "Mostra i punti delle maschere",
"Show Original": "Mostra originale",
"Show Photo Counts": "Mostra il numero di foto",
@@ -1345,6 +1406,7 @@
"Show overlay (O)": "Mostra sovrapposizione (O)",
"Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica",
"Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo",
"Show the import review when a camera or card is connected": "Mostra la revisione dell'importazione quando si collega una fotocamera o una scheda",
"Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo",
"Show/Hide Mask": "Mostra/nascondi maschera",
"Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto",
@@ -1372,6 +1434,7 @@
"Soft Pastel": "Pastello morbido",
"Soft Proofing": "Prova colore a video",
"Soft Skin": "Pelle morbida",
"Solo Mode": "Un pannello alla volta",
"Sort": "Ordina",
"Sort by": "Ordina per",
"Source": "Origine",
@@ -1518,6 +1581,7 @@
"Vibrance": "Vividezza",
"Videos": "Video",
"View": "Visualizza",
"Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "I visualizzatori che non leggono il blocco cICP del PNG mostrano i valori PQ come sRGB: piatti e slavati. Per un'immagine che appaia corretta ovunque, usa JPEG (gain map).",
"Viewing and sharing": "Visione e condivisione",
"Vignette": "Vignettatura",
"Vignetting": "Vignettatura",
@@ -1550,6 +1614,7 @@
"Will be restored": "Verrà ripristinata",
"Window": "Finestra",
"Winter Blue": "Blu d'inverno",
"Wrote the XMP sidecars that were waiting": "Scritti i file XMP che erano in attesa",
"XMP Sidecar Preferences": "Preferenze dei file XMP collaterali",
"XMP sidecars": "File XMP collaterali",
"Year": "Anno",
@@ -1716,6 +1781,8 @@
"cached picture": "immagine in cache",
"cached pictures": "immagini in cache",
"commercial use allowed": "uso commerciale consentito",
"Reading photos…": "Lettura delle foto…",
"Adding photos…": "Aggiunta delle foto…",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in uso",
"installed": "installato",
@@ -1751,6 +1818,95 @@
"Decrease Rating": "Diminuisci valutazione",
"Increase Rating": "Aumenta valutazione",
"Click to type a value": "Fai clic per scrivere un valore",
"Run face models on the CPU only": "Esegui i modelli dei volti solo sulla CPU",
"Detection": "Rilevamento",
"Recognition": "Riconoscimento",
"Not loaded yet": "Non ancora caricato",
"Checking the GPU…": "Controllo della GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modello per i volti usa la GPU solo quando un controllo mostra che dà gli stessi risultati più velocemente.",
"Checking the GPU for faces": "Controllo della GPU per i volti",
"Include selected photos": "Includi le foto selezionate",
"Choose Profile…": "Scegli profilo…",
"Edit in HDR: highlights above SDR white, for HDR exports and displays": "Modifica in HDR: luci oltre il bianco SDR, per esportazioni e schermi HDR",
"Edit in Other Editor": "Modifica nell'altro editor",
"Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Modifica nell'altro editor (⌥⌘E) apre la copia in questa applicazione (il nome di un'app su macOS, il percorso di un programma altrove).",
"Edit in PhotoCraft": "Modifica in PhotoCraft",
"External Editing": "Modifica esterna",
"Found automatically": "Trovato automaticamente",
"HDR": "HDR",
"LUT-based": "Basato su LUT",
"Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "A sinistra: la gamma standard (SDR) fino al bianco SDR. A destra: la gamma HDR, una fascia per ogni stop sopra il bianco SDR (la linea arancione: il margine HDR). Con Visualizza gamma HDR attivo, le fasce ne prendono i colori.",
"Matrix/TRC": "Matrice/TRC",
"Monitor profile": "Profilo monitor",
"None (the display is treated as sRGB)": "Nessuno (lo schermo viene trattato come sRGB)",
"Other editor": "Altro editor",
"Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Foto → Modifica in → Modifica in PhotoCraft (⇧⌘E, o clic destro su una foto) esegue il rendering di una copia con le tue modifiche, la impila con l'originale e la apre in PhotoCraft. Salva lì e la copia si aggiorna qui. Come PSD, PhotoCraft salva direttamente su di essa. Lascia vuota la posizione a meno che tu non usi una copia portatile.",
"PhotoCraft": "PhotoCraft",
"Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Le foto vengono mostrate attraverso il profilo ICC del monitor, così i colori sono corretti su schermi calibrati e a gamut esteso, e la vista Dettaglio usa tutto il gamut dello schermo. Istogramma, cache delle anteprime ed esportazioni non cambiano.",
"Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Le foto restano in Tutte le foto. Eliminando una cartella si eliminano anche i suoi album. Puoi annullare l'operazione.",
"Primaries (x, y)": "Primari (x, y)",
"Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "I profili assegnati nelle impostazioni colore del desktop di solito sono in ~/.local/share/icc.",
"Progressive": "Progressivo",
"SDR": "SDR",
"Use Screen Profile": "Usa profilo dello schermo",
"Use sRGB": "Usa sRGB",
"this screen's profile": "il profilo di questo schermo",
"Show photos": "Mostra foto",
"Their photos in the grid": "Le loro foto nella griglia",
"Click to name this face": "Fai clic per dare un nome a questo volto",
"Apple Photos Imports": "Importazioni da Foto di Apple",
"Background Tasks": "Attività in background",
"Cancel Background Task": "Annulla attività in background",
"Contact Sheet PDF…": "PDF provino a contatto…",
"Create Keyword": "Crea parola chiave",
"Display": "Schermo",
"Display Profile": "Profilo dello schermo",
"Edit Keyword": "Modifica parola chiave",
"Export Contact Sheet PDF": "Esporta PDF provino a contatto",
"Find Blemishes": "Trova imperfezioni",
"Keyword Info": "Informazioni parola chiave",
"Move Keyword": "Sposta parola chiave",
"Put New Keywords Inside": "Metti dentro le nuove parole chiave",
"Scan Folder for Changes": "Cerca modifiche nella cartella",
"Set Folder Color Label": "Imposta etichetta colore cartella",
"Remove Folder Color Label": "Rimuovi etichetta colore cartella",
"Show in Compare": "Mostra in Confronto",
"Update to Current Process": "Aggiorna al processo attuale",
"HDR Headroom": "Margine HDR",
"SDR Brightness": "Luminosità SDR",
"SDR Clarity": "Chiarezza SDR",
"SDR Contrast": "Contrasto SDR",
"SDR Highlights": "Luci SDR",
"SDR Shadows": "Ombre SDR",
"SDR Whites": "Bianchi SDR",
"Color space: Rec. 2020 HLG (HDR AVIF)": "Spazio colore: Rec. 2020 HLG (AVIF HDR)",
"or drop a .onnx file on the window": "oppure trascina un file .onnx sulla finestra",
"Already installed. Installing again is harmless.": "Già installato. Installarlo di nuovo non crea problemi.",
"Enter to confirm, Esc to cancel": "Invio per confermare, Esc per annullare",
"Add a model file…": "Aggiungi un file di modello…",
"Open Settings": "Apri impostazioni",
"Select none": "Deseleziona tutto",
"Select all": "Seleziona tutto",
"Visualize HDR": "Visualizza HDR",
"Add a Face Recognition Model": "Aggiungi un modello di riconoscimento dei volti",
"Choose Monitor Profile": "Scegli profilo monitor",
"Display profile not used": "Profilo dello schermo non usato",
"Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Trascina per impostare tonalità e saturazione. Tieni premuto Maiusc per mantenere la tonalità, Cmd/Ctrl per mantenere la saturazione, Alt/Opzione per una regolazione fine. Doppio clic per ripristinare.",
"Edit In": "Modifica in",
"Face models (ONNX)": "Modelli dei volti (ONNX)",
"ICC Profiles": "Profili ICC",
"M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100 e R8. I file CR3 che il decoder non legge ancora si aprono comunque dal JPEG incorporato, come prima.",
"Next History Step": "Passo successivo della cronologia",
"Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "I NEF Nikon ad alta efficienza (HE / HE★) usano la compressione intoPIX TicoRAW. LightCraft al momento non ha una licenza per questo codec, quindi è disponibile solo l'anteprima JPEG incorporata dalla fotocamera, non i dati raw del sensore. Per modificare i dati raw, scatta in NEF compresso senza perdita oppure converti i file HE esistenti in DNG con un convertitore dotato di licenza.",
"Open Original in Other Editor (RAW, no copy)": "Apri l'originale nell'altro editor (RAW, senza copia)",
"Photo Above": "Foto sopra",
"Photo Below": "Foto sotto",
"Previous History Step": "Passo precedente della cronologia",
"Select Only Active Photo": "Seleziona solo la foto attiva",
"Show this wheel's effect": "Mostra l'effetto di questa ruota",
"Targeted adjustment on: drag up or down on the photo · Esc to stop": "Regolazione mirata attiva: trascina in su o in giù sulla foto · Esc per terminare",
"To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Per passare invece a questo editor il file originale (il raw, senza copia: ad esempio DxO PhotoLab), fai clic destro su una foto → Modifica in → Apri l'originale nell'altro editor (RAW, senza copia).",
"Angle": "Angolo",
"Video": "Video",
"Copyrighted": "Protetta da copyright",
@@ -1814,6 +1970,15 @@
"Keyword List": "Elenco parole chiave",
"Filter Keywords": "Filtra parole chiave",
"No keywords yet": "Ancora nessuna parola chiave",
"Show fewer": "Mostra meno",
"Whole names": "Nomi interi",
"Grouped by parent": "Raggruppate per parola chiave superiore",
"Keywords of the selection": "Parole chiave della selezione",
"Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Raggruppate: le parole chiave dentro la stessa parola chiave condividono una riga col suo nome, e gli elenchi lunghi e i suggerimenti vengono accorciati.",
"Show every keyword of the selected photos": "Mostra tutte le parole chiave delle foto selezionate",
"new keyword": "nuova parola chiave",
"Return creates this keyword and gives it to the selected photos": "Invio crea questa parola chiave e la assegna alle foto selezionate",
"Separate several keywords with commas. Return gives them to the selected photos.": "Separa più parole chiave con le virgole. Invio le assegna alle foto selezionate.",
"No keywords match": "Nessuna parola chiave corrisponde",
"Keyword Name": "Nome della parola chiave",
"Synonyms": "Sinonimi",
@@ -1953,6 +2118,111 @@
"The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.",
"Adding to Apple Photos": "Aggiunta a Foto di Apple",
"Add Face Model…": "Aggiungi modello dei volti…",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nessun processore grafico trovato: LightCraft esegue il rendering sulla CPU, molto più lentamente. Dettagli in Aiuto → Info sistema.",
"On NixOS, start it from `nix develop`.": "Su NixOS, avvialo da `nix develop`.",
"Files with the same content as another file in this import: only the first is imported.": "File con lo stesso contenuto di un altro file di questa importazione: viene importato solo il primo.",
"Lightroom Catalog Import": "Importazione catalogo di Lightroom",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Queste foto sono solo in memoria. Apri una libreria su disco e importa di nuovo il catalogo per salvarle.",
"Show in Library": "Mostra nella libreria",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "I cataloghi di Lightroom vengono importati nella libreria aperta; non viene creata una cartella di catalogo separata.",
"Destination library": "Libreria di destinazione",
"Playback": "Riproduzione",
"Layout": "Layout",
"Link all margins": "Collega tutti i margini",
"Margins": "Margini",
"Left": "Sinistra",
"Right": "Destra",
"Top": "In alto",
"Bottom": "In basso",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "I margini sono una frazione del lato corto della diapositiva, così le diapositive appaiono uguali su qualsiasi schermo.",
"Show guides": "Mostra guide",
"Aspect preview": "Anteprima proporzioni",
"Zoom to fill frame": "Ingrandisci per riempire la cornice",
"Stroke border": "Bordo a tratto",
"Colour": "Colore",
"Cast shadow": "Ombra portata",
"Offset": "Spostamento",
"Backdrop": "Fondale",
"Background colour": "Colore di sfondo",
"Colour wash": "Velatura di colore",
"Background image": "Immagine di sfondo",
"(not in the library)": "(non nella libreria)",
"Use selected photo": "Usa la foto selezionata",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Seleziona una foto, poi attiva questa opzione per riempire con essa lo sfondo dietro ogni foto.",
"Reset layout": "Ripristina layout",
"Select a photo to preview its slide": "Seleziona una foto per vedere l'anteprima della sua diapositiva",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto selezionata come diapositiva. La riproduzione riempie lo schermo; l'anteprima delle proporzioni cambia solo questa anteprima.",
"Overlays": "Sovrapposizioni",
"Identity plate": "Targa di identità",
"Your name or studio": "Il tuo nome o studio",
"Anchored to": "Ancorato a",
"Slide": "Diapositiva",
"Render behind image": "Disegna dietro l'immagine",
"Rating stars": "Stelle di valutazione",
"Text overlays": "Testi sovrapposti",
"Add text overlay": "Aggiungi testo sovrapposto",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Segnaposto: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Il resto del testo appare così come lo scrivi.",
"Use export watermark": "Usa la filigrana di esportazione",
"The export watermark, drawn on the photo and sized relative to it.": "La filigrana di esportazione, disegnata sulla foto e dimensionata rispetto a essa.",
"Titles": "Titoli",
"Intro screen": "Schermata iniziale",
"Ending screen": "Schermata finale",
"Shows": "Mostra",
"Colour only": "Solo colore",
"Override colour": "Sostituisci colore",
"Own duration (off: the slide duration)": "Durata propria (disattivata: la durata della diapositiva)",
"Duration": "Durata",
"Title text": "Testo del titolo",
"Intro": "Apertura",
"Ending": "Chiusura",
"End": "Fine",
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'apertura viene riprodotta una volta prima della prima foto; la chiusura dopo l'ultima foto, quando Ripeti è disattivato. La targa di identità è quella impostata in Sovrapposizioni. I titoli si dissolvono come le diapositive e l'ordine casuale non li sposta mai.",
"This screen is off: turn it on under Titles to play it.": "Questa schermata è disattivata: attivala in Titoli per riprodurla.",
"The intro screen, before the first photo.": "La schermata iniziale, prima della prima foto.",
"The ending screen, after the last photo.": "La schermata finale, dopo l'ultima foto.",
"Templates": "Modelli",
"Built-in": "Integrati",
"Your templates": "I tuoi modelli",
"None yet: save the current settings as a template below.": "Ancora nessuno: salva le impostazioni attuali come modello qui sotto.",
"Before the last template": "Prima dell'ultimo modello",
"Previous settings": "Impostazioni precedenti",
"New template name": "Nome del nuovo modello",
"Save as Template": "Salva come modello",
"Import…": "Importa…",
"Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "I tuoi modelli richiedono una cartella delle impostazioni, che questa build non ha; quelli integrati funzionano.",
"Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Fai clic su un modello per applicarlo; passaci sopra per vederne l'anteprima sulla foto selezionata. Fai clic destro su uno dei tuoi per aggiornarlo, rinominarlo, esportarlo o eliminarlo.",
"Import Slideshow Templates": "Importa modelli di presentazione",
"Export Slideshow Template": "Esporta modello di presentazione",
"Slideshow Templates": "Modelli di presentazione",
"Plain Black": "Nero pieno",
"Gallery Wall": "Parete da galleria",
"Edge to Edge": "Da bordo a bordo",
"Cinema Wide": "Cinema panoramico",
"Shooting Data": "Dati di scatto",
"Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Ogni foto il più grande possibile, su nero, con una breve dissolvenza incrociata. Nulla in sovrimpressione.",
"A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una parete grigio chiaro con passe-partout bianco e un'ombra morbida; didascalia e valutazione sotto la foto.",
"Every photo cropped to fill the whole screen, with slow crossfades.": "Ogni foto ritagliata per riempire tutto lo schermo, con dissolvenze incrociate lente.",
"A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un'inquadratura 16:9 con bande nere sopra e sotto, il titolo nell'angolo, dissolvenze al nero e un cartello finale.",
"A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Uno sfondo ardesia scuro con esposizione, ISO e lunghezza focale, poi fotocamera e obiettivo, sotto ogni foto.",
"Exporting slideshow": "Esportazione della presentazione",
"Export Slideshow…": "Esporta presentazione…",
"Export Slideshow": "Esporta presentazione",
"Export Slideshow: folder for the JPEG files": "Esporta presentazione: cartella per i file JPEG",
"Frame rate": "Fotogrammi al secondo",
"Container": "Contenitore",
"Export PDF…": "Esporta PDF…",
"Export JPEG…": "Esporta JPEG…",
"Export Video…": "Esporta video…",
"Automatically show full screen": "Mostra automaticamente a schermo intero",
"One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una pagina per diapositiva (il lato lungo in pixel = punti), con la durata e la dissolvenza della diapositiva per la modalità presentazione del visualizzatore.",
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "File JPEG numerati (nome-001.jpg…) in una nuova cartella, il cui nome si sceglie al passo successivo.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, ogni diapositiva per la sua durata con le sue dissolvenze. Senza audio: la musica non viene ancora esportata.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Le diapositive usano layout, sovrapposizioni e schermate iniziale e finale della presentazione, nell'ordine della vista.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Le foto nella vista come diapositive, con le impostazioni qui sopra. L'avanzamento compare nella pila delle attività.",
"Checking raw files": "Controllo dei file raw",
"1 raw can now be developed from its raw data": "1 raw ora può essere sviluppato dai suoi dati raw",
"This raw is now developed from its raw data": "Questo raw ora viene sviluppato dai suoi dati raw",
"This raw variant still can't be decoded": "Questa variante raw non si può ancora decodificare",
"Cancel Face Model Download": "Annulla il download del modello dei volti",
"Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto",
"Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti",
@@ -1972,6 +2242,7 @@
"Go back to listing them by name": "Torna all'elenco per nome",
"Index Faces": "Indicizza i volti",
"Index Faces in the Background": "Indicizza i volti in background",
"Click to type a value, drag to change it": "Fai clic per digitare un valore, trascina per cambiarlo",
"Inspect Face Model File": "Esamina il file del modello dei volti",
"Install Face Model": "Installa il modello dei volti",
"Library Folders": "Cartelle della libreria",
@@ -1990,5 +2261,78 @@
"Test Face Model": "Testa il modello dei volti",
"This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.",
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)",
"Unnamed Faces": "Volti senza nome"
"Unnamed Faces": "Volti senza nome",
"smart album value|No": "No",
"smart album value|Yes": "Sì",
"Add Print Cell": "Aggiungi cella di stampa",
"Apply Mask Effect": "Applica effetto maschera",
"Apply Print Template": "Applica modello di stampa",
"Apply Slideshow Template": "Applica modello di presentazione",
"Arrange Print Cell": "Disponi cella di stampa",
"Auto Layout": "Layout automatico",
"Choose Layout Image…": "Scegli immagine di layout…",
"Clear Print Layout": "Cancella layout di stampa",
"Cycle Flag": "Alterna bandiera",
"Cycle Tool Overlay": "Alterna sovrapposizione strumento",
"Decrease Flag Status": "Riduci stato bandiera",
"Decrease Grid Thumbnail Size": "Riduci dimensione miniature griglia",
"Delete Mask Effect": "Elimina effetto maschera",
"Delete Print Cell": "Elimina cella di stampa",
"Delete Print Template": "Elimina modello di stampa",
"Delete Slideshow Template": "Elimina modello di presentazione",
"Duplicate Mask Effect": "Duplica effetto maschera",
"End Slideshow": "Termina presentazione",
"Export Effect Presets…": "Esporta preset effetti…",
"Export Mask Effects": "Esporta effetti maschera",
"Face Models on the GPU": "Modelli dei volti sulla GPU",
"Favorite Mask Effect": "Effetto maschera preferito",
"Import Mask Effects": "Importa effetti maschera",
"Import Slideshow Template": "Importa modello di presentazione",
"Increase Flag Status": "Aumenta stato bandiera",
"Increase Grid Thumbnail Size": "Aumenta dimensione miniature griglia",
"Layout Image Matte": "Passe-partout immagine di layout",
"Layout Image Opacity": "Opacità immagine di layout",
"Manage Effect Presets…": "Gestisci preset effetti…",
"Mask Effect Presets": "Preset effetti maschera",
"Move Mask Effect to Group": "Sposta effetto maschera nel gruppo",
"Move Print Cell": "Sposta cella di stampa",
"Move Print Cell to Page": "Sposta cella di stampa nella pagina",
"New Print Page": "Nuova pagina di stampa",
"Next Slide": "Diapositiva successiva",
"Pause / Resume Slideshow": "Pausa / riprendi presentazione",
"Previous Slide": "Diapositiva precedente",
"Print Settings": "Impostazioni di stampa",
"Print Templates": "Modelli di stampa",
"Print to PDF": "Stampa su PDF",
"Read Focus Points in the Background": "Leggi punti di messa a fuoco in background",
"Recheck Preview-Only Raws": "Ricontrolla i raw con sola anteprima",
"Rename Mask Effect": "Rinomina effetto maschera",
"Rename Mask Effect Group": "Rinomina gruppo di effetti maschera",
"Rename Person": "Rinomina persona",
"Rename Slideshow Template": "Rinomina modello di presentazione",
"Reset Print Settings": "Ripristina impostazioni di stampa",
"Resize Print Cell": "Ridimensiona cella di stampa",
"Save Current Settings as New Effect Preset…": "Salva le impostazioni attuali come nuovo preset effetto…",
"Save Mask Effect": "Salva effetto maschera",
"Save Print Template": "Salva modello di stampa",
"Save Slideshow Template": "Salva modello di presentazione",
"Set Effect for New Masks": "Imposta effetto per le nuove maschere",
"Set Print Cell Photo": "Imposta foto della cella di stampa",
"Set Print Settings": "Imposta impostazioni di stampa",
"Show Layout Image": "Mostra immagine di layout",
"Slideshow Export Status": "Stato esportazione presentazione",
"Slideshow Layout Settings": "Impostazioni layout presentazione",
"Slideshow Options…": "Opzioni presentazione…",
"Slideshow Overlay Settings": "Impostazioni sovrapposizioni presentazione",
"Slideshow Playback Settings": "Impostazioni riproduzione presentazione",
"Slideshow Slide Preview": "Anteprima diapositiva della presentazione",
"Slideshow Title Settings": "Impostazioni titoli presentazione",
"Update Mask Effect": "Aggiorna effetto maschera",
"Update Slideshow Template": "Aggiorna modello di presentazione",
"Write Waiting XMP Sidecars": "Scrivi i file XMP in attesa",
"Color Overlay": "Sovrapposizione colore",
"Color Overlay on B&W": "Sovrapposizione colore su B/N",
"Image on Black": "Immagine su nero",
"Image on White": "Immagine su bianco",
"White on Black": "Bianco su nero"
}
+1
View File
@@ -272,6 +272,7 @@
"{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",
"· {in_source} identical copies": "・同一のコピー {in_source} 件",
"{count} more warnings.": "ほかに{count}件の警告があります。",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "カタログ内のレコードが不完全なため、{skipped}件の画像を読み込めませんでした。",
"{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。",
"Applied {name}": "{name} を適用しました",
+1
View File
@@ -278,6 +278,7 @@
"Updated {name}": "Updated {name}",
"new, inside {parent}": "new, inside {parent}",
"{count} more warnings.": "{count} more warnings.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "კატალოგის {skipped} სურათი არ დაიმპორტდა, რადგან მათი ჩანაწერები კატალოგში არასრულია.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"{n} JPEG files": "{n} JPEG files",
"{n} pages": "{n} pages",
+45 -44
View File
@@ -248,50 +248,51 @@
"{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem",
"· {in_source} identical copies": "· identyczne kopie: {in_source}",
"· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}",
" in {g} group{}": " in {g} group{}",
" · kept, not applied: {}": " · kept, not applied: {}",
" — kept but not applied: {}": " — kept but not applied: {}",
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
"Effect: {name}": "Effect: {name}",
"Exported {} effect preset{}": "Exported {} effect preset{}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
"{n} preset{}": "{n} preset{}",
"{count} more warnings.": "{count} more warnings.",
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"Applied {name}": "Applied {name}",
"Delete “{name}”?": "Delete “{name}”?",
"Updated {name}": "Updated {name}",
"Exported to {path}": "Exported to {path}",
"Saved template {name}": "Saved template {name}",
"Imported {name}": "Imported {name}",
"Skipped {problem}": "Skipped {problem}",
"…and {more} more": "…and {more} more",
"Template preview: {name}": "Template preview: {name}",
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
"{n} pages": "{n} pages",
"{n} JPEG files": "{n} JPEG files",
"{s} s of AV1 video": "{s} s of AV1 video",
"Slideshow exported: {what}": "Slideshow exported: {what}",
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
"Create the keyword “{name}”": "Create the keyword “{name}”",
"new, inside {parent}": "new, inside {parent}",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
" in {g} group{}": " w grupach: {g}{:.0}",
" · kept, not applied: {}": " · zachowane, niezastosowane: {}",
" — kept but not applied: {}": " — zachowane, ale niezastosowane: {}",
"Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach",
"Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?",
"Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?",
"Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.",
"Effect: {name}": "Efekt: {name}",
"Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)",
"Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami",
"{n} preset{}": "Ustawienia wstępne: {n}{:.0}",
"{count} more warnings.": "Pozostałe ostrzeżenia: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.",
"{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.",
"Applied {name}": "Zastosowano: {name}",
"Delete “{name}”?": "Usunąć „{name}”?",
"Updated {name}": "Zaktualizowano: {name}",
"Exported to {path}": "Wyeksportowano do {path}",
"Saved template {name}": "Zapisano szablon {name}",
"Imported {name}": "Zaimportowano: {name}",
"Skipped {problem}": "Pominięto: {problem}",
"…and {more} more": "…i jeszcze {more}",
"Template preview: {name}": "Podgląd szablonu: {name}",
"{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}",
"{n} pages": "Strony: {n}",
"{n} JPEG files": "Pliki JPEG: {n}",
"{s} s of AV1 video": "{s} s wideo AV1",
"Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}",
" · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}",
"{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.",
"Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”",
"new, inside {parent}": "nowe, wewnątrz {parent}",
"Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}",
"Already in “{}”": "Już w „{}”",
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
+112 -2
View File
@@ -1511,7 +1511,7 @@
"Versions (Shift+V)": "Wersje (Shift+V)",
"Vertical": "Pionowo",
"Vertical text": "Tekst pionowy",
"Vibrance": "Dynamika",
"Vibrance": "Jaskrawość",
"Videos": "Wideo",
"View": "Widok",
"Viewing and sharing": "Wyświetlanie i udostępnianie",
@@ -2157,5 +2157,115 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?",
"Unnamed Faces": "Nienazwane twarze",
"Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)",
"not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)"
"not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)",
"Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu",
"Refresh View": "Odśwież widok",
"Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia",
"Photo Below": "Zdjęcie poniżej",
"Photo Above": "Zdjęcie powyżej",
"Cycle Flag": "Przełącz oznaczenie",
"Increase Flag Status": "Podnieś status oznaczenia",
"Decrease Flag Status": "Obniż status oznaczenia",
"Update to Current Process": "Aktualizuj do bieżącej wersji procesu",
"HDR": "HDR",
"Previous History Step": "Poprzedni krok historii",
"Next History Step": "Następny krok historii",
"Find Blemishes": "Znajdź skazy",
"Keyword Info": "Informacje o słowie kluczowym",
"Create Keyword": "Utwórz słowo kluczowe",
"Edit Keyword": "Edytuj słowo kluczowe",
"Move Keyword": "Przenieś słowo kluczowe",
"Put New Keywords Inside": "Umieszczaj nowe słowa kluczowe wewnątrz",
"Mask Effect Presets": "Ustawienia wstępne efektów maski",
"Apply Mask Effect": "Zastosuj efekt maski",
"Save Mask Effect": "Zapisz efekt maski",
"Update Mask Effect": "Aktualizuj efekt maski",
"Rename Mask Effect": "Zmień nazwę efektu maski",
"Duplicate Mask Effect": "Duplikuj efekt maski",
"Delete Mask Effect": "Usuń efekt maski",
"Favorite Mask Effect": "Oznacz efekt maski jako ulubiony",
"Move Mask Effect to Group": "Przenieś efekt maski do grupy",
"Rename Mask Effect Group": "Zmień nazwę grupy efektów maski",
"Set Effect for New Masks": "Ustaw efekt dla nowych masek",
"Import Mask Effects": "Importuj efekty maski",
"Export Mask Effects": "Eksportuj efekty maski",
"HDR for Each Bracket Set": "HDR dla każdej serii ekspozycji",
"Write Waiting XMP Sidecars": "Zapisz oczekujące pliki pomocnicze XMP",
"Slideshow Playback Settings": "Ustawienia odtwarzania pokazu slajdów",
"Slideshow Layout Settings": "Ustawienia układu pokazu slajdów",
"Slideshow Overlay Settings": "Ustawienia nakładek pokazu slajdów",
"Slideshow Title Settings": "Ustawienia tytułów pokazu slajdów",
"Slideshow Templates": "Szablony pokazu slajdów",
"Apply Slideshow Template": "Zastosuj szablon pokazu slajdów",
"Save Slideshow Template": "Zapisz szablon pokazu slajdów",
"Update Slideshow Template": "Aktualizuj szablon pokazu slajdów",
"Rename Slideshow Template": "Zmień nazwę szablonu pokazu slajdów",
"Delete Slideshow Template": "Usuń szablon pokazu slajdów",
"Import Slideshow Template": "Importuj szablon pokazu slajdów",
"Export Slideshow Template": "Eksportuj szablon pokazu slajdów",
"Slideshow Slide Preview": "Podgląd slajdu pokazu",
"Export Slideshow": "Eksportuj pokaz slajdów",
"Slideshow Export Status": "Stan eksportu pokazu slajdów",
"Export Contact Sheet PDF": "Eksportuj arkusz stykowy PDF",
"Apple Photos Imports": "Dodawanie do aplikacji Zdjęcia",
"Print Settings": "Ustawienia drukowania",
"Set Print Settings": "Zmień ustawienia drukowania",
"Reset Print Settings": "Przywróć ustawienia drukowania",
"Add Print Cell": "Dodaj komórkę wydruku",
"Move Print Cell": "Przenieś komórkę wydruku",
"Resize Print Cell": "Zmień rozmiar komórki wydruku",
"Delete Print Cell": "Usuń komórkę wydruku",
"Arrange Print Cell": "Rozmieść komórkę wydruku",
"Set Print Cell Photo": "Ustaw zdjęcie komórki wydruku",
"Move Print Cell to Page": "Przenieś komórkę wydruku na stronę",
"Auto Layout": "Układ automatyczny",
"New Print Page": "Nowa strona wydruku",
"Clear Print Layout": "Wyczyść układ wydruku",
"Print to PDF": "Drukuj do PDF",
"Print Templates": "Szablony wydruku",
"Save Print Template": "Zapisz szablon wydruku",
"Apply Print Template": "Zastosuj szablon wydruku",
"Delete Print Template": "Usuń szablon wydruku",
"Recheck Preview-Only Raws": "Sprawdź ponownie pliki RAW dostępne tylko jako podgląd",
"Scan Folder for Changes": "Skanuj folder w poszukiwaniu zmian",
"Rename Person": "Zmień nazwę osoby",
"Read Focus Points": "Odczytaj punkty ostrości",
"Read Focus Points in the Background": "Odczytuj punkty ostrości w tle",
"Background Tasks": "Zadania w tle",
"Cancel Background Task": "Anuluj zadanie w tle",
"Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie",
"Show Focus Point": "Pokaż punkt ostrości",
"Zoom to Focus Point": "Powiększ do punktu ostrości",
"Show in Compare": "Pokaż w porównaniu",
"Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce",
"Decrease Grid Thumbnail Size": "Zmniejsz rozmiar miniatur w siatce",
"Visualize HDR": "Wizualizuj HDR",
"Cycle Tool Overlay": "Przełącz nakładkę narzędzia",
"Choose Layout Image…": "Wybierz obraz układu…",
"Show Layout Image": "Pokaż obraz układu",
"Layout Image Opacity": "Krycie obrazu układu",
"Layout Image Matte": "Matowanie obrazu układu",
"Solo Mode": "Tryb solo",
"Slideshow Options…": "Opcje pokazu slajdów…",
"Export Slideshow…": "Eksportuj pokaz slajdów…",
"Pause / Resume Slideshow": "Wstrzymaj / wznów pokaz slajdów",
"Next Slide": "Następny slajd",
"Previous Slide": "Poprzedni slajd",
"End Slideshow": "Zakończ pokaz slajdów",
"Contact Sheet PDF…": "Arkusz stykowy PDF…",
"Edit in PhotoCraft": "Edytuj w PhotoCraft",
"Edit in Other Editor": "Edytuj w innym edytorze",
"Open Original in Other Editor (RAW, no copy)": "Otwórz oryginał w innym edytorze (RAW, bez kopii)",
"Save Current Settings as New Effect Preset…": "Zapisz bieżące ustawienia jako nowe ustawienie wstępne efektu…",
"Manage Effect Presets…": "Zarządzaj ustawieniami wstępnymi efektów…",
"Export Effect Presets…": "Eksportuj ustawienia wstępne efektów…",
"Display Profile": "Profil ekranu",
"HDR Headroom": "Zapas jasności HDR",
"SDR Brightness": "Jasność SDR",
"SDR Contrast": "Kontrast SDR",
"SDR Highlights": "Światła SDR",
"SDR Shadows": "Cienie SDR",
"SDR Whites": "Biele SDR",
"SDR Clarity": "Przejrzystość SDR",
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU"
}
@@ -272,6 +272,7 @@
"{n} originals opened in the other editor": "{n} originais abertos no outro editor",
"· {in_source} identical copies": "· {in_source} cópias idênticas",
"{count} more warnings.": "Mais {count} avisos.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} imagens do catálogo não foram importadas porque seus registros no catálogo estão incompletos.",
"{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.",
"Applied {name}": "{name} aplicado",
+7 -1
View File
@@ -2180,5 +2180,11 @@
"Checking the GPU…": "Verificando a GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.",
"Checking the GPU for faces": "Verificando a GPU para rostos"
"Checking the GPU for faces": "Verificando a GPU para rostos",
"Include selected photos": "Incluir fotos selecionadas",
"Color Overlay": "Sobreposição de cor",
"Color Overlay on B&W": "Sobreposição de cor em P&B",
"Image on Black": "Imagem sobre preto",
"Image on White": "Imagem sobre branco",
"White on Black": "Branco sobre preto"
}
+1
View File
@@ -268,6 +268,7 @@
"{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",
"· {in_source} identical copies": "· идентичных копий: {in_source}",
"{count} more warnings.": "Других предупреждений: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не импортированы изображения каталога: {skipped}. Их записи в каталоге неполные.",
"{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.",
"Applied {name}": "Применено: {name}",
+1
View File
@@ -268,6 +268,7 @@
"{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",
"· {in_source} identical copies": "· ідентичних копій: {in_source}",
"{count} more warnings.": "Інших попереджень: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не імпортовано зображення каталогу: {skipped}. Їхні записи в каталозі неповні.",
"{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання.",
"Applied {name}": "Застосовано: {name}",
+12 -11
View File
@@ -204,23 +204,23 @@
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
", {} label": ",{0}标签",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "从 {host} 下载。下载完成并通过校验后,LightCraft 会进行安装,{after}。",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"Running on the processor: {}.": "正在处理器上运行:{}。",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}。保存在图库的“denoise”文件夹中;达到上限时,会先删除没有照片使用的最旧结果。",
"Denoising {name}: {done} of {total} tiles{}": "正在为 {name} 降噪:已完成 {done}/{total} 个图块{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} of {} from {host}": "正在从 {host} 下载:{} / {}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"Making the denoised picture… {}%": "正在生成降噪结果… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"Trained on: {provenance}": "训练数据:{provenance}",
" · {queued} waiting": " · {queued} 张等待中",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "先显示该模型的条款,然后从 {host} 下载 {},安装并开始使用。除此之外不会上传任何数据。",
"In line for AI Denoise ({ahead} ahead)…": "AI 降噪排队中(前方还有 {ahead} 张)…",
"Running on {}.": "正在{}上运行。",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "放入“{parent}”中",
@@ -272,6 +272,7 @@
"{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",
"· {in_source} identical copies": "· {in_source} 个相同副本",
"{count} more warnings.": "另有 {count} 条警告。",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 张图像未导入,因为它们在目录中的记录不完整。",
"{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。",
"Applied {name}": "已应用 {name}",
+6 -1
View File
@@ -2179,5 +2179,10 @@
"Checking the GPU…": "正在检查 GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。",
"Checking the GPU for faces": "正在检查用于人脸的 GPU"
"Checking the GPU for faces": "正在检查用于人脸的 GPU",
"Include selected photos": "包含所选照片",
"Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。",
"This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。",
"Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。",
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)"
}
@@ -272,6 +272,7 @@
"{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",
"· {in_source} identical copies": "· {in_source} 個相同副本",
"{count} more warnings.": "另有 {count} 則警告。",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 張影像未匯入,因為它們在目錄中的記錄不完整。",
"{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。",
"Applied {name}": "已套用 {name}",
+38 -3
View File
@@ -9,7 +9,7 @@
use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2};
use lightcraft_geom::Point;
use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures};
use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs_counted, push_pressures};
/// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution.
const MAX_SIDE: usize = 4096;
@@ -41,6 +41,8 @@ pub struct LiveStroke {
alpha: Vec<f32>,
/// Samples stamped so far, and the last one (buffer pixels).
placed: usize,
/// Total dabs emitted across every stamp, including the first sample.
emitted: usize,
last: Option<Point>,
/// The pen pressure of the last stamped sample.
last_pressure: f32,
@@ -54,7 +56,7 @@ impl LiveStroke {
let scale = buffer_scale(params.clip, params.scale);
let params = LiveParams { scale, ..params };
let (w, h) = buffer_size(params.clip, scale);
LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None }
LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, emitted: 0, last: None, last_pressure: 1.0, dirty: None, texture: None }
}
/// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside.
@@ -86,7 +88,7 @@ impl LiveStroke {
for (k, q) in new.iter().enumerate() {
let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64);
let before = dabs.len();
if push_dabs(&mut dabs, self.last, q, r) {
if push_dabs_counted(&mut dabs, &mut self.emitted, self.last, q, r) {
let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0);
let from = if self.last.is_some() { self.last_pressure } else { pq };
push_pressures(&mut pressures, dabs.len() - before, from, pq);
@@ -253,6 +255,39 @@ mod tests {
assert_eq!(once.alpha, steps.alpha);
}
/// Crossing the budget in separate frames must match one stamp, including pressure state.
#[test]
fn incremental_stamping_respects_the_whole_stroke_budget() {
use lightcraft_pipeline::masks::MAX_STROKE_DABS;
let pts = [pos2(20.0, 20.0), pos2(80.0, 20.0), pos2(200.0, 80.0), pos2(21.0, 20.0)];
let pressure = [0.2, 0.4, 1.0, 0.9];
let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() });
once.emitted = MAX_STROKE_DABS - 3;
once.stamp(&pts, &pressure);
let mut steps = LiveStroke::new(once.params);
steps.emitted = MAX_STROKE_DABS - 3;
for n in 1..=pts.len() {
steps.stamp(&pts[..n], &pressure[..n]);
}
assert_eq!(once.alpha, steps.alpha);
assert_eq!(once.emitted, MAX_STROKE_DABS);
assert_eq!(steps.emitted, once.emitted);
assert_eq!(once.last, Some(Point::new(80.0, 20.0)));
assert_eq!(steps.last, once.last);
assert_eq!(once.last_pressure, pressure[1]);
assert_eq!(steps.last_pressure, once.last_pressure);
assert!(once.coverage(80, 20) > 0.0);
assert_eq!(once.coverage(200, 80), 0.0);
let alpha = steps.alpha.clone();
let more = [pts[0], pts[1], pts[2], pts[3], pos2(300.0, 100.0)];
steps.stamp(&more, &[1.0; 5]);
assert_eq!(steps.alpha, alpha, "later frames cannot paint or reconnect discarded samples");
assert_eq!(steps.last, once.last);
assert_eq!(steps.last_pressure, once.last_pressure);
assert_eq!(steps.emitted, MAX_STROKE_DABS);
}
#[test]
fn hostile_input_paints_nothing_and_does_not_panic() {
let mut s = LiveStroke::new(params());
+2 -5
View File
@@ -484,11 +484,8 @@ pub fn default_export_dir() -> String {
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
let p = &app.session.export_params(p)?;
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
}
// the format follows `path` (JPEG when it names none), and `bitDepth` is checked against it
let opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
None if !app.session.selection.ids.is_empty() => {
+132 -4
View File
@@ -2191,6 +2191,54 @@ mod tests {
assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}");
}
/// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made
/// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export,
/// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept.
#[test]
fn export_dialog_drops_a_depth_the_format_lacks() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let opened = |h: &mut Headless, last: Value| {
h.app.ui.dialog = None;
h.app.session.last_export = Some(last);
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
match &h.app.ui.dialog {
Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(),
_ => panic!("the export dialog is open"),
}
};
let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16}));
assert_eq!(o.bit_depth, None);
lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export");
assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32));
}
/// Review of #1966: a `path` whose extension names no format exports JPEG here, so its
/// `bitDepth` is checked against JPEG's, not let through to an 8-bit file.
#[test]
fn export_checks_the_depth_of_the_format_a_bare_path_gets() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
for name in ["output", "output.xyz"] {
let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10});
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
assert_eq!(r["ok"], false, "{name}: {r}");
assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}");
}
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
// the format such a path gets is still JPEG
let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8});
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
assert_eq!(r["ok"], true, "{r}");
let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path();
assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG");
let _ = std::fs::remove_dir_all(&out);
}
#[test]
fn export_dialog_runs_in_the_background() {
let mut h = demo([1200.0, 900.0]);
@@ -2646,12 +2694,14 @@ mod tests {
h.request("ui.key", json!({"key": "O", "shift": true}), t);
assert_eq!(h.app.ui.crop_overlay_orient, 1);
assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping");
let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some();
assert!(!locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
// an untouched photo starts locked to its own shape (#991): A unlocks, A again locks
let locked = |h: &Headless| h.app.crop_tool_aspect().is_some();
assert!(locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
assert!(!locked(&h));
assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free")));
h.request("ui.key", json!({"key": "A"}), t);
assert!(locked(&h));
assert!(!h.app.ui.visualize_spots);
}
@@ -2833,15 +2883,56 @@ mod tests {
assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}");
assert!(h.app.session.browse.is_some(), "hiding does not stop browsing");
assert!(dir.is_dir(), "the folder itself is untouched");
// a second hidden place: the row lists them, and each comes back on its own (issue #2114:
// the row used to bring every hidden place back at once)
let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id()));
let _ = std::fs::remove_dir_all(&dir2);
std::fs::create_dir_all(&dir2).unwrap();
let path2 = dir2.to_string_lossy().to_string();
for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] {
let r = h.request("engine.execute", json!({"command": command, "params": params}), t);
assert_eq!(r["ok"], true, "{r}");
}
h.settle(SETTLE);
let menu = |h: &mut Headless| -> Vec<String> {
let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t);
w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect()
};
assert!(menu(&mut h).is_empty(), "the list opens from the row");
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.is_empty());
let items = menu(&mut h);
assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}");
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true);
h.settle(SETTLE);
assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations);
assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden");
assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore");
// two hidden again: Restore All brings every one back
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations);
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true);
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations);
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore");
// one hidden: the row brings it back itself, no list
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert!(menu(&mut h).is_empty(), "no list for a single hidden place");
assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations);
if !hidden_before {
// (with Home hidden for this test, the folder is back inside Home's tree instead)
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&dir2);
}
/// Local: a kept folder stays a root while its subfolders (and other locations) are
@@ -4385,6 +4476,43 @@ mod tests {
h.settle(SETTLE);
}
/// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the
/// loupe render is, not a preview-level render the GPU shrinks.
#[test]
fn a_hover_preview_is_fitted_to_the_drawn_size() {
use crate::render::Slot;
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.profiles"}), t);
h.settle(SETTLE);
h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main));
let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t);
assert_eq!(r["ok"], true, "{r}");
h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover"));
let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size);
let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render"));
assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}");
assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}");
}
/// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of
/// rising to the preview level.
#[test]
fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() {
use crate::render::Slot;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
h.settle(SETTLE);
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0));
h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main));
let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size;
assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}");
}
#[test]
fn trackpad_navigation_keeps_the_image_cursor_between_events() {
let mut h = demo([1000.0, 700.0]);
+38 -2
View File
@@ -149,11 +149,33 @@ impl<'de> Deserialize<'de> for Locale {
thread_local! {
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
/// The host's answer for the "Auto" language ([`set_default_language`]).
static DEFAULT_LANGUAGE: std::cell::Cell<Option<Locale>> = const { std::cell::Cell::new(None) };
}
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
/// Install the language the "Auto" setting resolves to when nothing is stored. The browser host
/// calls this once at startup with the browser's own languages ([`locale_from_tags`]); the
/// desktop host doesn't: there Auto keeps following the environment. Must run before the UI state
/// is first constructed.
pub fn set_default_language(language: Locale) {
DEFAULT_LANGUAGE.with(|slot| slot.set(Some(language)));
}
/// The first of `tags` (BCP-47, most preferred first, as `navigator.languages` lists them) that
/// names a shipped language; None when none does.
pub fn locale_from_tags(tags: &[String]) -> Option<Locale> {
tags.iter().find_map(|tag| Locale::parse_tag(tag))
}
/// The UI language "Auto" resolves to: the environment's (`LIGHTCRAFT_LANGUAGE=zh-hans`, for
/// headless runs), or the host-installed default ([`set_default_language`], the browser's languages
/// on the web), else English.
pub fn default_language() -> Locale {
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
std::env::var("LIGHTCRAFT_LANGUAGE")
.ok()
.and_then(|value| Locale::parse_tag(&value))
.or_else(|| DEFAULT_LANGUAGE.with(std::cell::Cell::get))
.unwrap_or(Locale::En)
}
pub fn set_language(language: Locale) {
@@ -636,6 +658,20 @@ mod tests {
assert_eq!(Locale::parse_tag("xx"), None);
}
/// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first
/// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English
/// ([`default_language`] fallback).
#[test]
fn locale_from_tags_takes_the_first_shipped_language() {
assert_eq!(locale_from_tags(&[]), None);
assert_eq!(locale_from_tags(&["xx-XX".into()]), None);
assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It));
assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De));
assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant));
assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja));
assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr));
}
#[test]
fn switching_language_translates_and_restores() {
set_language(Locale::ZhHans);
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more