Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 23e181283c Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:06:09 -04:00
Dylan M. Taylor beb044bf35 Merge pull request #2494 from storytold/sonar-fix/path-cmd-hardening
security: validate CLI paths/args in dev tools (SonarCloud S2083/S8707/S8705)
2026-10-22 19:27:49 -04:00
Dylan M. Taylor ac2d7623e3 security: tighten readable/regular file checks in CLI tools 2026-10-22 19:18:41 -04:00
Brandon ThomasandClaude Opus 5.5 479a05a0be Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:39:28 -04:00
BFlat c66014e65f Merge pull request #2548 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:01 +07:00
bflatastic c810a51047 README: the app icon above the title 2026-10-22 17:58:01 +07:00
aiongg 31ee2643cf Merge PR #2515: integration of 85 reviewed PRs (round 2) 2026-10-22 00:25:29 -04:00
aiongg 3dd1b88ff9 Merge PR #2349: the sidebar trees narrow their indent step before names are cut (test follow-ups) 2026-10-22 03:45:54 +00:00
aiongg 1847cbd3e3 Integration: #1997 test picks the 3:4 crop by its named ratio, which follows the photo's orientation 2026-10-22 03:28:46 +00:00
aiongg 04275ce9da Integration: #1484 vi format messages use → instead of the unpaintable ▸ 2026-10-22 03:28:46 +00:00
helgehelge123andClaude Opus 5.5 eb7513c5d2 LRC-LIB-LIBFOLDERS: deep_folders_app waits for each row before opening it
On the FreeBSD runner a click's synthetic input can arrive a frame or two late, so after opening
`/a/b/c/d` the row of `/a/b/c/d/e` was not drawn yet when the next click asked for it. The
fixture now steps until each level's chevron is drawn before clicking it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:10:04 +02:00
aiongg c25f7a238f Integration: list Bahasa Indonesia and Tiếng Việt in the README's interface languages 2026-10-22 03:00:44 +00:00
aiongg bf96159526 Integration: regenerate the parity checklist summary 2026-10-22 02:59:01 +00:00
aiongg ec264876fc Integration: English text for the folder-listing message in the fi, id, sv and vi format catalogues 2026-10-22 02:57:56 +00:00
aiongg 8daa25c4c9 Merge track r2-t04 into next
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
#	docs/whats-new.md
2026-10-22 02:53:42 +00:00
aiongg 727e66babe Merge track r2-t03 into next
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
#	docs/whats-new.md
2026-10-22 02:53:00 +00:00
aiongg 1f2f94cd4f Integration: #1855 #2172 #2295 #1484 English text for format messages the new catalogs lack
build.rs requires every *-formats.json to carry the same messages. The sv (7), fi (1),
id (1) and vi (49) format catalogs predate messages main has added; those entries use the
English text until they are translated.
2026-10-22 02:51:28 +00:00
aiongg 3300e6dad8 Merge track r2-t01 into next
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw/coverage.tsv
#	docs/whats-new.md
2026-10-22 02:49:52 +00:00
aiongg 5991f9de61 Integration: #1809 smart preview SourceInfo takes native_long (None; the catalog fills it) 2026-10-22 02:47:31 +00:00
aiongg d26c11a676 Merge track r2-t02 into next
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-22 02:42:47 +00:00
aiongg fa6f5b285b Integration: one LOOK_VERSION (12) and RENDER_CACHE_VERSION (69) bump for the merged camera-look changes 2026-10-22 02:42:38 +00:00
aiongg e705eb563c Merge PR #2380: LR-EDIT-DETAIL-DENOISE: GPU denoise gives the right result on DX12 with NVIDIA Pascal 2026-10-22 02:42:06 +00:00
aiongg 93c55d7c89 Merge PR #2041: fix(camera-profiles): invalidate cached renders after subsecond profile changes 2026-10-22 02:42:06 +00:00
aiongg 731b511295 Merge PR #1985: Imported LUTs no longer leave a copy in the temp folder without a library on disk 2026-10-22 02:42:06 +00:00
aiongg 17b9bdefc7 Merge PR #1984: GPU: a lost device is not polled again, and a poll that panics fails the render (#1858) 2026-10-22 02:42:06 +00:00
aiongg 12c096669a Merge PR #1304: RW2: decode tiled format 8 (DC-S1RM2 high-resolution mode) (#1276) 2026-10-22 02:42:05 +00:00
aiongg 70e9338085 Merge PR #1040: LR-IMP-FORMATS: decode Minolta MRW
docs/parity-checklist.md: header keeps the newer date; LR-IMP-FORMATS row keeps both sides' changes.
docs/raw-parity.md: header change note names both sides' changes.
docs/raw/coverage.tsv: merged cleanly; same rows as both sides, no duplicates (regenerated on the combined branch).
2026-10-22 02:42:05 +00:00
aiongg 3ea9db95f5 Merge track r2-t05 into next 2026-10-22 02:41:40 +00:00
aiongg cd3e6941e7 Integration: #2054 one parity-checklist header line
The PR's branch carried a second header line; its change note joins the existing one.
2026-10-22 02:41:38 +00:00
aiongg 2fc56a5374 Merge PR #2441: feat(ui): add "Show in Folders" to the photo menu (#2437)
docs/parity-checklist.md: header and revision rows from both sides; generated summary line left for cargo xtask parity --write
2026-10-22 02:41:14 +00:00
aiongg 91744336d0 Merge PR #2006: LR-EDIT-COLOR-TEMP: Kelvin white balance for the Nikon Z5II from built-in presets
crates/raw/src/kelvin_presets.rs: covered_models keeps the Z5II asserts (#2006) and the Canon cases (#2009).
docs/parity-checklist.md: LR-EDIT-COLOR-TEMP row keeps both sides' preset clauses.
2026-10-22 02:41:12 +00:00
aiongg b1d36f6b36 Merge PR #2417: Carry rounded GPS minutes into degrees in XMP writer 2026-10-22 02:41:08 +00:00
aiongg dbf4b2b428 Merge PR #2409: Drop non-finite elevation values in the GPX parser 2026-10-22 02:41:08 +00:00
aiongg 1e99e2e1d9 Merge PR #2386: The Windows x64 installer is verified on Windows 11
Conflicts:
- docs/gaps.md: header keeps both changes; revision rows are the union
- docs/target-app-parity.md: revision rows are the union
2026-10-22 02:41:08 +00:00
aiongg 136330a0a5 Merge PR #2385: Smart album rules: + and − never cover a rule, and hovering a field doesn't wrap it 2026-10-22 02:40:59 +00:00
aiongg d598092f6a Merge PR #2349: LRC-LIB-LIBFOLDERS: the sidebar trees narrow their indent step before names are cut (#2298)
docs/parity-checklist.md: header and revision rows from both sides
2026-10-22 02:40:59 +00:00
aiongg b05bb535e6 Merge PR #2271: Analyse a photo listed twice once in Assisted Culling 2026-10-22 02:40:53 +00:00
aiongg 61ad2ff853 Merge PR #2054: LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched
crates/ui-egui/src/tests_grid.rs: both sides' tests

docs/whats-new.md: both entries under one 2026-10-16 heading

docs/parity-checklist.md: header and revision rows from both sides
2026-10-22 02:40:53 +00:00
aiongg 6e7d733ab5 Merge PR #2309: Fix trailing dot in All Metadata rational formatter 2026-10-22 02:40:48 +00:00
aiongg aea654b59a Merge PR #2257: LR-META-XMP: fewer false "XMP adjustments were not imported" warnings for raws edited elsewhere
Conflicts:
- docs/parity-checklist.md: LR-META-XMP row carries both changes (JPEG embedded XMP, bookkeeping crs fields)
2026-10-22 02:40:48 +00:00
aiongg d158d75b59 Merge PR #1917: KEYC-RATING: backslash shows and hides the Filter Bar in the grids
docs/parity-checklist.md: header and revision rows from both sides

docs/ui-parity.md: header keeps the newer dates and both change notes
2026-10-22 02:40:33 +00:00
aiongg 9e80884add Merge PR #2106: docs: add Traditional Chinese (Taiwan) README 2026-10-22 02:40:30 +00:00
aiongg 5acc4dead6 Merge PR #2066: LR-BEHAV-LOCALIZE: German catalog filled, 217 interface strings and 862 What's New lines (80% → 97%)
Conflicts:
- docs/localization-parity.md: header keeps the newer date and both changes
2026-10-22 02:40:30 +00:00
aiongg 2959b3a7e8 Merge PR #2009: Kelvin white balance from Canon CR2 ColorData presets (PowerShot G1 X Mark III)
docs/raw-parity.md: header change note and revision rows keep both sides (newest first).
2026-10-22 02:40:28 +00:00
aiongg 3ec8b08ad8 Merge PR #2042: LR-META-XMP: reread embedded JPEG metadata
Conflicts:
- crates/engine/src/sidecar.rs: imports keep HashSet and add Read, Seek, SeekFrom
- docs/parity-checklist.md: LR-META-XMP and LR-META-EXIF-FULL rows carry both changes (import stem ranking, JPEG embedded XMP)
- docs/xmp-interop.md: Read row carries both changes
2026-10-22 02:40:19 +00:00
aiongg 792b2517e5 Merge PR #1468: Issue #1449: Search Photos finds named people, and quotes keep a name together (steps 1 and 2) 2026-10-22 02:40:15 +00:00
aiongg 8dec1e1183 Merge PR #2444: LR-EDIT-OPTICS-PROFILE: read Fujifilm's nine-knot lens tables at the plain radius
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
2026-10-22 02:40:15 +00:00
aiongg df319373cc Merge PR #2450: LRC-LIB-COMPARE: the Info panel labels the active photo while culling…
crates/ui-egui/src/tests_compare.rs: both sides' tests

docs/parity-checklist.md: header and revision rows from both sides
2026-10-22 02:40:11 +00:00
aiongg 04b099a4f5 Merge PR #1901: LR-EDIT-OPTICS-PROFILE: apply A7CR embedded distortion for corrected full-frame raws
crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
docs/whats-new.md: both 2026-10-14 entries under one heading.
2026-10-22 02:40:09 +00:00
aiongg d0583f77ee Merge PR #1173: LRC-LIB-REFVIEW: fix Escape and catalog-local reference lifecycle
docs/parity-checklist.md: header and revision rows from both sides
2026-10-22 02:39:58 +00:00
aiongg 055362cb1d Merge PR #2481: LR-MERGE-HDR: merge warps sample Lanczos-3 instead of bilinear (1/3 of #1887)
docs/parity-checklist.md: header keeps 2026-10-21 and lists both sides' change notes
2026-10-22 02:39:55 +00:00
aiongg 195340da77 Merge PR #2263: LR-IMP-PREVIEW-READ: thumbnails of merged HDR/panorama DNGs develop from their reduced raw copies (38 s → 1 s)
crates/engine/src/media.rs, crates/engine/src/smart.rs: DecodedSource keeps both new fields, from_proxy (#1809) and reduced (#2263); a smart preview loads with from_proxy true, reduced false.
crates/engine/src/cmd/previews.rs: original_only refuses both proxy-loaded and reduced sources.
2026-10-22 02:39:46 +00:00
aiongg 29565bbad0 Merge PR #1861: LR-EXP-DIM: Width & Height limits each edge to its own number (#1620)
Conflicts:
- docs/parity-checklist.md: header keeps the newer date; revision rows are the union
2026-10-22 02:39:44 +00:00
aiongg c61ad57601 Merge PR #2425: LR-IMP-ADD-DIALOG: preserve album hierarchy in Import Photos
docs/parity-checklist.md: header keeps 2026-10-21 and lists both sides' change notes
2026-10-22 02:39:44 +00:00
aiongg fdb93026b4 Merge PR #2456: Fix/strict ids params 2026-10-22 02:39:38 +00:00
aiongg 5cd029a40c Merge PR #1628: #1617: placeholder GPS block (NUL refs, 0/0 rationals) reads as no location 2026-10-22 02:39:34 +00:00
aiongg b81b6d9f3d Merge PR #2300: LR-LIB-STACK: the filmstrip stack badge expands/collapses the stack; badges work on unselected stacks
crates/ui-egui/src/panels/grid.rs: badge keeps main's double-click guard and calls #2300's toggle_stack

crates/ui-egui/src/tests_grid.rs: both sides' badge tests

docs/parity-checklist.md: header and revision rows from both sides; rows take both changes
2026-10-22 02:39:32 +00:00
aiongg f460cbd90c Merge PR #2449: Settings and Edit ▸ Language: list the languages alphabetically by their own name
Conflicts:
- crates/ui-egui/src/menus.rs: LANGUAGE_COMMANDS lists every language, including sv, fi, id and vi, in Locale::for_picker order
2026-10-22 02:39:29 +00:00
aiongg 65ec95997e Merge PR #2296: test(ui): wait for menu registration and visible sync rows 2026-10-22 02:39:27 +00:00
aiongg 45c590757b Merge PR #2024: LR-IMP-LOCAL: Opening on Missing Photos no longer checks every original 2026-10-22 02:39:26 +00:00
aiongg d586360401 Merge PR #1959: Photo Merge: Merge a photo named twice only once (#1947) 2026-10-22 02:39:26 +00:00
aiongg 0e59398375 Integration: #1746 whats-new entry under the existing 2026-10-14 heading, without a duplicate heading 2026-10-22 02:39:25 +00:00
aiongg 758f2e2f21 Merge PR #671: LR-IMP-FORMATS: decode table-less Nikon 34713 NEFs stored as packed 12/14-bit data
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
docs/parity-checklist.md: LR-IMP-FORMATS row keeps both sides' changes.
2026-10-22 02:39:20 +00:00
aiongg b4df27022f Merge PR #1746: LR-IMP-LOCAL: Record folders browsed from the sidebar as browsed 2026-10-22 02:39:13 +00:00
aiongg 2d32b263ba Merge PR #1450: Add a security policy 2026-10-22 02:39:13 +00:00
aiongg a104372c92 Integration: #1165 Sony maker row counts match coverage.tsv (94 / 43 / 10 / 2) 2026-10-22 02:39:09 +00:00
aiongg 91c465a29c Merge PR #1165: LR-IMP-FORMATS: folder import finds Sony SR2 and SRF files
docs/parity-checklist.md: LR-IMP-FORMATS row keeps main's DNG/Hasselblad text and adds #1165's SR2/SRF phrase; revision rows are the union
docs/raw-parity.md, docs/gaps.md: revision rows are the union
2026-10-22 02:39:08 +00:00
aiongg 9d9df1cbe2 Integration: #2304 use Renderer.requests from #1913 in place of request_ids 2026-10-22 02:39:00 +00:00
aiongg 90eee2208f Integration: #1258 restore the pipeline local tests the PR head truncated
Drops the unfinished nr_contrast_keeps_fine_texture test and restores exposure_doubles and
fast_tests::fast_guided_close_to_reference; keeps the hidden/empty mask test.
2026-10-22 02:38:57 +00:00
aiongg 5aab69a059 Integration: #1484 report Vietnamese coverage gaps, list vi in the keyword-label test, attribute the vi catalogs
The vi coverage test reports missing catalog messages and display labels instead of
failing on them, like the Italian and Finnish tests; the English-fallback check stays.
2026-10-22 02:38:50 +00:00
aiongg 0c659d5576 Merge PR #2304: LR-VIEW-PHOTOGRID: preserve embedded previews through priority changes and Undo 2026-10-22 02:38:46 +00:00
aiongg 72c34a1673 Merge PR #1913: fix(render): identify source files in render-failure logs 2026-10-22 02:38:45 +00:00
aiongg bf2f3df0ac Merge PR #1809: engine: keep the decoder facts a smart preview's offline edits need
crates/engine/src/cmd/previews.rs: the opening refresh takes every photo on disk (#1809) with the preview_fitted_look flag of #1545 (vendor raws and DNGs); SmartJob and refresh docs name both.
2026-10-22 02:38:42 +00:00
aiongg 27f608ab03 Merge PR #2499: LR-VIEW-ZOOM: a pinch holds the loupe's fit size, so it no longer renders at every zoom step
docs/parity-checklist.md: header from HEAD; both revision rows.
docs/whats-new.md: both entries under 2026-10-21.
2026-10-22 02:38:42 +00:00
aiongg a4335731aa Merge PR #1965: LRC-DEV-VIEWOPTIONS: View Options (Cmd+J) chooses the lines of the loupe's Info 1 and Info 2
crates/ui-egui/src/panels/detail.rs: #2317's split (info_overlay / info_overlay_lines / info_overlay_extent) kept; info_overlay_lines builds its lines from #1965's View Options fields (info_line) and returns None when off or when no line is left

crates/ui-egui/src/panels/dialogs.rs: only_shows keeps Dialog::ViewOptions and the up-to-date Synchronize Folder case

crates/ui-egui/locales/it.json: union of keys
2026-10-22 02:38:41 +00:00
aiongg 05f31758fd Merge PR #1484: Add Vietnamese interface translation
Conflicts:
- crates/ui-egui/src/i18n.rs, crates/ui-egui/src/menus.rs: language table and commands keep every language plus vi
- docs/localization.md: language list, note links and table excerpt keep every language plus vi
- docs/localization-parity.md: header keeps the newer date and both changes; the newer per-language measurements are kept, Vietnamese row from the PR; links and revision rows are the union
2026-10-22 02:38:28 +00:00
aiongg d27b56447b Merge PR #2454: KEYC-PANELS: F8 shows and hides the right panel
docs/parity-checklist.md: header and generated summary from HEAD (summary is regenerated later); both revision rows, newest first.
2026-10-22 02:38:23 +00:00
aiongg 2ba2c38d3f Merge PR #2431: Keep file name and query for fragment-only redirect Location 2026-10-22 02:38:11 +00:00
aiongg f0951e819c Merge PR #2410: Match URL schemes case-insensitively in Url::parse and Url::join
crates/fetch/src/http.rs: keeps #2406's remove_dot_segments and #2410's strip_scheme helpers; tests keep all three PRs' cases
2026-10-22 02:38:10 +00:00
aiongg fd2503f125 Merge PR #2451: LR-MASK-OVERLAY: a new or picked mask shows its overlay again (#1800)
docs/parity-checklist.md: newer header (2026-10-21); both revision rows.
2026-10-22 02:38:08 +00:00
aiongg bb930900af Merge PR #1545: LR-RAW-DNG: preserve lens corrections in the existing DNG tone-fit proxy
crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
2026-10-22 02:38:07 +00:00
aiongg 4ba72fa0eb Merge PR #2420: PERF-SMART: File Path rules compare in place instead of building strings per photo
docs/whats-new.md: both entries, newest first.
2026-10-22 02:38:00 +00:00
aiongg 4438e2bb64 Merge PR #2406: Remove dot segments when resolving redirect Locations
crates/fetch/src/http.rs: tests keep #2404's join_query_only_keeps_file_name and #2406's join_removes_dot_segments
2026-10-22 02:37:54 +00:00
aiongg 0068d9040b Merge PR #2295: i18n: Indonesian (id) interface language
Conflicts:
- crates/ui-egui/src/i18n.rs, crates/ui-egui/src/menus.rs: language table and commands keep sv, fi and id
- crates/ui-egui/src/panels/keyword_list.rs: code list gains id; Locale::ALL.len() is 17
- docs/localization.md: language list and note links keep every language
- docs/localization-parity.md: header keeps the newer date and both changes; links and revision rows are the union
2026-10-22 02:37:53 +00:00
aiongg 53ce89fc11 Integration: #1616 move the overcast-scene doc comment back to its test 2026-10-22 02:37:51 +00:00
aiongg 73c61cf655 Merge PR #2400: Crop: the spiral guide is complete at every aspect ratio 2026-10-22 02:37:50 +00:00
aiongg eee3b799fe Merge PR #2371: #2365: centre the search field on the window, not inside the bar's margins 2026-10-22 02:37:49 +00:00
aiongg fbc927f0b1 Merge PR #2347: feat(guided): Guided Upright guides select, start on the press and drag their endpoints
docs/parity-checklist.md: both revision rows.
2026-10-22 02:37:49 +00:00
aiongg 8bd9a3af1e Merge PR #1616: LR-PROF-CAMERACOLOR: near-black frames get a tone curve when no colour look fits (#1587)
crates/engine/src/camera_preview.rs: LOOK_VERSION keeps the higher value; fit_tone_only_pairs (from #1616) takes the HighlightCheck of #699 from fit_tone_only_on, and fit_dark_tone passes its frame's (empty, as no JPEG pixel reaches 0.85) check.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
docs/parity-checklist.md: LR-PROF-CAMERACOLOR row keeps both sides' changes.
2026-10-22 02:37:46 +00:00
aiongg feaf1aa5bb Merge PR #2317: #2267: the Before label stays in view when zoomed in (stacked on #2274)
crates/ui-egui/src/tests_zoom.rs: both new Before-label tests, with #1968's comment wording on the crop test

docs/whats-new.md: both entries under 2026-10-19
2026-10-22 02:37:40 +00:00
aiongg 4de80f3ef9 Merge PR #2306: LR-CROP-RECT: a moved crop slides along a straightened photo's tilted edges (#2299) 2026-10-22 02:37:36 +00:00
aiongg cb8115b239 Merge PR #2217: LR-EDIT-OPTICS-PROFILE: look up Nikon DSLR lenses by their lens specification
docs/parity-checklist.md: newer header (2026-10-21); both revision rows.
2026-10-22 02:37:35 +00:00
aiongg 2aae31ecb4 Merge PR #2404: Keep file name when a redirect Location is query-only 2026-10-22 02:37:33 +00:00
aiongg 551defa04e Merge PR #2336: Treat Unicode minus and dash characters as negative in parse_gps
crates/engine/src/cmd/library.rs: gps_tests keeps both PRs' asserts
2026-10-22 02:37:33 +00:00
aiongg d8b2eb60d3 Merge PR #2172: i18n: add Finnish (fi) interface language
Conflicts:
- README.md, docs/localization.md: language lists keep every language, Finnish appended
- assets/ATTRIBUTION.md: union of rows
- crates/ui-egui/src/i18n.rs, crates/ui-egui/src/menus.rs: language table and commands keep sv and fi
- crates/ui-egui/src/panels/keyword_list.rs: code list gains fi; Locale::ALL.len() is 16
- docs/localization-parity.md: header keeps the newer date and both changes; revision rows are the union
2026-10-22 02:37:29 +00:00
aiongg 2965161505 Merge PR #1997: KEY-CROP-SWAP: X in the crop tool swaps the aspect once (#1866) 2026-10-22 02:37:24 +00:00
aiongg 590c17a8f3 Merge PR #1977: GUM-3203: Stress-test previews against full-size renders 2026-10-22 02:37:23 +00:00
aiongg b242cd6bfc Merge PR #1972: Tone: neutral display tone map is the identity up to white (#1945) 2026-10-22 02:37:23 +00:00
aiongg f8f68fa8b2 Merge PR #2341: Fix parse_gps for hemisphere-letter-first coordinates without comma 2026-10-22 02:37:22 +00:00
aiongg bb18036420 Merge PR #1909: docs(support): explain the Remove tool workflow (#1756) 2026-10-22 02:37:22 +00:00
aiongg b1c4cb7df9 Merge PR #1258: Ignore hidden and empty masks when choosing develop planes. 2026-10-22 02:37:22 +00:00
aiongg fb316ee5ea Merge PR #1402: Fix slider wheel edits after leaving and returning to the Edit panel
crates/ui-egui/src/widgets/tests_numeric_entry.rs: keep both sides' tests.
docs/parity-checklist.md: newer header (2026-10-21); KEY-SLIDER-NUDGE from #1402, KEY-SLIDER-TYPE from #1687; both revision rows.
2026-10-22 02:37:21 +00:00
aiongg 51bf538f27 Merge PR #1968: crop.set refuses a crop smaller than the crop tool allows, instead of exporting 1×1 2026-10-22 02:37:19 +00:00
aiongg 166380630b Merge PR #2366: LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date, with one Close
docs/parity-checklist.md: header and revision rows from both sides; LRC-LIB-LIBFOLDERS row takes both wording changes
2026-10-22 02:37:19 +00:00
aiongg 67d6e1879f Merge PR #1991: Control port: don't clone the connection's socket (Windows: launched programs kept it open)
apps/lightcraft/src/control_server.rs: serve() keeps #2027's idle read timeout and #1991's single-stream serve_lines (no try_clone); #1991's Windows test passes IDLE_TIMEOUT
2026-10-22 02:37:18 +00:00
aiongg cf804ee9f6 Merge PR #699: LR-PROF-CAMERACOLOR: fit the tone curve of low-contrast scenes (range limit 1.5 to 1.2)
crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
2026-10-22 02:37:09 +00:00
aiongg 4764767694 Merge PR #1855: i18n: Swedish (sv) interface translation
Conflicts:
- README.md, docs/localization.md: language lists keep every language, Swedish appended
- assets/ATTRIBUTION.md: union of rows
- crates/ui-egui/src/i18n.rs: language table and command test list keep it, ka and sv; both tests kept
- crates/ui-egui/src/menus.rs: language commands keep it, ka and sv
- crates/ui-egui/src/panels/keyword_list.rs: code list gains sv; Locale::ALL.len() is 15
2026-10-22 02:37:05 +00:00
aiongg 92c90ad743 Merge PR #1687: KEY-SLIDER-TYPE: a long click keeps the value field open; drag the value to scrub 2026-10-22 02:36:59 +00:00
aiongg 08f9334457 Merge PR #2452: LR-PRE-PANEL: the applied preset stays marked on returning to the pho… 2026-10-22 02:36:59 +00:00
aiongg a22f447523 Merge PR #1921: Context menus: a right-click elsewhere on the same widget moves its o… 2026-10-22 02:36:58 +00:00
aiongg f4dd312eac Merge PR #2177: LRC-DEV-AUTOSYNC: sync HDR state while retaining per-photo tuning
crates/engine/src/lib.rs: keep both explicit comparison leaves (camera_local_tone and hdr).
crates/engine/src/tests.rs: keep both sides' tests.
2026-10-22 02:36:58 +00:00
aiongg 04e20de345 Merge PR #2027: #1952: expire idle control connections
contributors/people.toml: union of new people entries
2026-10-22 02:36:51 +00:00
aiongg 81f304a867 Merge PR #2208: LRC-DEV-AUTOSYNC: sync camera local tone Off 2026-10-22 02:36:40 +00:00
aiongg 214c595794 Merge PR #2049: LRC-LIB-LIBFOLDERS: Synchronize Folder says it is listing the folder instead of sitting at "0 of N" 2026-10-22 02:36:15 +00:00
helgehelge123andClaude Opus 5.5 e07c2bd44e LRC-LIB-LIBFOLDERS: the deep-folder tests put a photo in every level (ready for #1243)
The two deep-chain tests now build their library with `deep_folders_app`, as #1243 does: every
level holds a photo, so it has a row of its own and starts closed, and the test opens it. They
pass the same whether or not the sidebar passes over folders that only lead on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:31:08 +02:00
Nathaniel Ledford 0e08318bad LR-VIEW-ZOOM: a pinch holds the loupe's fit size, so it no longer renders at every zoom step 2026-10-21 19:50:28 -04:00
Dylan M. Taylor bfb23db2ca Use chrono instead of hand-rolled UTC conversion (#2457)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month table with chrono as a workspace dependency:
- apps/lightcraft/src/logging.rs: timestamp (log line time)
- crates/catalog/src/dates.rs: civil + weekday
- crates/catalog/src/stacks.rs: iso_seconds (date -> seconds)
- crates/engine/src/cmd/face_recognize.rs: capture_secs (native only)
- crates/meta/src/datetime.rs: DateTime::unix_seconds and the valid() day check
Output formats and semantics are unchanged (pre-epoch clamp, day rollover that
normalize_iso relies on, leap-second tolerance, EXIF/XMP parsing). chrono uses
only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock as the epoch, keeping the old pre-epoch clamp.

timestamp and catalog's civil write years past 9999 with {:04} like the old
{y:04} did, because %Y would add a '+'. Tests cover the out-of-range and
5-digit-year cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01, changing output the old hand-rolled
Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was
318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).

- apps/lightcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/catalog/src/dates.rs: `civil` cycle-based (the callers stay inside
  0000-9999, but a far clock no longer resets to the epoch).

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  civil 10_000_000_000_000 -> 318857-05-20T17:46:40,
        -10_000_000_000_000 -> -314918-08-13T06:13:20, i64 extremes

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:36 -04:00
Dylan M. Taylor d79b1f33e3 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#2493)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:35:59 -04:00
Bruno-Alumn f32116bee1 Merge remote-tracking branch 'upstream/main' into fix/strict-ids-params
# Conflicts:
#	crates/engine/src/cmd/mask_effects.rs
#	crates/engine/src/cmd/masks.rs
#	crates/ui-egui/src/control.rs
2026-10-21 22:48:03 +00:00
Bruno-Alumn d15fc0a6ce Merge remote-tracking branch 'upstream/main' into fix/f8-right-panel
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 22:42:18 +00:00
Bruno-Alumn c96af686a1 Merge remote-tracking branch 'upstream/main' into fix/preset-mark-per-photo
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 22:42:18 +00:00
Bruno-Alumn c733daf1c2 Merge remote-tracking branch 'upstream/main' into fix/cull-color-label
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 22:42:17 +00:00
Bruno-Alumn 731b461d7d Merge remote-tracking branch 'upstream/main' into fix/mask-overlay-reshow
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 22:42:17 +00:00
chordpli c28a537a15 LR-CROP-RECT: a crop a handle drag filled to the tilted edges still moves (#2299)
The E2E showed the previous fix missed the common case: enlarging a locked
crop until it stops leaves a frame that fills the photo's height, with its
top and bottom both at the edge of is_within_image's 1e-6 tolerance. No
translation keeps both there with room to spare, so the move stayed stuck.

When the frame can't move as it is, solve again with it shrunk about its
centre by the least that puts it strictly inside (at most 1e-5 of its size,
aspect unchanged). This also covers a frame at the edge on one side, so the
half-tolerance first pass is dropped and a click moves nothing again.
2026-10-22 07:34:48 +09:00
chordpli 89a35ded91 LR-CROP-RECT: a crop a handle drag left at a tilted edge still moves (#2299)
A handle drag stops where is_within_image's 1e-6 tolerance ends, so a
corner can sit right at it. The move solve then let that corner go 1e-9
further, the result failed the check, and the across-then-down fallback
left the frame stuck. Solve first with every corner kept within half the
tolerance, and only then where it is.
2026-10-22 07:34:48 +09:00
chordpli f443bed56f LR-CROP-RECT: trim the crop move comments (#2299) 2026-10-22 07:34:48 +09:00
chordpli 4bd40cda60 LR-CROP-RECT: a crop just past a tilted edge still moves, and a click leaves it be (#2299)
A handle drag can leave the frame up to is_within_image's tolerance past a
tilted edge. The move's bounds were tighter than that, so such a frame was
stuck again, and a click inside it moved it. The bounds are now never tighter
than where the frame is. A test pins how a smaller frame pushed past a tilted
edge slides along it, and the parity checklist's revision history notes the fix.
2026-10-22 07:34:48 +09:00
tylerphotosandCodex 4245d50f2f LR-IMP-ADD-DIALOG: reconcile album picker with integrated main (#2425)
Retain both sets of parity revision notes after the upstream integration.
Keep the hierarchical picker and deleted-destination guard unchanged.

Co-Authored-By: Codex <[email protected]>
2026-10-21 17:34:40 -05:00
chordpli d63ce8e5f8 LR-CROP-RECT: a moved crop slides along a straightened photo's tilted edges (#2299)
On a straightened photo, a crop that fills the photo's height could not be
moved sideways: the move went across and then down, and no straight sideways
step stays inside the turned photo. The frame now goes to the place nearest
the pointer's that keeps it inside, so it slides along the tilted edges to the
photo's side. Unstraightened, that place is the old across-then-down clamp.
2026-10-22 07:34:28 +09:00
JackalCOBandClaude Opus 5.5 4771ae0ea5 LR-EDIT-OPTICS-PROFILE: read Fujifilm's nine-knot lens tables at the plain radius
Fujifilm's nine-knot distortion and vignetting tables (knots at sqrt(k/8))
end at the corner on the eighth knot; the ninth, sqrt(9/8), lies past it
with the eighth's value repeated. Reading them at rho = knot_last * r took
every value from 6 % further out. On the X-T4 the warp polynomial then
failed its fit checks on 253 of 425 zoom-lens raws, which got no
distortion correction at all, and corners came out up to 6.5 % brighter
than the camera's. Read at rho = r, the tables match the camera's measured
geometry and corner gain (docs/fuji-lens-corrections.md, "Knot radius").
Nothing changes for the 11-knot and 8-knot tables, whose last knot is 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:27:49 +13:00
Dylan M. Taylor 3a9b35c687 ci: exclude dev tool scripts from SonarCloud coverage gate
tools/ and docs/ scripts aren't unit-tested; exclude them from coverage so the
new-code coverage gate doesn't block hardening-only changes.
2026-10-21 17:31:18 -04:00
Dylan M. Taylor 68d5118a9d security: validate CLI paths/args in dev tools (SonarCloud S2083/S8707/S8705/S7044)
Resolve and validate command-line path arguments before use, enforce HTTPS at
download sinks, and confirm path containment. Hardens local codegen/fetch tools
against path-traversal and command-argument-injection findings.
2026-10-21 17:13:05 -04:00
Claude 55a094475d Merge main into fix/dx12-nn-denoise
The only conflict was the 2026-10-19 whats-new entries; both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K93HTyCiwYvbRGaAkjPvXn
2026-10-21 13:01:31 -07:00
Claude bfe3ab3d58 #2267: the Before label sits below the info overlay too
Review follow-up (CodeRabbit): zoomed in, the badge moves to the canvas'
top-left corner, where the info overlay draws its lines; with the overlay
on, the label was painted under its first line. The badge's top inset
now takes the overlay's extent as well as the filter pill's. The overlay
is split into its lines and an extent helper, laid out with the same
fonts, so the two can't disagree.

Test: zoomed_in_the_before_label_keeps_clear_of_the_info_overlay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-21 19:50:05 +00:00
Haocheng FangandClaude Opus 5.5 33585c93ef LRC-LIB-REFVIEW: release both Reference panes when the pin is not live
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:40:24 -05:00
Haocheng FangandClaude Opus 5.5 977bbebcdc KEY-SLIDER-NUDGE: a drag that leaves the row disarms wheel edits
The disarm waited for the button to be released outside the row, so a
drag that went out and came back kept the wheel armed. Leaving disarms
now even mid-drag, and a drag that starts outside the row does not arm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:40:04 -05:00
Haocheng FangandClaude Opus 5.5 bb5d68978b LRC-LIB-REFVIEW: refuse malformed and deleted references clearly; release a dead pane
Review follow-ups for the Reference lifecycle fix:
- photo.setReference refuses an id that is not a photo id instead of
  pinning the active photo; only an absent or null id means the active one.
- A Recently Deleted photo is refused with a message that says to restore
  it (setReference and view.reference); the grid menu item is disabled for
  it and any refusal is shown as a toast, not dropped.
- Reference view with no live pin releases its panes (windows, region and
  pane textures, full capacity) like other views.
- A successful library switch leaves Reference for the grid; the Compare
  pair is now cleared only on success, like the reference pin.
- One compare::live helper replaces four copies of the "exists and not
  deleted" check.
- whats-new entry moved to 2026-10-21; checklist and ui-parity dates bumped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:31:05 -05:00
Haocheng FangandClaude Opus 5.5 868de3a9fe KEY-SLIDER-NUDGE: disarm the wheel on any move out of the row (#1355)
Review follow-ups:
- Check every PointerMoved / PointerGone of the frame, not only where the
  pointer ended: a slow frame merged leaving the row and coming back, so
  the slider stayed armed (new test for both events).
- One global armed slider (`wheel_slider_key`) instead of a bool per
  slider: unarmed sliders no longer write context data every frame, and
  arming another slider disarms the previous one. The wheel remainder is
  cleared on arming and disarming, so the second focus check is gone.
- The sideways branch uses `pointer_in_row` too.
- Document that Tab focus arms the arrows only; move the whats-new entry
  to 2026-10-21; update the KEY-SLIDER-NUDGE row, header and history; fix
  a test comment that described the old remainder carry-over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:30:32 -05:00
Nathaniel Ledford 9116cc1615 docs: one 2026-10-19 heading in What's New 2026-10-21 14:29:53 -04:00
Haocheng FangandClaude Opus 5.5 6318364ce7 #323: keep the default_is_automatic regression test
The PR branch dropped it along with the PREVIEW_LIMITS import; nothing
here changes the default preview limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:16:29 -05:00
Nathaniel LedfordandClaude Opus 5.5 84dbd1b15a PERF-SMART: File Path rules compare in place instead of building strings per photo
An ASCII path and rule text are compared byte by byte, folding case and
backslashes, with no allocation; other text takes the old code unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 14:07:51 -04:00
Matthew James SivakandClaude Opus 5.5 85ceee31a7 Merge main into import-crs-false-warnings (conflicts resolved)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:57:58 +02:00
Matthew James SivakandClaude Opus 5.5 158923de1c Merge main into jxl-preview-thumbs (conflicts resolved)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:57:55 +02:00
Michael Anderson e93f1174c3 Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 12:40:16 -05:00
skybluu ad0b27c036 LRC-LIB-REFVIEW: fix Escape and catalog-local reference pins 2026-10-21 12:35:33 -05:00
Haocheng Fang f8978a0b93 Fix #1355: disarm slider wheel edits after pointer leaves 2026-10-21 12:34:49 -05:00
Claude 1df76a12da Merge upstream main into fix/issue-2267-before-label-in-view
#2274 landed with #2470; What's New keeps both entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-21 17:25:17 +00:00
Matthew James Sivak b4f387d311 Merge remote-tracking branch 'origin/main' into merge-lanczos-warps
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-21 19:19:38 +02:00
Nathaniel LedfordandClaude Opus 5.5 53ee38431d LR-VIEW-PHOTOGRID: follow test over five seeds; comment the mode switch and the names
The random-edit test now runs five seeds instead of one. A grid-mode change
re-reading every photo, and the group names growing unpruned, are now said in
the comments (the "a day or two at most" claim was wrong for capture-time edits).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:39 -04:00
Nathaniel LedfordandClaude d184b3ef50 LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched
Every catalog change gave the grid a new list of photos, and it read every
photo of it again: each one's date group (a lookup, a date parse and a
string) and its cell's shape. At 180,000 photos that was a 100 ms frame for
every batch an import added.

- catalog: Catalog::date_runs is split into date_group (one photo's own
  group) and date_runs_of (the stack rule and the runs), which the grid
  uses too.
- ui: GridCache keeps what it read of each photo of the list, position by
  position. A new list that differs by the photos the catalog says an edit
  touched is read again only for those; anything else (another view,
  order or grouping, a stack) is read whole. The square grid reads no
  shapes. A 16-photo batch at 181,749 photos: 101 ms -> 7 ms.

Co-Authored-By: Claude <[email protected]>
2026-10-21 13:17:39 -04:00
Nathaniel LedfordandClaude Opus 5.5 df1e6b4426 LRC-LIB-LIBFOLDERS: a scan cancelled while it goes through the files stops checking the cached probes against the disk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:05 -04:00
Nathaniel LedfordandClaude Opus 5.5 fd7fbe59e4 LRC-LIB-LIBFOLDERS: a scan cancelled during its listing asks the disk nothing more
The cancelled result is never used, but scan_files still checked each listed file's cached probe against the file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:05 -04:00
Nathaniel LedfordandClaude Opus 5.5 d1d3dbbb0e LRC-LIB-LIBFOLDERS: the sync module docs say the walk stops when the scan is cancelled
439ab04c2 fixed the checklist row but missed the module docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:05 -04:00
Nathaniel LedfordandClaude Opus 5.5 8ca082ba6a LRC-LIB-LIBFOLDERS: The docs say the walk for new files stops when the scan is cancelled
The module docs and the parity checklist still said it is not cancelled midway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:05 -04:00
Nathaniel LedfordandClaude Opus 5.5 80fcab83b0 LRC-LIB-LIBFOLDERS: The listing row counts folders and files as labels, so one reads right
The row read "1 folders, 1 files"; it now reads "folders: 1, files: 1" (all 13 catalogs).
`listed` is stored with Release and loaded with Acquire, so a reader that sees the listing over also sees what was stored before it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:17:05 -04:00
Nathaniel LedfordandClaude 3bb084762d LRC-LIB-LIBFOLDERS: Synchronize Folder says it is listing the folder instead of sitting at "0 of N"
The scan lists the whole folder tree before it checks a photo. On a large
folder on a network share that takes minutes, and the scan's row showed
"0 of 64085" with an empty bar all that while: it looked frozen. ✕ did
not stop the listing either.

- engine: SyncProgress counts the folders and files the walk listed and
  says when the listing is over; a folder note (walk::NoteFolder) now
  answers whether to go on, and a cancelled scan answers no.
- ui: while the folder is listed, the row has an indeterminate bar and
  reads "Listing folders: N folders, M files · folder" (all 13
  catalogs); the dialog says the same; then the photos checked are
  counted as before.

Co-Authored-By: Claude <[email protected]>
2026-10-21 13:17:04 -04:00
Nathaniel LedfordandClaude 35cc05ef64 LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date, with one Close
When the scan finds nothing to import, relink, remove or read, the dialog
showed four greyed choices (two still ticked) and a Synchronize button
that started an empty job with an activity row. It now shows the folder,
"The library is up to date with this folder." and any Skipped lines, with
one Close and no Cancel (`only_shows`). `sync::confirm` answers
`{"upToDate": true}` and starts nothing, so `ui.dialog.confirm` and agents
get the same result.

Co-Authored-By: Claude <[email protected]>
2026-10-21 13:17:02 -04:00
mahmatias 77b0204331 fix(guided): the press position survives press+release in one frame
egui clears `press_origin` on the release, so a slow frame that swallows
press, move and release into one batch reports `primary_pressed` with no
origin: the gesture fell back to the release position and a real drag
wrote nothing (its length check saw a zero-length stub). The gate now
reads the primary press back from the frame's own events before that
fallback — the press decides what it means, wherever the batch ended.
2026-10-21 14:13:18 -03:00
mahmatias c19265dee0 fix(guided): the press point, not the current pointer, owns the gesture
`contains_pointer` reads where the pointer is NOW; a slow frame can
deliver the press on a menu and the move off it in the same pass, and
the gate would then pass with the saved press origin under the menu.
`layer_id_at(press_origin) == resp.layer_id` asks which layer owns the
press itself: the menu's area (or any modal) wins there and the guide
under it stays untouched. The navigator check stays for its own widget.
2026-10-21 14:13:18 -03:00
mahmatias f2e97d39fe fix(guided): the loupe's press is layer-aware, not just inside its rect
`primary_pressed` plus `resp.rect.contains` reads the raw pointer, so
anything floating above the photo — the context menu, a dropdown, a
dialog — also started a guide gesture under it. The gate now asks
`resp.contains_pointer()`, which the hit test keeps honest about
covering widgets; the navigator check stays. Two tests press on a menu
over a line and over an endpoint: nothing is selected, moved or drawn.
2026-10-21 14:13:18 -03:00
mahmatias b1c607d7ac fix(guided): the primary button and the canvas only; the press point is the gesture's own
- a secondary/middle press or one on the navigator panned the photo but
  also selected or drew a guide: the gestures take the primary button and
  skip the navigator's rect
- egui forgets the press origin by the release, so the endpoint drag
  compared against the guide's old position: a press that landed within
  the grab radius and never moved wrote a no-op guide; the gesture now
  carries the press point itself
- a selection left behind by a Delete past the last guide clears with the
  photo/tool check in crop_overlay
2026-10-21 14:13:17 -03:00
mahmatias ca26c4046a fix(guided): a stale selected index does not fire an empty guides edit; docs: un-mojibake the dashes 2026-10-21 14:13:17 -03:00
mahmatias 331e0d2103 style: fmt (blank line before point_segment_dist) 2026-10-21 14:13:17 -03:00
mahmatias aa0f9b92a7 docs(parity): LR-GUIDED-SELECT/DELETE rows land as done (press semantics) 2026-10-21 14:13:17 -03:00
mahmatias a22c496d03 feat(guided): guides start on the press and their endpoints can be dragged
A press starts what it means right away from the pointer's press point: on an endpoint (within 7 px) it drags that endpoint, committing one geometry.guides edit on release (the guide must stay at least 0.02 long; a press without a real move only selects, so a click never leaves a no-op undo entry); on a line (within 6 px) it selects the guide (a press on the selected one deselects); anywhere else it starts a new guide from the press point itself instead of where egui's drag threshold was crossed, leaving nothing selected. The endpoint being dragged is drawn bright while the pointer moves. tests_guided_guides 8/8.
2026-10-21 14:11:08 -03:00
mahmatias 7635b38c36 feat(guided): select and delete one Guided Upright guide
A click near a guide selects it (white, thicker, larger endpoints); drawing a new guide or clicking away clears the selection, and so does leaving the photo or the tool. Delete / Backspace with the tool open removes only the selected guide (geometry.guides minus its index, one undo step); with none selected it removes nothing — the photo itself is never deleted while Guided Upright is open. selected_guide is UI-only (serde skip). Coverage: tests_guided_guides 5/5.
2026-10-21 14:11:08 -03:00
helgehelge123 c631d4baed Merge branch 'main' of https://github.com/storytold/lightcraft into sidebar-adaptive-indent
# Conflicts:
#	docs/parity-checklist.md
2026-10-21 19:05:56 +02:00
Matthew James SivakandClaude Opus 5.5 a3c1d6c0f5 LR-IMP-PREVIEW-READ: UI thumbnail tests stub the reduced loader too
with_fs() now sets media.reduced_loader, which thumbnail jobs prefer over file_loader, so the render
fixture's stub file_loader was bypassed: the job read the nonexistent audit.jpg and
resizing_the_disk_cache_keeps_textures_and_skips_rebuilds got no texture (on every OS, not a flake).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:00:52 +02:00
Michael Anderson 536edba1e8 Merge remote-tracking branch 'upstream/main' into fix/1947-merge-duplicate-ids
# Conflicts:
#	docs/whats-new.md
2026-10-21 12:00:45 -05:00
Matthew James SivakandClaude Opus 5.5 d87a8714ad LR-MERGE-HDR: merge warps sample Lanczos-3 instead of bilinear
Split out of #1887 (part 1 of 3). Every warp in the merge crate (HDR alignment,
panorama projection, Boundary Warp) sampled bilinearly: at a half-pixel offset
that is a [½ ½] filter, which keeps cos²(πf) of a pattern's fine detail (a third
at 0.3 cycles per pixel).

- lightcraft_raster::sample: Rgb32f::sample_lanczos3, a 6 × 6 Lanczos-3 point
  sampler with tabulated taps, each sample held to the range of the 4 × 4 pixels
  around it so hard edges (clipped highlights, stars) don't ring.
- align::warp, the panorama projection and Boundary Warp use it.
- align.rs: the merge luminance (`luma`) and the simplest-model choice among
  RANSAC fits (`choose_model`) are shared functions, for the full-size
  refinement that builds on them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:52:24 +02:00
Bruno-Alumn cbf809a37e Masks: mask-effect ids and component indexes that aren't whole numbers are errors
mask.applyEffect, saveEffect and updateEffect read an `id` that isn't a
mask id as the selected mask (applyEffect {"id": "1"} changed mask 2);
mask.update, mask.component and mask.refineDetail cast `component` with
as-casts and fell back to component 0 or the last selection. They share
the strict opt_mask_id / opt_index helpers now.
2026-10-21 12:03:42 +00:00
Bruno-Alumn dad4d6254e LR-PRE-PANEL: an active photo outside the selection isn't marked with the preset applied to the selection (#2392)
preset.apply changes the selection, else the active photo; the panel
remembered the preset on the active photo too, so an active photo outside
the selection showed a preset it never received.
2026-10-21 11:57:58 +00:00
Arekatla Nishanth ChowdaryandClaude Sonnet 5.5 110aea1a1a fix(ui): Show in Folders reveals the right-clicked photo and stays off for folders below the tree's depth
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:42:57 +05:30
Nikolai Nyegaard c262f54f19 Settings and Edit ▸ Language: list the languages alphabetically by their own name
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
2026-10-21 12:01:48 +02:00
Marco Arnulfo bcef909d40 tests_import: without a library the imported profile keeps no file path at all 2026-10-21 10:28:39 +02:00
Marco Arnulfo 4a82c0774e tests_import: keep the folder rename comment with its own test 2026-10-21 10:17:57 +02:00
Arekatla Nishanth ChowdaryandClaude Sonnet 5.5 4ab5dcc319 feat(ui): add Photo > Show in Folders (#2437)
Selects the active photo's folder in the sidebar's Folders section,
opens the rows above it and scrolls it into view. Also on the photo
right-click menu. Adds the parity row and the label in all locales.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 13:02:31 +05:30
Bruno-Alumn 205a7ec120 photo.label: a missing label, or one that isn't text, is an error, not a clear
photo.label read its label with str_param, which is None both when the
label is missing and when it isn't a string, and None meant "none": so
photo.label {} or {"label": 3} cleared the colour label of every selected
photo. Only "none" or null clear now; anything else that isn't a known
label is an error. Every caller in the app already passes a label.
2026-10-21 01:49:30 +00:00
Bruno-Alumn 622988d7f7 Masks and spots: an id or index that isn't one is an error, not the selected one
mask_id read `id` with `as_u64().map(|v| v as u32)`, so text fell back to
the selected mask (mask.delete {"id": "1"} deleted it) and an id past
u32 wrapped onto another mask (4294967297 as u32 is 1). spot_index and
spot.select did the same with `index`, and a colour range's `component`
fell back to the first colour range part. They now refuse such values,
as the photo ids do; a missing or null value still means the selection.
2026-10-21 01:39:29 +00:00
tylerphotosandCodex 4cb90d4b7e LR-IMP-ADD-DIALOG: clear deleted import destinations
Keep the stored destination consistent with the picker's None fallback after
catalog changes. Extend the real-file import regression to delete the chosen
album before confirmation, then verify import membership and undo.
Document the helper contracts and destination transitions for review coverage.

Co-Authored-By: Codex <[email protected]>
2026-10-20 20:34:52 -05:00
Bruno-Alumn 42c7cc03a4 Commands: an id or ids that isn't a photo id is an error, not the selection
Several commands read their photo parameters leniently. An `ids` that
wasn't a list of whole numbers (`"ids": 3`, `["3"]`) fell back to the
selection or the active photo, and an `id` that wasn't a whole number
(`"id": "5"`) fell back to the active photo, so a call meant for one
photo acted on another:

- ids: album.addPhotos / removePhotos / toggleTarget, library.select,
  develop.set, denoise.queue, faces.index, mask.applyEffect (mask ids)
  and app.export;
- id: photo.relink (relinked the active photo to the file),
  photo.removeRegion / setRegion, faces.setName, stack.setTop / moveUp /
  moveDown / split, library.findSimilar, the photo queries,
  denoise.cancel (cancelled every photo) / status and ui.render.

They now refuse such values with an error, as Session::targets already
does for the other commands (shared cmd::photo_ids / photo_id). A
missing or null value still means the selection or the active photo.
2026-10-21 01:21:45 +00:00
NetterSilverandClaude Haiku 5.5 c3a5d9b7f1 Keep file name and query for fragment-only redirect Location
Found while testing crates/fetch/src/http.rs:139 (Url::join)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 01:13:10 +00:00
Bruno-Alumn 7ab5b150f3 KEYC-PANELS: F8 shows and hides the right panel
Lightroom Classic's F8 hides the right panel; F6 and F7 (filmstrip, left
panel) were bound but F8 did nothing. View > Right Panel (view.rightPanel,
F8 as a secondary key like F6 / F7) hides the right panel and shows the
same one again next time, or Edit when none was hidden. Hiding a tool
ends it, as closing its panel does.

Gap 28 listed rating [ ], flag cycling and F1 help as missing; they are
bound, so the entry now lists only filter-bar keys, Tab / Shift+Tab and F5.
2026-10-21 00:39:53 +00:00
Bruno-Alumn 61823b1736 LR-PRE-PANEL: the applied preset stays marked on returning to the photo (#2392)
The panel marked the last applied preset only while the selection was
the one it was applied to, and forgot it on the first frame with another
photo: back on the photo, nothing showed which preset it had. It now
remembers the preset per photo together with the photo's settings right
after, and marks it while the photo still has them; another photo shows
its own (or none), and a further edit drops the mark. The Amount slider
is no longer forgotten on a switch either: it comes back with the photo
while no later edit, undo or redo owns the undo step.
2026-10-21 00:27:45 +00:00
Bruno-Alumn 360bf89903 LR-MASK-OVERLAY: a new or picked mask shows its overlay again (#1800)
A mask slider hides the overlay so the adjustment can be judged, and
nothing showed it again: creating another mask, picking one in the list,
duplicating it or adding a part left it hidden while the Overlay menu
still named Color Overlay. Those now show the overlay, as Lightroom does;
so does a new AI selection from a click or a description.
2026-10-21 00:13:57 +00:00
Bruno-Alumn f16c6ef07f LRC-LIB-COMPARE: the Info panel labels the active photo while culling (#2398)
In Compare and Survey the selection holds both photos on screen, so the
Info panel's colour-label swatches and stars (and the Set Color Label
context menu) applied to the whole selection, while the bottom bar and the
rating/label keys act on the active photo only. They now go through
compare::target_active like the bottom bar does.

The culling panes' captions showed stars and flags but not the colour
label, so labelling there looked like it did nothing; each caption now
shows the label as a dot.
2026-10-20 23:49:03 +00:00
tylerphotosandCodex 286171e2e5 LR-IMP-ADD-DIALOG: preserve import destination album hierarchy
Reuse the shared album picker in Import Photos, retaining None/New album,
editable names, album-context preselection and existing import semantics.
Keep long paths within the viewport and cover hierarchy, localization,
keyboard choices, real imports, undo and layout regressions.

Co-Authored-By: Codex <[email protected]>
2026-10-20 16:41:40 -05:00
NetterSilverandClaude Haiku 5.5 8fe440c759 Carry rounded GPS minutes into degrees in XMP writer
Found while testing crates/meta/src/xmp.rs:458 (fmt_gps_coord)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 19:59:03 +00:00
NetterSilverandClaude Haiku 5.5 dee5f7910a Match URL schemes case-insensitively in Url::parse and Url::join
Found while testing crates/fetch/src/http.rs:71 (Url::parse), also Url::join at :115

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 19:17:55 +02:00
NetterSilverandClaude Haiku 5.5 81b8f0e598 Drop non-finite elevation values in the GPX parser
Found while testing crates/meta/src/gpx.rs:150

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 16:48:29 +00:00
helgehelge123andClaude Opus 5.5 d1eeff9600 LR-BEHAV-LOCALIZE: say what German's 59 and its 3,754 count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 18:44:36 +02:00
Roope Gröhnandcoderabbitai[bot] 89a916a31d Update docs/localization-parity.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-20 18:54:28 +03:00
Roope GröhnandClaude Sonnet 5.5 494d9d77bb i18n: add Finnish (fi) interface language
Adds Finnish catalogs (fi.json, fi-formats.json), the language table entry
and menu command, Finnish tests, docs and an attribution row. What's New text
stays English, as in most catalogs.

AI-assisted translation (Claude Code), reviewed by a native Finnish speaker.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:29:23 +03:00
NetterSilverandClaude Haiku 5.5 cee9349658 Remove dot segments when resolving redirect Locations
Found while testing crates/fetch/src/http.rs:136 (Url::join)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 15:21:33 +00:00
NetterSilverandClaude Haiku 5.5 78f0e6e650 Keep file name when a redirect Location is query-only
Found while testing crates/fetch/src/http.rs:139 (Url::join)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 17:03:00 +02:00
helgehelge123andClaude Opus 5.5 f42792ff2c i18n(de): × instead of ✕ in three What's New lines
Main now rejects ✕ (U+2715) in translations, since Noto Sans Georgian has no
glyph for it (0c3c500e); × reads the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:09:03 +02:00
helgehelge123andClaude Opus 5.5 f64d9a2e3c i18n(de): speak to the user with du throughout
The German catalog mixed du and Sie. It now uses du everywhere: the 11
messages that used Sie (imperatives such as "Wählen Sie", and "Ihr Name oder
Studio") changed; "Sie" and "Ihr" that mean they and their stay.
docs/localization-parity.md records the choice for the next batch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:59:38 +02:00
helgehelge123andClaude Opus 5.5 6d5a3b02d2 i18n(de): translate the 862 What's New lines the German catalog lacked
Help → What's New now reads in German down to the oldest entry: every
wrapped line of docs/whats-new.md without a German entry has one. Dates
read DD.MM.YYYY; issue numbers, code spans, file names and link targets
are unchanged, and link texts stay English because the pages they open
are English. Each wrapped line is its own catalog key, so the German
lines break near the English line breaks. "Raw(s)" is "RAW-Datei(en)",
menu paths use →, and the text keeps the catalog's "du" form.

Held back: the 59 lines whose English text contains a ▸ menu path.
Their translations are ready (with →) and go in once #2016 (for #1950)
renames those keys; adding them now would only conflict with it.

localization-parity.md and ROADMAP.md: German re-measured with the
documented count over the catalogs, 2,572 → 3,644 of 3,754 messages
(80% → 97%). The denominator grows because German now translates
What's New lines no other catalog has; other rows keep their counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:57:40 +02:00
helgehelge123andClaude Opus 5.5 8fdbbad623 i18n(de): translate the 217 interface strings the German catalog lacked
Every command, control and menu label and every tr() message in the
interface that another catalog already translates now has a German
entry too: mask effect presets, print and contact sheet, slideshow
settings and templates, External Editing (PhotoCraft / other editor),
monitor profiles (Settings → Display), HDR editing (SDR sliders, HDR
headroom), Tool Overlay and Loupe Overlay, keyword commands, background
tasks and face model setup.

Terms follow the existing catalog (Vorgabe, Stichwort, Maske,
Begleitdatei, Überlagerung, Dia, RAW-Datei) and its "du" form; menu
paths use → instead of ▸. The one English message that contains ▸
(the PhotoCraft help text in Settings → General) is left for after
#1950, which renames those source strings.

i18n::tests now report no missing German display labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:57:40 +02:00
Hamza 7b4b300d75 Crop: the spiral guide is complete at every aspect ratio
The squares were carved off the crop rectangle itself, so the guide
depended on the crop's shape. A square crop lost everything after the
first arc -- the first subtraction leaves no rectangle to carve, and the
loop stops -- and other ratios kept different amounts. Counting the
quarter-circle arcs:

    crop    before   after
    1:1          1      10
    phi:1       10      10
    3:2          3      10
    16:9         4      10
    4:5          3      10

Only a golden-ratio rectangle subdivides into squares forever, so that
is the one shape that yields a complete spiral. The construction now
uses a PHI x 1 rectangle whatever the crop is, and the normalisation
already at the end of the function stretches it to fit.

That distorts the curve on a crop of another ratio, which is the price
of the guide staying complete, and is the approach the report proposes.
The portrait transpose and the orientation flips are unchanged, so a
portrait crop still gets a portrait spiral and all four orientations
still mirror it.

Fixes #2393.
2026-10-20 17:30:08 +05:00
Matthew James SivakandClaude Opus 5.5 9ed971149b LR-IMP-PREVIEW-READ: exports never decode a DNG's reduced copy
Reduced decoding is now opt-in: `files::load_vec_reduced` and the session's `reduced_loader` are used by grid
thumbnail jobs only; `load_bytes` / `load_vec` / `load_vec_pair` always decode the full image. A source developed
from a copy is marked (`DecodedSource::reduced`), so an export at a thumbnail's size reads the file again instead
of the cached thumbnail source, and Build Smart Previews never rebuilds a stale proxy from one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:08:03 +02:00
insideTheFluxandClaude Opus 5.5 4ea9be25b2 Gap 27: the Windows x64 installer is verified on Windows 11
The released 0.9.1 x64 MSI was tested on Windows 11 Pro 24H2 (build
26100.9711) with verbose msiexec logs. A fresh install and an upgrade
from 0.8.0 into a custom folder both end on the completion page, so
#790 is fixed (by #1569). The upgrade keeps the folder, Launch starts
the app as the signed-in user, and both removals leave nothing behind.

Uninstalling from Settings -> Apps still ends without any confirmation
(#892, #931), so that stays in the gap. Hours and percentages are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:40:21 -07:00
Reva Doni AprilioandClaude Opus 5.5 116230eb21 Smart album rules: + and − never cover a rule, and hovering a field doesn't wrap it
A rule's − and + were laid out right to left in whatever room the row had
left. The dialogs share one window, which keeps the width the previous
dialog left it at, so after a narrower one the rule editor only grew to
the end of its longest value: a date "is between" then had no room left,
and the − and + were drawn over its end date and the end date's calendar
button. That calendar was there all along, but the + on top of it took
the click. The buttons now follow the value (pushed to the right edge
when there is room, as before), so the dialog widens to the row.

Every dropdown list drew its items with the theme's resting button
border. An unhovered item gives up that border's width of padding, so a
hovered one needed 2 points more, and the longest name of a submenu
sized to it (Date ▸ Capture Date, Keywords & People ▸ Keyword Count, and
the longest name of every other group) wrapped onto a second line. The
dropdowns' lists and their submenus now drop the resting border, as
egui's own menus do.

Fixes #2353
Fixes #2352
Fixes #2354

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 12:08:36 +07:00
Claude 2c085e0ae5 LR-EDIT-DETAIL-DENOISE: check the transposed convolution's stores exactly too
A max-pool, a channel-copy 2x2 transposed convolution (mode 1) and the
channel-copy 1x1 layer give an exact answer, so a store fault in mode 1
can't hide in tolerance (CodeRabbit review of #2380).

On the GTX 1070 this test passes even without mode 1's stepped stores:
FXC merges them the same way, but the driver runs that store right, so
only mode 0 was shown wrong. Mode 1 keeps the stepped stores as a
precaution; the shader comment and docs/gpu-pipeline.md now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:34:56 -07:00
Claude 75292874a3 LR-EDIT-DETAIL-DENOISE: GPU denoise gives the right result on DX12 with NVIDIA Pascal
On DX12, FXC merged the conv kernel's four output stores (modes 0 and 1)
into one 16-byte store_raw. The DXBC is valid and aligned, but NVIDIA's
driver ran it wrong on a GTX 1070: every second pixel's channels came
out shifted, so AI Denoise on the GPU was silently wrong on Windows.
DXC and Vulkan were right on the same card.

The stores now step by `1 + p.zero` (the always-0 uniform, renamed from
`pad`), which FXC cannot fold, so it keeps them apart. A two-layer
channel-copy network checks the stores exactly; it fails on that card
without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 18:54:24 -07:00
Andrew FrahnandClaude Sonnet 5.5 e09e91f890 Move the dated LR-IMP-FORMATS entries from the checklist table to the revision history
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 12:06:04 +11:00
Andrew FrahnandClaude Sonnet 5.5 376ad8177d Review: correct ColorTemperatureAuto tag id, dedupe relative-scale text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 12:03:36 +11:00
Andrew FrahnandClaude Sonnet 5.5 a5a4b64ec6 Review: fluorescent accuracy bound 5 %, drop stale relative-scale text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 12:03:32 +11:00
tecari0andClaude Opus 5 afa27661c4 #2365: centre the search field on the window, not inside the bar's margins
The top bar's left margin holds the traffic lights' room on macOS, and full
screen gives that room back (#1763). The search field was centred on the rect
inside the margins, so it sat 33 px right of centre in a window and 2 px right
of it in full screen, and its width changed by 62 px as well: the field jumped
sideways whenever that margin changed.

The field is now placed against the whole bar, so neither its centre nor its
width depends on the traffic lights. The left buttons still move into their
space in full screen. `window_handle` takes that bar rect instead of building
the same rect from the content and its margins.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-19 17:48:48 -07:00
Andrew FrahnandClaude Sonnet 5.5 de8e4c415c MRW: RawImage fields added upstream (lens_metadata, pixel_aspect)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 10:56:25 +11:00
Andrew FrahnandClaude Sonnet 5.5 52c4ec6649 Restore LF line endings
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 10:09:11 +11:00
Andrew FrahnandClaude Sonnet 5.5 e2b56367db Merge upstream/main; KEY-SLIDER-TYPE: keep the long-click fix only (scrub landed in #1604)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 10:06:35 +11:00
Andrew FrahnandClaude Sonnet 5.5 d8f1b30bcc Kelvin presets: match multiword makes in the catalog's joined camera lookup
covers_camera tried only the first-space split, so NIKON CORPORATION NIKON Z5_2
stayed on the relative scale in the catalog and editor. Try every split, as
spectral::find_camera does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 10:04:25 +11:00
Andrew FrahnandClaude Sonnet 5.5 5e1bac1264 Merge upstream/main into pr/longtail-raws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 09:58:17 +11:00
Andrew Frahn 522e29919d Merge remote-tracking branch 'upstream/main' into pr2006-fix 2026-10-20 09:42:25 +11:00
Andrew FrahnandClaude Sonnet 5.5 041f91d1cc Fix raw-parity merge markers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 09:20:10 +11:00
Andrew FrahnandClaude Sonnet 5.5 b2a58e8f33 Merge upstream/main into canon-colordata-kelvin
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 09:19:53 +11:00
helgehelge123andClaude Opus 5.5 1399dcd3a5 LRC-LIB-LIBFOLDERS: By Date's day rows indent by two INDENT_STEPs, not a literal 32 pt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:09:55 +02:00
helgehelge123andClaude Opus 5.5 fb3babf8be stack.toggle: ids name the stacks without selecting them; badges keep the selection and are named for screen readers
A badge click no longer selects the photo first: stack.toggle opts into explicit_targets, so
`ids` skip the selection guard and the command refuses ids with no stacked photo. Compare keeps
its pair. Both badges announce Expand Stack / Collapse Stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:05:15 +02:00
Claude 1ea75ff9ea #2267: the Before label paints over the window renders, below the filter pill, inside the pane
Review follow-up:
- the badge is painted after the region window renders, which zoomed in
  covered it (the very case the fix is for);
- with a filter active, the pill in the canvas' corner has that corner:
  the badge sits below it, also when the photo's corner is just under it;
- a wide label near the pane's right edge stays 8 points inside it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 18:37:44 +00:00
NetterSilverandClaude Haiku 5.5 80a342e9e2 Fix parse_gps for hemisphere-letter-first coordinates without comma
Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 19:45:23 +02:00
NetterSilverandClaude Haiku 5.5 8c90c35472 Treat Unicode minus and dash characters as negative in parse_gps
Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 19:27:29 +02:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 3a6e7d0b9b Merge main into feat/1449-people-search; people and quotes go through text_matches (#2099)
Review on #1468: main's #2099 split out text_matches() and lets unknown field
prefixes fall back to plain text. On top of it, text_matches() now also matches
Photo::people(); the free-text token is unquoted before text_matches(); an
unknown field's quoted value is unquoted in the fallback (`10:"30 am"` is the
text `10:30 am`); and the field split keeps the !t.starts_with('"') guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:18:49 +05:30
Claude 82a703111e #2267: the Before label stays in view when zoomed in
Zoomed in, the photo's corner is off the canvas, and the "Before" label
(\ key or the Before view) left the screen with it. The label's pill now
sits at the canvas' corner when the photo reaches past it, on whichever
axes it does; at Fit nothing changes. Same rule for the hovered preset's
name. Stacked on #2274 (the dark pill behind the label, same lines).

Tests: the pill geometry with the photo inside and past the pane, and a
headless check that at 100 % the registered label rect stays inside the
canvas.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
2026-10-19 13:09:20 +00:00
NetterSilverandClaude Haiku 5.5 af0146c5e8 Fix trailing dot in All Metadata rational formatter
Found while testing crates/meta/src/tags.rs:148 (rational)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 13:33:41 +02:00
Matthew James SivakandClaude Opus 5.5 86bf39dc4e LR-META-XMP: re-run CI (FreeBSD aarch64 runner setup failed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:15:56 +02:00
sanyamsingh001andCodex 30cdb4b1c4 LR-VIEW-PHOTOGRID: preserve embedded previews through priority changes and Undo
Queued embedded grid-preview requests now change priority without restarting their work. Undo to an embedded preview retires obsolete thumbnail requests so a late edited render cannot replace the restored preview.

Partially addresses #348.

Co-authored-by: Codex <[email protected]>
2026-10-19 19:33:20 +10:00
helgehelge123andClaude Opus 5.5 3e163f3f3d LRC-LIB-LIBFOLDERS: the sidebar trees narrow their indent step before names are cut (#2298)
Every level of Albums, Local, By Date, Folders and Keywords still steps one
level right of its parent, but the step is chosen each frame from the panel's
width and the deepest open row: 16 pt while that row keeps about 80 pt for its
name and room for its count, else 14, 12, 10 or 8 pt. Past 8 pt the sidebar
scrolls sideways as before. Callers keep passing indents in 16 pt steps;
`indent_x` maps them to the drawn step for the icon, name, chevron, album drop
line and the width a row asks for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:51:31 +02:00
helgehelge123andClaude Opus 5.5 35be24bcbe LR-LIB-STACK: note the filmstrip badge click in the parity checklist
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:11:27 +02:00
helgehelge123andClaude Opus 5.5 084cd3ddd3 Stack badges: filmstrip badge expands/collapses, badges work on unselected stacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:11:00 +02:00
sanyamsingh001andCodex 089172137b test(ui): wait for menu registration and visible sync rows
Wait for the registered Edit title before the existing one-click test. Reveal the Sync folder row only when needed, click its visible intersection, and keep geometry in failing assertions. Preserve the original scenarios and pointer interactions.

Co-authored-by: Codex <[email protected]>
2026-10-19 17:12:31 +10:00
Reva Doni AprilioandClaude Opus 5.5 61402815b5 docs: gaps header for gap 21; Indonesian parity counts messages only
Address review: gaps.md's header and history record the 2026-10-19 gap 21
change, and the Indonesian parity row counts the 2,555 catalog messages,
with the 69 command labels noted separately.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:26:32 +07:00
Reva Doni AprilioandClaude Opus 5.5 822c6ed101 docs: Indonesian in the key-languages tables and the localization headline numbers
Address review: the twelve-key-languages row for Indonesian still read 0%
while the shipped table listed it, and ROADMAP repeated it under Also
shipped. Indonesian is a key language, so it moves to its row (100% but
What's New) and out of the other-languages table (back to 4). Localization
mean of the 12 key languages ~55% -> ~64%, 8 of 12 present; the new-script
estimate drops Indonesian (135-239 h); the full-target weighted sum moves
61.2% -> 61.5% (still ~61%).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:07:58 +07:00
sanyamsingh001andCodex fe6f431a03 LRC-DEV-AUTOSYNC: separate UI fixture changes
Restore the original UI tests so this PR contains only the camera local-tone Auto Sync fix and its two engine regressions. Keep the independently prepared fixture correction in its separate review scope.

Co-authored-by: Codex <[email protected]>
2026-10-19 15:47:16 +10:00
Reva Doni AprilioandClaude Opus 5.5 816a1112e3 i18n: Indonesian (id) interface language
Adds crates/ui-egui/locales/id.json and id-formats.json: every plain
(2,258) and formats (297) message any catalog has, plus 69 command
labels no catalog translated yet. What's New stays in English.

Indonesian keeps a noun unchanged after a count, so English plural
suffixes are consumed with {:.0}; dates read "Minggu, 20/09/2026".
Menu triangles use → and ⌄ like the other catalogs. 356 labels reuse
the sibling apps' Indonesian wording (PhotoCraft, VectorCraft,
FilmCraft, FigCraft) so the suite reads the same; credited in
ATTRIBUTION.md.

Registers Locale::Id ("Bahasa Indonesia", Latn), the
app.language.indonesian command, the keyword-list catalog check and
an Indonesian switch/persist/format/date test. Docs: localization.md,
localization-id.md, localization-parity.md, ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:17:59 +07:00
NetterSilverandClaude Haiku 5.5 1358ea61e1 Analyse a photo listed twice once in Assisted Culling
Found while testing crates/engine/src/cmd/cull.rs:23 (analyze: ids come from Session::targets, crates/engine/src/lib.rs:1205, which does not de-duplicate)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 20:36:00 +00:00
Marco Arnulfo 0c932b6958 LRC-DEV-VIEWOPTIONS: View Options (Cmd+J) chooses the lines of the loupe's Info 1 and Info 2 2026-10-18 19:41:59 +02:00
Marco Arnulfo 7b6eff73b3 KEYC-RATING: backslash keeps Show Original in the full-screen preview 2026-10-18 19:05:05 +02:00
Marco Arnulfo d5de74f6cd KEYC-RATING: backslash shows and hides the Filter Bar in the grids 2026-10-18 19:05:05 +02:00
Matthew James SivakandClaude Opus 5.5 7f97bdbb5f LR-IMP-PREVIEW-READ: thumbnails of Lightroom HDR/panorama DNGs develop from the file's reduced raw copies
A DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom merges keep 256, 512 and
2048 px ones) now develops a thumbnail or preview from the smallest copy that covers it, with the main
image's levels and colour and its crop scaled. A 23836 x 7078 drone panorama's 512 px render went from
38 s to 1.1 s. Copies are skipped when the main image has opcode lists, a linearization table or
black-level deltas, and for denoised photos.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:47:23 +02:00
nerdislbandClaude Opus 5.5 cb61588e7d LR-EDIT-OPTICS-PROFILE: find the profile named by a lens specification behind a tie
Another maker's lens with the same specification ("Otherco 18-55mm f/3.5-5.6")
scores as high as the profile aliased "18-55mm f/3.5-5.6"; when it came first,
find_lens kept it and the exact-name check then refused it, so the alias was
never used. LensDb::find_lens_named ranks only the profiles named exactly so,
with the same maker and mount preferences (review on #2217).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:42:50 +02:00
nerdislbandClaude Opus 5.5 067f4fdf1e LR-EDIT-OPTICS-PROFILE: look up Nikon DSLR lenses by their lens specification
Nikon DSLRs write no Exif LensModel, only the lens specification (maker note
0x0084, read into Metadata::lens_spec), so the lensfun-format lens database never
corrected their raws. A raw without a lens model is now looked up by a name built
from its specification ("24-70mm f/2.8", lightcraft_lensdb::spec_name), and a
profile is used only when one of its names is exactly that
(Profile::names_exactly): the specification fits every maker's lens with that
range, so a partial match or a maker-named entry is never taken. lensfun's own
database has no such names; a user aliases the one lens they own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:42:50 +02:00
Matthew James SivakandClaude Opus 5.5 38b247e394 LR-META-XMP: Lightroom's profile and version bookkeeping is not reported as unimported XMP
CompatibleVersion, the profile digests, the lens profile's identity and its
default-strength scales, and CropConstrainToUnitSquare don't change the
rendering; a Lightroom raw sidecar listed all of them for every photo.
Look and unknown fields are still reported.

Split out of #2008.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:21:53 +02:00
wstock ef4322b2ff docs(user): the export guide keeps describing v0.9.1 (#1620)
docs/user/AGENTS.md: pages describe the latest release, not unreleased code on
main. Width & Height and the Dimensions rename are not in v0.9.1, so the
guide's size step goes back to the released dialog; it is updated once a
release ships the box.
2026-10-18 15:46:45 +00:00
wstockandClaude Fable 5.1 780476de1c LR-EXP-DIM: Width & Height limits each edge to its own number (#1620)
Lightroom's Resize to Fit has two boxes: Dimensions (the larger number to
the long edge, whichever way round the photo is) and Width & Height (the
width limited to W and the height to H as the photo stands). LightCraft
had only the first, labelled "Width × Height". Add `ResizeMode::WidthHeight`,
`fit: widthHeight` beside `width` + `height` in app.export, the mode in the
Export dialog (the old one is now called Dimensions), and the docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-18 15:22:09 +00:00
Michael Anderson ff663469d7 Merge upstream/main into fix/local-browse-stamps 2026-10-18 09:42:34 -05:00
Marco Arnulfo be34283943 crop.set checks the minimum on the crop it stores, so an oversized rect can't come out a sliver (part of #1952) 2026-10-18 13:28:27 +02:00
sanyamsingh001andCodex bc071bb57b LRC-DEV-AUTOSYNC: sync camera local tone Off
Compare the typed camera-local-tone flag on both sides of the Auto Sync delta so selected peers receive Off while retaining their own profile id, amount, and unrelated settings. Add regressions for propagation, unchanged values, selection boundaries, and history/serialization behavior.

Co-authored-by: Codex <[email protected]>
2026-10-18 12:00:11 +10:00
FredApps 69e34624e0 Preserve requested source in render failure diagnostics
Snapshot the requested catalog source and retain stale-result suppression.
Add regressions for relinking and superseded failed renders.

Partial follow-up for #1890.
2026-10-18 01:08:17 +02:00
midasmatterhorn 2e23875ed3 Make encoding staleness reach every smart preview and keep refreshes off the proxy cache
A proxy whose stored pixels are an older encoding is stale for every
photo, not only the ones that take the per-file camera look, so DNG and
JPEG proxies migrate to the current encoding like the raws do. And a
source decoded from a smart preview now knows it came from the cache:
Build Smart Previews refuses it as the original, so an offline refresh
can no longer rebuild a stale proxy from itself.
2026-10-17 22:12:36 +03:00
sanyamsingh001andCodex fb8de10b99 LRC-DEV-AUTOSYNC: sync HDR state while retaining per-photo tuning
Turning HDR off with Auto Sync active now turns it off for the other selected photos. Enabling HDR preserves existing per-photo HDR tuning.

Co-authored-by: Codex <[email protected]>
2026-10-18 04:25:51 +10:00
wly 6055ec6992 docs: polish Traditional Chinese README wording
Use Taiwan-standard terms instead of Mainland ones (底片, 巢狀, 控制項,
影像, 浮水印, 儲存庫, 覆寫, 相依套件 …), match the in-app zh-hant labels
(照片庫, 算繪, 編輯前/後, 縮圖目錄, 開啟記錄資料夾, 光線/色彩/效果 panel
names, stock preset and profile names), and rewrite literal translations
that read stiffly or changed the meaning.
2026-10-17 15:08:07 +08:00
wly 2efa32009a docs: add Traditional Chinese (Taiwan) README
Add README.zh-hant.md alongside the existing English and Simplified
Chinese READMEs, translated from the current README.md and aligned
with the in-app zh-hant terminology (crates/ui-egui/locales/zh-hant.json).
Cross-link all three README files in their language switchers.
2026-10-17 14:51:15 +08:00
FredApps b427378eaf Address #1890: refresh catalog render diagnostics onto main
Partial scope only: catalog-backed renderer diagnostics. Broader import/export paths remain open. Preserve already-merged workflow/package changes; the exact candidate tree passed both diagnostic regressions, renderer tests and all eight local CI gates.
2026-10-16 17:28:49 +02:00
sanyamsingh001andCodex b0403c06f9 LR-META-XMP: reread embedded JPEG metadata
Manual metadata reread now finds standard embedded JPEG XMP when no sidecar exists. Existing sidecars remain authoritative, metadata updates remain undoable, and rereading does not write a sidecar. The reader retains its bounded header policy; Extended XMP assembly is unsupported.

Co-authored-by: Codex <[email protected]>
2026-10-16 15:00:33 +00:00
Aleksandr Batyrev 14ede0b7d9 fix(camera-profiles): fingerprint subsecond profile modification times 2026-10-16 19:58:40 +05:00
ronibalthazar a43c3c2a89 #1952: expire idle control connections 2026-10-16 14:15:48 +01:00
Alexander Bystrow 085d18d692 LR-IMP-LOCAL: Opening on Missing Photos picks no photo from it either
Review round 1: with no active photo saved (an empty Missing view leaves
none), open_stores worked the view out to pick its first photo, with the
same synchronous check of every original. Skip that on Missing Photos;
the test now opens both an empty and a full Missing view.
2026-10-16 13:54:33 +02:00
Alexander Bystrow bf82b57a0a LR-IMP-LOCAL: Opening on Missing Photos no longer checks every original
Forgetting untouched Local records on open kept the visible photos, and on
Missing Photos working them out stats every original on the UI thread
before the availability worker runs. A 37k-photo library on a sleeping
SMB share froze the window for minutes. Missing Photos lists library
photos only, never Local records, so it can keep nothing: skip it.
2026-10-16 13:46:30 +02:00
Andrew FrahnandClaude Opus 5.5 340ed4c2d8 LR-EDIT-COLOR-TEMP: Kelvin white balance from Canon CR2 ColorData presets
Canon's maker-note ColorData lists the levels of each white-balance preset
with Canon's colour temperature for it. For versions -4 and 13-15 the
Daylight, Shade, Cloudy, Tungsten and Flash rows now give a Kelvin curve
(kelvin_presets, generalised from five fixed points to any number, with a
from_unordered constructor that sorts and drops points that would turn the
curve back). Fluorescent (off the locus) and the Kelvin row (the dial's last
setting) are left out. Enabled for the PowerShot G1 X Mark III, which has no
colour matrices: its nine as-shot white balances read within 4 % of the
camera's own ColorTempAsShot.

Sources: own design on the existing kelvin_presets model; ColorData row
positions from the ExifTool Canon tag-name documentation, confirmed with
ExifTool run as a black-box tool on sample files.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:19:18 +11:00
Andrew FrahnandClaude Opus 5.5 b03f1dd396 LR-EDIT-COLOR-TEMP: Kelvin white balance for the Nikon Z5II from built-in presets
The Z5II has no colour matrices and its NEFs carry no preset table, so its Temp slider
showed the relative -100..+100 scale. A small per-model table (crates/raw/src/kelvin_builtin.rs)
gives five (Kelvin, multipliers) points that feed the existing KelvinPresets curve: the NEF
decoder attaches them to ColorData::kelvin_presets and kelvin_presets::covers accepts the model.

Points: as-shot WB_RBLevels of the body's own shots at Incandescent, Direct sunlight, Kelvin
5500 K, Cloudy and Shade, placed at the temperature the camera itself writes for each preset
(maker note ColorTemperatureAuto: 3000, 5210, 5500, 5990, 8000 K). Fluorescent and flash are
left out. Natural Auto (camera: 5030 K) reads back at 5036 K.

Sources: own design on the existing kelvin_presets model; the camera's own stated temperatures;
ExifTool Nikon tag-name documentation (prose), exiftool used as a black-box tool.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:13:35 +11:00
LochLogicandClaude Opus 5.5 16f5a9f0ca Control port: don't clone the connection's socket (Windows: launched programs held it open)
serve() read through stream.try_clone(). On Windows a cloned socket handle is
inheritable (std's accept/connect sockets are not), so every program the app
started while a control client was connected (the browser for a link,
Explorer for Show in Explorer, an external editor) got a copy of the
connection. When the server then closed it, the client saw nothing until that
program exited, and then a reset instead of a clean close. Read and write
through the one stream (&TcpStream is Read and Write) instead.

Regression test (Windows): a program started while a client is connected no
longer delays or resets the close; it failed with ConnectionReset before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:16:51 -06:00
Marco Arnulfo 38973dba3a Imported LUTs no longer leave a copy in the temp folder without a library on disk (part of #1952) 2026-10-16 06:45:40 +02:00
lincleejunandClaude Opus 5.5 389a2cdd65 GPU: a lost device is not polled again, and a poll that panics fails the render (#1858)
Once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll`
instead of returning an error: the device error is not a `PollError`, so
`wgpu_core.rs` hands it to `handle_error_fatal`. The readback assumed the
wait would fail. A render whose readback found the device lost panicked
(caught by `try_render`), and while it unwound, a buffer going back to
the pool polled again from a destructor (`Gpu::trim`). That second panic
aborted the app ("panic in a destructor during cleanup"), as in the
reporter's logs on an Intel UHD 620 under Vulkan, each time a merge lost
the device.

The render device's three polls (the readback, `trim`, `sync`) now go
through `Gpu::poll`, which stops polling a lost device (the device-lost
callback, or a poll that panicked) and turns a panicking poll into an
error that retires the GPU. The render then fails over to the CPU and the
app keeps running. A GPU retired for another reason (a validation error)
keeps a live device and is still polled, so its pooled buffers are freed.

Regression tests run in a child process (they lose the device):
- `a_poll_on_a_lost_device_neither_panics_nor_aborts`: polls panic as
  wgpu's do on a lost Vulkan device; a readback, then an unwinding render
  whose destructor trims the pool. Before the fix the child aborts with
  SIGABRT and the log line above.
- `a_really_lost_device_is_not_polled_again`: a Metal device really lost
  (4200 unsubmitted command buffers, past wgpu-hal's 4096) is not polled
  again. Before the fix the poll still reaches wgpu.
- `a_retired_but_live_device_is_still_polled`: a GPU retired by a
  validation error still polls (and releases its buffers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RbXK4njy12cpm4PG12Xrqt
2026-10-15 21:40:29 -07:00
Ted Mao 2b83edc8f1 Test previews against reduced full-size renders
Generated by an AI coding agent.
2026-10-15 20:26:22 -07:00
Gert Pellin 27974fac0f Tone: test the edited-curve shoulder at white, not past its end (#1945) 2026-10-16 04:46:51 +02:00
Gert Pellin c5cebdef39 Tone: neutral display tone map is the identity up to white (#1945)
The SDR shoulder in ToneMap::display_hdr ran at neutral Contrast, Whites
and Blacks too, so an unedited JPEG or PNG exported neutral 255, 254 and
253 one level darker and a white background never came out as 255. The
shoulder now shapes edited curves only; neutral sliders are the identity
up to white and clip above it. Edited curves render as before (V1).

Tests: the identity test runs to 1.0, a new pipeline test renders the
issue's neutral greys back exactly, the sRGB ramp round trip is exact,
and a guard keeps the shoulder on edited curves.
2026-10-16 04:27:35 +02:00
Marco Arnulfo 1162c2a018 crop.set refuses a crop smaller than the crop tool allows, instead of exporting 1×1 (part of #1952) 2026-10-16 04:09:25 +02:00
Michael Anderson b5c7eddf8f Photo Merge: Merge a photo named twice only once (#1947) 2026-10-15 17:24:05 -05:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 597fb998dd Merge main into feat/1449-people-search
Both sides added code at the same places: main's Prepared filter struct next to
search_tokens()/unquote() in query.rs, its touched_since test next to the
people-search test in tests.rs, and its 2026-10-14 What's New sections next to
this one. All kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:47:22 +05:30
Martin KurnothandClaude Fable 5.1 0f819854c0 Context menus: a right-click elsewhere on the same widget moves its open menu (#1877)
A right-click on a photo (or any widget) while its own context menu was open
flashed the menu at the new spot and closed it again on the same frame. egui's
`Popup::show` judges "was open last frame" by the popup's id, so the click that
reopened the menu at the pointer also counted as a click while open and closed
it under `CloseOnClick`. A right-click on a different widget was fine.

`widgets::context_menu` wraps `Popup::context_menu` and ignores clicks on the
frame of the right-click that opens or moves the menu; every context menu
(30 sites) goes through it, and `TextField` applies the same rule to its
close-on-click-outside menu (`widgets::context_menu_close`).

Tests: `right_clicking_elsewhere_moves_the_context_menu` (headless grid; failed
at the second frame before the fix) and `right_clicking_again_moves_the_menu`
(text field). docs/ui-parity.md: Context menus row and revision history.

Fixes #1877

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-15 20:01:23 +02:00
radialmonster 001943f01a LR-IMP-FORMATS: reject incomplete declared packed NEF strips 2026-10-15 12:23:37 -04:00
Codex d989d97334 fix(render): include image source paths in failure logs
Addresses #1890 for catalog-backed render slots. Preserve the original error and escape source paths in diagnostics.

Assisted-by: OpenAI Codex
2026-10-15 18:11:12 +02:00
G6billandCopilot App ee8eed1c76 KEY-CROP-SWAP: X in the crop tool swaps the aspect once (#1866)
X is bound to both photo.reject and crop.rotateAspect. In the crop tool
both bindings fired in the same frame and each ran crop.rotateAspect, so
the crop turned portrait and straight back to landscape: X looked like it
did nothing. Swap once when crop.rotateAspect fires itself.

Co-authored-by: Copilot App <[email protected]>
2026-10-15 17:15:02 +02:00
Zaxaerith 8109deb5ef docs(support): explain the Remove tool workflow (#1756) 2026-10-15 22:53:35 +08:00
Michael Anderson a663ec729f Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 09:08:52 -05:00
radialmonster 781e6126fd LR-IMP-FORMATS: bound packed NEF header probes and validate dimensions and strip bounds 2026-10-15 10:02:21 -04:00
radialmonster cc75f0b765 LR-RAW-DNG: preserve lens corrections in the existing DNG tone-fit proxy 2026-10-15 09:03:26 -04:00
radialmonster 6cbf6c7a9c LR-IMP-FORMATS: test Z 6II Packed 14 bits geometry (6064x4040, 10624-byte rows) 2026-10-15 08:25:08 -04:00
radialmonster 37fea78c14 LR-IMP-FORMATS: decode Coolpix NRWs stored as 16-bit big-endian words under 34713
The Coolpix P330, P7700 and P7800 NRWs carry compression 34713 with no maker
note 0x0096 table, 12 bits, one sample per pixel and a single strip of exactly
width * height * 2 bytes. The strip is one big-endian 16-bit word per pixel,
row-major with no header or padding; the sample is the word >> 4.

The layout was found by black-box analysis of the CC0 raw.pixls.us files
(strip byte-count identity, same-colour smoothness, correlation with the
embedded JPEG), not from any other converter's code.

The group is tried after the other packed layouts, so it cannot take a strip
they handle. Black level: these files have no 0x003d and no NRW block and no
masked pixels, so it is 200 (the floor of the P7800 frame is 201; the other
Coolpix NRWs state 200), never above the darkest sample. White comes from the
data. Other Nikon NEF/NRW files decode identically (278 checked).

Bumps RENDER_CACHE_VERSION to 52.
2026-10-15 08:25:08 -04:00
radialmonster 749636fb2e LR-IMP-FORMATS: decode table-less Nikon 34713 NEFs stored as packed 12/14-bit data 2026-10-15 08:25:08 -04:00
Alex ChambersandClaude Opus 5.5 aecdb99a7a LR-PROF-CAMERACOLOR: fit tone for near-black NEFs (#1587)
Use luminance pairs after the existing camera looks fail their colour-pair
minimum. Keep the source colour, exclude clipped pixels from training, and
check held-out luminance and each clipped pixel before accepting the curve.

The darkest supplied D610 sample changes from +2.81 to -0.09 stops at the
median. The other 15 renders are byte-identical. Add synthetic coverage for
source-colour precedence, independent holdout and invalid or clipped input.
Bump LOOK_VERSION to 10 and RENDER_CACHE_VERSION to 67 after checking open PRs.

Engine tests pass. Workspace tests pass with only the known #1457 overlay
test excluded. Formatting, Clippy, HEIC, parity, layers, assets and WASM pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:27:28 +02:00
Alex ChambersandClaude Opus 5.5 1be3b3d743 LR-EDIT-OPTICS-PROFILE: apply A7CR embedded distortion for corrected full-frame raws
Use the corrected-radius table convention for full-frame Bayer ILCE-7CR files with distortion Auto or forced Auto. Preserve Off files, unvalidated modes and the existing ILCE-7RM4A conversion.

Add mapping, setting, scope, padding, orientation, resizing and DNG checks. Record camera-JPEG geometry and color measurements, including the remaining wide-end edge residual. Refresh stored lens data with LOOK_VERSION 11 and rendered results with RENDER_CACHE_VERSION 68.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:21:19 +02:00
Alex ChambersandClaude Opus 5.5 d5a6d6c509 LR-PROF-CAMERACOLOR: fit the tone curve of low-contrast scenes (range limit 1.5 to 1.2)
Both tone fits refused a curve when the brightest 1/32 of the training
pairs was less than 1.5x the darkest 1/32, a hard cliff before any
acceptance gate: a hazy ILCE-7RM4 burst (issue #631) opened with the
neutral fallback on frames just under it (DSC01630: range 1.489, dE76 vs
the camera JPEG 23.8) and with a camera look on the frame just over it.
MIN_TONE_RANGE = 1.2 still refuses nearly uniform pairs.

A curve under the old 1.5 must also keep the bright areas (camera JPEG
luminance 0.85 and up), which the fit and the held-out gates leave out, no
further from the camera JPEG than the neutral fallback (highlights_hold,
full and partial looks): fitted to a narrow range it extrapolates them, and
reviews found synthetic frames whose accepted curve rendered a 0.93 sky at
0.28 to 0.61. The bright pixels come from the proxies before any training
filter, and for DRO photos the curve is checked lowered as it will be
used, inside the search. The tone curve of a black-and-white camera JPEG
(#577) comes from the same fit, so it gets the same check, by luminance as
its own gates compare: a synthetic frame's 0.93 sky rendered at 0.28
without it. DSC01630 (DRO Auto): look accepted, dE76 1.94. Every other
file of a 309-file sweep (135 public Sony) renders pixel-identical, also
with the camera JPEGs made grey. RENDER_CACHE_VERSION 63, LOOK_VERSION 6
(smart previews refit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:21:18 +02:00
Alex ChambersandCodex 484215f009 LR-IMP-FORMATS: discover Sony SR2 and SRF files in folders
Reuse the engine's import extensions in MCP and CLI discovery. Add
recursive, case-insensitive discovery tests and record that DSC-F828
SRF import remains preview-only.

Co-Authored-By: Codex <[email protected]>
2026-10-15 10:15:53 +02:00
Michael Anderson d9ac1229e3 Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	docs/whats-new.md
2026-10-15 01:02:42 -05:00
Patrik LermonandClaude Opus 5.5 9ce2804160 Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 2707 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:50:47 +02:00
Michael Anderson 07294185d8 Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:49:23 -05:00
Michael Anderson 290005a8e7 Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	crates/ui-egui/src/headless.rs
#	crates/ui-egui/src/import.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 23:34:05 -05:00
Michael Anderson f405052156 Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:56:46 -05:00
midasmatterhorn 8a8fe7e098 engine: keep the decoder facts a smart preview's offline edits need
A proxy stored the pixels and the camera tone curve only. An edit that
reads the file's own facts — a Kelvin white balance through the camera
colour model, Camera local tone mapping's gain table map, an AI mask's
semantic matte — rendered differently once the original went offline,
because the catalog's header facts cannot carry them.

The facts now ride in a tail after the JPEG (a length-prefixed JSON blob,
then one byte plane per matte), counted from the file's end so a header
rewritten in place keeps pointing at it; is_valid requires the JPEG to
end right before the tail. ENCODE_VERSION becomes 3, so proxies an older
build wrote are rebuilt when their original is readable (offline ones
keep their older look, as before). smart::encode now takes the source's
SourceInfo and decode returns the restored one.

Tests:
- tests_import::smart_previews_keep_the_decoder_facts_offline_edits_need:
  a proxy's own pixels and facts render bit-identically offline for a
  Kelvin white balance (6144/6144 pixels differ before), a stored sky
  matte, and a gain table map
- smart::tests::facts_and_mattes_travel_with_the_proxy: the facts and
  mattes round-trip; a proxy cut inside its tail is invalid; an old
  proxy, and one whose header lies about the tail, read as no facts
2026-10-15 00:42:03 +03:00
Michael Anderson 40050b7bd5 LR-IMP-LOCAL: Record folders browsed from the sidebar as browsed
The sidebar reads a folder on a worker thread and never stamped it like library.browse does, so folders in daily use were forgotten after the set number of days.
2026-10-14 10:32:35 -05:00
Andrew FrahnandClaude Opus 5.5 b45ac5e659 Merge upstream/main into pr/longtail-raws
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:22:33 +11:00
Andrew FrahnandClaude Opus 5.5 a706798951 KEY-SLIDER-TYPE: a long click keeps the value field open; drag the value to scrub
A press on a slider's value held past egui's click time ended with no click on
release, so the field it had opened closed again (the field "flashes and
vanishes"). Any release now keeps the field open; a sideways drag of more than
a few points on the value scrubs it instead (as along the track, a tenth with
Shift, one undo step), as in Lightroom Classic.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:58:02 +11:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 635d7d280d Merge main into feat/1449-people-search
main now keeps one 2026-10-13 section in What's New; this entry moves to the
2026-10-14 section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:38:57 +05:30
Andrew FrahnandClaude Opus 5.5 6696a92005 Merge upstream/main into pr/longtail-raws
Docs conflicts resolved on upstream's text; MRW edits reapplied with
coverage re-measured by docs/raw/coverage.py (243 verified, 946
unverified, 221 preview). Drops stale duplicate rows upstream's docs
carried in raw-parity.md and gaps.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 08:07:58 +11:00
Abdullah Abo El-HijaandClaude Opus 5.5 e22b173ecd #1617: placeholder GPS block (NUL refs, 0/0 rationals) reads as no location
Phones without a fix write a GPS IFD whose refs are NUL and whose rationals
are 0/0. dms() read the triplet through to_f64_vec, which turns 0/0 into
0.0, so the block became 0° N 0° E and exports wrote that location.

dms() now rejects a component that is present but not a number, and
read_gps() requires an N/S and E/W ref.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:18:59 +03:00
DUY Tran Nhat (SAE-KIDS)andCursor ebfecdad7b fix(i18n): polish Vietnamese terminology and parity status
Align Shutter Speed / Smart Preview wording, fix glossary Tint→Tông, and
mark Vietnamese as catalog-complete but partial while lower-layer errors
remain English.

Co-authored-by: Cursor <[email protected]>
2026-10-13 18:08:41 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 07c8e4e68e docs(i18n): document Vietnamese localization
Co-authored-by: Cursor <[email protected]>
2026-10-13 17:13:29 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 9e558917e8 fix(i18n): polish Vietnamese wording and Settings strings
Co-authored-by: Cursor <[email protected]>
2026-10-13 17:13:29 +07:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 70c5cdc285 Issue #1449: Search Photos finds named people, and quotes keep a name together (steps 1 and 2)
Free-text tokens now also match Photo::people(), so a person's name typed in
Search Photos finds the photos with that named face. The search text is split
by a quote-aware tokenizer instead of split_whitespace(), so person:"Jane Doe",
who:"Jane Doe", "jane doe" and keyword:"new york" are one token each. An
unclosed quote runs to the end of the text, which can only narrow the match.
The tokenizer makes one pass and allocates nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:45:58 +05:30
DUY Tran Nhat (SAE-KIDS)andCursor bb1bd776d8 test(i18n): enforce complete Vietnamese localization
Co-authored-by: Cursor <[email protected]>
2026-10-13 16:13:07 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 353e1241bc i18n(vi): register Vietnamese and translate formatted messages
Co-authored-by: Cursor <[email protected]>
2026-10-13 15:59:49 +07:00
DUY Tran Nhat (SAE-KIDS) 84e9e909ed i18n(vi): complete help and residual catalog strings 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) 33527d26ef i18n(vi): translate export merge and advanced workflows 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) a1c85d4043 i18n(vi): translate presets profiles and history 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) 3eec232f69 i18n(vi): translate masking and remove workflows 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) dfa63f2b4d i18n(vi): translate crop geometry and detail controls 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) 462c47dc73 i18n(vi): translate effects and tone controls 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) 67722fb637 i18n(vi): translate light and color controls 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS) 1fb7bad093 i18n(vi): translate import workflows 2026-10-13 15:54:00 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 662ddbd2a6 i18n(vi): translate organization and metadata workflows
Co-authored-by: Cursor <[email protected]>
2026-10-13 15:45:32 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 815febed6a i18n(vi): translate library and photo navigation
Co-authored-by: Cursor <[email protected]>
2026-10-13 15:43:54 +07:00
DUY Tran Nhat (SAE-KIDS)andCursor 2247b9a386 i18n(vi): translate app shell and navigation
Co-authored-by: Cursor <[email protected]>
2026-10-13 15:42:57 +07:00
null 00000111 d3e56df54a Add a security policy 2026-10-12 23:50:09 -07:00
radialmonster 71fb91daeb LR-IMP-FORMATS: RW2 format 8 decodes tiled images (DC-S1RM2 high-resolution mode, tag 0x0043 = 2) (#1276)
The high-resolution mode of the DC-S1RM2 (16296 x 10856) tiles the image in two
rows of four. Tag 0x0045 holds (x, y) u16 pairs per tile (y was always 0 in
single-row files, where the old 32-bit reading gave the same numbers), and tag
0x0043 is the number of tile rows. The decoder now takes each tile's position
from the lists, checks that the tiles lie inside the image, do not overlap and
cover it exactly, and that 0x0043 equals the number of distinct tile tops.
Tiles are decoded like strips. Other tag values are still refused.
2026-10-12 12:31:37 -04:00
Vikram Bhaskaran 8bdde1ad7b Ignore hidden and empty masks when sizing develop planes. 2026-10-13 01:24:21 +11:00
Andrew FrahnandClaude Opus 5.5 7361cb222c LR-IMP-FORMATS: MRW gets the file-local look fitted to its preview
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:15:53 +11:00
Andrew FrahnandClaude Opus 5.5 d6c29aaf25 LR-IMP-FORMATS: decode Minolta MRW (packed 12-bit and 16-bit words, RGGB / GBRG)
The \0MRM block container: PRD sensor size, storage and Bayer cell; WBG
as-shot gains in cell order; TTW TIFF for Exif and orientation. Black 0,
white from the saturation plateau. 4 CC0 samples pinned in RAW_SAMPLES;
DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified. Also fixes
clippy::chunks_exact_to_as_chunks in pipeline/spots.rs.

Sources: public prose description of the MRW layout (Dalibor Jelinek's
MRW notes) and own black-box analysis of the CC0 samples.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:18:59 +11:00
226 changed files with 24071 additions and 1183 deletions

No files matched your search

+1
View File
@@ -62,6 +62,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
+1
View File
@@ -67,6 +67,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
Generated
+46 -33
View File
@@ -936,6 +936,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -3078,8 +3087,9 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"eframe",
"egui",
"lightcraft-codecs",
@@ -3103,8 +3113,9 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
@@ -3120,7 +3131,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3136,7 +3147,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3167,7 +3178,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3176,7 +3187,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3193,7 +3204,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3208,7 +3219,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3220,9 +3231,10 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"ab_glyph",
"chrono",
"craft-docpath",
"harfrust",
"jxl-oxide",
@@ -3263,7 +3275,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
@@ -3278,7 +3290,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"log",
"rustls",
@@ -3289,7 +3301,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3297,7 +3309,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -3305,7 +3317,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3325,7 +3337,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3333,14 +3345,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3349,7 +3361,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3360,7 +3372,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3376,8 +3388,9 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"lightcraft-geom",
"lightcraft-tiff",
"proptest",
@@ -3388,7 +3401,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3406,7 +3419,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3418,7 +3431,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3430,7 +3443,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"dhat",
"jpeg-encoder",
@@ -3454,7 +3467,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3465,7 +3478,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3479,7 +3492,7 @@ dependencies = [
[[package]]
name = "lightcraft-slideshow"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-raster",
"serde",
@@ -3488,15 +3501,15 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.11.0"
version = "0.13.0"
[[package]]
name = "lightcraft-testkit"
version = "0.11.0"
version = "0.13.0"
[[package]]
name = "lightcraft-tiff"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -3505,7 +3518,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"craft-ui",
"egui",
@@ -3534,7 +3547,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"crc32fast",
"eframe",
@@ -7004,7 +7017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"brotli",
"flate2",
+3 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -49,6 +49,8 @@ sha2 = "0.10"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
bytemuck = { version = "1", features = ["derive"] }
+9 -3
View File
@@ -8,13 +8,18 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png" width="128" height="128" alt="LightCraft icon: an engraved lynx on an amber tile">
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
@@ -212,7 +217,8 @@ Then tone it with Color Grading for selenium, sepia or split-tone prints.
A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains person:"Jane Doe"`; plain words also match
the people named on faces, and quotes keep several words together. Every view sorts by capture date,
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
so they stay smooth whether you have forty photos or forty thousand.
@@ -451,7 +457,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული, Svenska, Suomi, Bahasa Indonesia, Tiếng Việt) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+2 -1
View File
@@ -14,7 +14,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
+629
View File
@@ -0,0 +1,629 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
<b>照片庫與 raw 顯影;以淨室(clean-room)方式、用純 Rust 重新實作的開源 Adobe Lightroom。</b><br>
在 macOS、Windows 與 Linux 上原生執行,也能透過 WebAssembly 在瀏覽器中使用,AI 代理也能透過 MCP 全程操作。
</p>
<p align="center">
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<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>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">所有 Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的編輯檢視畫面:放大檢視中是安瑟·亞當斯的《提頓山脈與蛇河》,右側開啟著「光線」與「效果」面板,底片顯示窗格中是四張展示照片" width="100%">
<br>
<sub><i>安瑟·亞當斯,《提頓山脈與蛇河》(1942)。公眾領域,美國國家檔案館。於 LightCraft 中顯影。</i></sub>
</p>
> [!NOTE]
> **ArtCraft 是一個由各行各業的創作者組成的社群。** 數位藝術、生成藝術、音樂、
> 遊戲 &mdash; 只要你在創作,你就是我們的一員。**[來 Discord 打聲招呼吧](https://discord.gg/artcraft)。**
<p align="center">
<a href="#edit-like-you-mean-it">編輯</a> ·
<a href="#color-grading-the-cinematic-way">色彩分級</a> ·
<a href="#before--after">編輯前/後對比</a> ·
<a href="#masking-that-goes-where-you-point">遮色片</a> ·
<a href="#presets-profiles--the-color-mixer">預設集</a> ·
<a href="#organize-everything">照片庫</a> ·
<a href="#built-for-agents">代理與 MCP</a> ·
<a href="#fast-native-private">效能</a> ·
<a href="#feature-status">功能狀態</a> ·
<a href="#quick-start">快速開始</a> ·
<a href="ROADMAP.md">路線圖</a> ·
<a href="#downloads">下載</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
## Edit like you mean it
LightCraft 是裝在單一原生應用程式裡的完整暗房。每一項調整都是**非破壞性**的,永遠不會動到原始檔案。
每個滑桿都經過一條**場景參考、寬色域、32 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈,
色彩從拍攝到匯出始終保持乾淨。
<table>
<tr>
<td width="50%" valign="top">
### ☀️ 光線
**曝光、對比、亮部、陰影、白色色階、黑色色階**,搭配邊緣感知的局部色調映射(對對數亮度做引導式濾波)。
把亮部拉到 −100,就能救回一片過曝的天空,而且不會出現用簡單曲線時常見的灰色光暈。
### 🎨 色彩
依**色溫**與**色調**調整**白平衡**(raw 用 K 值,JPEG 用相對值),附帶預設、自動,以及點一下就能中和的**滴管**。
保護膚色的**自然飽和度**、**飽和度**、8 段式**混色器**(色相/飽和度/明度),以及附帶混合與平衡的
三區**色彩分級**色輪。所有運算都在 OkLCh 這個現代感知色彩空間中完成。
</td>
<td width="50%" valign="top">
### ✨ 效果
讓細節更細緻的**紋理**、讓中間調更有力的**清晰度**、**去朦朧**(暗通道先驗加引導式細化;
推到負值可增添氛圍感)、裁切後附帶亮部優先、圓度與羽化的**暗角**,以及解析度無關、可調大小與粗糙度的
底片**顆粒**。
### 📈 色調曲線
可移動分界點的參數曲線(區域曲線),**再加上** RGB、紅、綠、藍的點曲線。曲線在結構上就是單調的,
所以不會意外出現色調反轉。
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="「光線」面板下方開啟的色調曲線,RGB 點曲線上是一條平緩的 S 形曲線,套用在《提頓山脈與蛇河》上" width="100%">
<br>
<sub>RGB 點曲線上一條平緩的 S 形曲線,就在「光線」滑桿正下方。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub>
</p>
<br>
## Color grading, the cinematic way
用可隨處拖曳的色輪,分別為陰影、中間調與亮部做分離色調。下圖中,桃樂絲·蘭格的
*《移民母親》* 只拖曳兩次,就調出溫暖、如沖印照片般的色調(亮部 42°,陰影 28°)。
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="中間調、陰影與亮部的色彩分級色輪,旁邊是桃樂絲·蘭格的《移民母親》,調成溫暖的沖印色調" width="100%">
<br>
<sub>「色彩」面板中的陰影、中間調與亮部色輪。<i>桃樂絲·蘭格,《移民母親》(1936)。公眾領域,美國國會圖書館。</i></sub>
</p>
<br>
## Before & after
按住 <kbd>\\</kbd> 偷看原圖,按 <kbd>Y</kbd> 並排比較,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分割檢視。
下面每一張都是 LightCraft 的真實截圖,由代理透過[控制通道](#built-for-agents)自動擷取。
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《提頓山脈與蛇河》的並排編輯前/後對比:編輯後的雲層更深,河岸邊的暗部更通透"><br><sub><b>《提頓山脈與蛇河》。</b> 亮部 −45、陰影 +38、清晰度 +28、去朦朧 +18。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母親》的並排編輯前/後對比:編輯後更溫暖,陰影被提亮"><br><sub><b>《移民母親》。</b> 陰影 +42、紋理 +18、分離色調分級、暗角。<i>桃樂絲·蘭格,1936(公眾領域)。</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的並排編輯前/後對比:月球地平線上方是地球,編輯後略暖、更濃郁"><br><sub><b>《地出》。</b> 去朦朧 +22、亮部 −30、更暖的白平衡、自然飽和度 +22。<i>NASA/比爾·安德斯,阿波羅 8 號,1968(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《藍色大理石》的並排編輯前/後對比:編輯後黑色更深,雲層細節更扎實"><br><sub><b>《藍色大理石》。</b> 亮部 −38、黑色色階 −20、去朦朧 +15、自然飽和度 +30。<i>NASA,阿波羅 17 號,1972(公眾領域)。</i></sub></td>
</tr>
</table>
<br>
## Masking that goes where you point
用**筆刷**塗抹(大小、羽化、流量、濃度、擦除),放置附可拖曳控制點的**線性漸層**與**放射狀漸層**,
或依**明度範圍**、**色彩範圍**、**天空**、**主體**與**背景**來選取。用**新增/減去/交集**組合各個元件,
反轉其中任何一個,每個遮色片可設定 15 項局部調整(色溫、色調、曝光、對比、亮部、陰影、白色色階、黑色色階、紋理、
清晰度、去朦朧、色相、飽和度、銳利化、雜訊抑制),再加上一個整體的強度。
<p align="center">
<img src="docs/images/masking.jpg" alt="遮色片面板中有一個線性天空遮色片與一個放射狀陽光光暈遮色片;放射狀漸層在湖景照片上以紅色疊加層畫在太陽周圍" width="100%">
<br>
<sub>疊加在線性「天空」遮色片之上的放射狀「陽光光暈」遮色片(色溫 +40、曝光 +0.50)。<i>照片來自 LightCraft 程式產生的示範照片庫。</i></sub>
</p>
<br>
## Presets, profiles & the Color Mixer
內建十八個手工打造的預設集(*黃金時刻、青綠與橙色、褪色霧面、硒調色、清晰風景*等),每個都有 0 到 200 % 的**強度**滑桿。你可以從任意一組設定儲存自己的預設集、加入喜好項目,
並在整個選取範圍中,只複製、貼上或同步你選定的那些群組。
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="預設集欄依黑白、色彩、底片、風景、人像與樣式分組,旁邊是「色彩」面板,8 段式混色器開啟在紫色上" width="100%">
<br>
<sub>預設集欄與 8 段式混色器並排。<i>照片來自示範照片庫。</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ 裁切、拉直與幾何
自由或鎖定的長寬比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖曳旋轉拉直時會始終把最大的裁切框
保持在畫面內,三分法/格線/黃金比例裁切疊加,翻轉與 90° 旋轉,再加上手動的垂直、水平、旋轉、長寬比、
縮放與偏移變換。
</td>
<td width="50%" valign="top">
### ⚫️ 黑白
一鍵(<kbd>V</kbd>)轉為單色,搭配 8 段式**黑白混色**,可壓暗天空或讓樹葉發亮。
之後再用色彩分級調出硒調色、深褐色調或分離色調的沖印效果。
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《提頓山脈與蛇河》上的裁切工具:一個略微旋轉的 16:9 裁切框,附三分法裁切疊加,右側是幾何滑桿"><br><sub>一個 16:9 的裁切框拉直了 +2.5°,附三分法裁切疊加。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的編輯前/後分割檢視:左邊是彩色,右邊是附顆粒與暗角的黑白"><br><sub>分割檢視:左邊是彩色原圖,右邊是附清晰度、暗角與顆粒的黑白。<i>示範照片庫。</i></sub></td>
</tr>
</table>
<br>
## Organize everything
一個不擋路的照片庫:**所有照片**、**最近加入**、**精選照片**、**依日期**、以巢狀收在**資料夾**裡的**相簿**,
以及**最近刪除**。用 <kbd>0</kbd>–<kbd>5</kbd> 評分,用 <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd> 加旗標,用 <kbd>6</kbd>–<kbd>9</kbd> 加色彩標籤。搜尋支援欄位語法:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每個檢視都能依拍攝日期、
匯入日期、編輯日期、名稱、評分、大小或隨機排序(一種穩定的亂序;「檢視 → 排序 → 重新隨機排序」可換一個順序)。
左右對齊的**照片格狀檢視**與**方格檢視**都經過虛擬化處理,
所以無論是四十張還是四萬張照片都一樣流暢。
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 張示範照片的左右對齊照片格狀檢視,附評分與旗標,側邊欄是 Travel 2026 資料夾下的巢狀相簿"><br><sub>照片格狀檢視,相簿以巢狀收在資料夾裡,附評分與旗標。<i>示範照片庫。</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="方格檢視,顯示四張公眾領域展示照片及其星級評分與精選旗標"><br><sub>四張公眾領域展示照片的方格檢視。</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的資訊面板,顯示檔名、2400 × 2400 的 JPEG 尺寸、評分、標題欄位與相機中繼資料"><br><sub>資訊面板:檔案、尺寸、評分、標題、圖說、著作權與相機中繼資料。<i>NASA/比爾·安德斯,《地出》,阿波羅 8 號,1968(公眾領域)。</i></sub></td>
</tr>
</table>
<br>
## Built for agents
LightCraft 裡的每一個選單項目、滑桿、筆刷筆觸、裁切控點與按鍵,都是一個擁有穩定 ID 與
JSON 參數的**指令**。介面、鍵盤、CLI、JSON-lines 控制通道與一個 **MCP 伺服器**全都透過同一個入口分派。
代理可以挑片、顯影、為天空加上遮色片並匯出,而且能*看到*結果。
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 依名稱操作任意控制項
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **指令註冊表。** `engine.commands` 列出可用的指令;`develop.controls` 列出每個滑桿的範圍、
預設值與目前值。
- **MCP 伺服器。** `lightcraft-cli mcp` 把指令註冊表開放給 Claude(或任何 MCP 用戶端),
同時提供匯入、查詢、顯影、遮色片、算繪(以影像回傳)與匯出的輔助工具。它可以無介面執行,也可以附掛
到正在執行的應用程式上,支援截圖、點擊與手勢。詳見 [docs/mcp.md](docs/mcp.md)。
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 無介面
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 操作執行中的應用程式
```
- **可腳本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
path=out.jpg longEdge=2048` 可執行任意指令鏈(無介面、對已儲存的照片庫,或對執行中的應用程式),
每條指令印出一行 JSON 結果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
- **一切皆可復原**,包括代理的操作:一次滑桿拖曳(或腳本中一連串的更新)算一步復原。
- **縮圖目錄(原生應用程式/CLI):** 選取照片,然後 **檔案 → 縮圖目錄 PDF…**。可選擇 A4 或 Letter、橫向、
列數與欄數,以及檔名圖說;儲存分頁 PDF,再用你的 PDF 檢視器列印。匯出會套用目前的編輯,每張照片完整放入
而不再次裁切,以 150 dpi 和 sRGB 算繪。背景匯出支援取消,並保護原始檔案與 XMP 附屬檔案。從 CLI 呼叫:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` 也接受明確的 `ids`、`landscape=true` 和 `paper=letter`。只有在整份 PDF 成功產生之後,
它才會取代既有的輸出檔案。限制:1,000 張照片、100 頁、256 MiB;圖說使用與浮水印相同的字型涵蓋範圍來點陣化
(CJK 文字需要 craft-fonts),過長的名稱會在中間省略。
這是一套縮圖目錄工作流程,
不是印表機驅動程式或自訂拼版編輯器。
- **列印版面(原生應用程式/CLI):** **檔案 → 列印…** 會開啟「列印」模組的版面編輯器:單張照片、
縮圖目錄、相片包(自動版面會把格子排進頁面)與自訂相片包(拖曳並調整格子大小、堆疊順序、對齊格子或
格線),縮放填滿/旋轉以符合、相片邊框與內框線,紙張尺寸從 4 × 6 到 13 × 19 以及 A3 到 A5 或自訂,
邊界、以英吋/公分/公釐/點為單位的尺規、頁面背景、72–720 ppi,以及已儲存的範本。**列印為 PDF…**
會在背景算繪。一切都是 `print.*` 指令
(`print.get`、`print.set`、`print.cell.add`、`print.layout.autoLayout`、`print.exportPdf`、
`print.template.save`……),所以 CLI 和 MCP 也能操作它:
```sh
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
印表機色彩管理與頁面疊加層(身分標識、浮水印、頁碼)目前還沒有。
- **每個控制項都可定址**(`ui.widgets`)且可依名稱點擊,所以代理操作的是真實介面,而不是走旁門。
- 本 README 中的截圖全部由 [`docs/showcase/`](docs/showcase/) 腳本全自動產生。
協定參考:[docs/control-protocol.md](docs/control-protocol.md)。
<br>
## Fast, native, private
- **純 Rust,無 C。** 我們自己的 RAW 解碼器(DNG、Canon CR2/CR3、Sony ARW、Nikon NEF、Fujifilm RAF 含 X-Trans、
Panasonic RW2/Leica RWL、Pentax PEF、Olympus ORF),我們自己的色彩科學,我們自己的管線。JPEG、PNG、TIFF、WebP、
PSD 合成圖與 JPEG XL 現在就能開啟;加上 `--features heif` 還能開啟 HEIC/HEIF 照片(HEVC 於建置時選用,
與 PhotoCraft 中一致)。
- **場景參考與寬色域。** 內部使用線性 Rec.2020 浮點、Bradford 色彩適應的白平衡、以色域映射取代裁剪,
raw 走底片式肩部曲線,未經改動的 JPEG 則採像素級直通。
- **解析度無關的編輯。** 半徑與筆刷大小都相對於影像,所以 400 px 的預覽、你的 5K 顯示器和 60 MP 的匯出
看起來完全一致。
- **GPU 加速,與 CPU 結果一致。** 整條顯影管線都以 wgpu 計算核心執行(Metal/Vulkan/DX12),
並與 CPU 管線比對到 1/255 以內的誤差。在 24 MP 的 raw 上(Apple M4 Pro):一次滑桿更新約 4 ms 重新算繪,
冷啟動的 2.5 MP 放大檢視約 30 ms,全尺寸匯出約 0.3 秒(含平行 JPEG 編碼)。
沒有 GPU 時,同一條管線會在所有 CPU 核心上執行,只重做受滑桿影響的那些階段。批次匯出會同時算繪多張照片,
所以一張的解碼與編碼能和另一張的算繪重疊。
- **即時挑片。** 開啟一張 raw 會在約 0.1 秒內顯示其內嵌的相機預覽或快取算繪結果,完整算繪隨後跟上
(24 MP 約 0.2–0.5 秒)。上一張與下一張會在背景準備好,因此瀏覽整批照片時每張約 50 ms。
- **背景算繪。** 一個工作執行緒池在 UI 執行緒之外算繪放大檢視、編輯前/後對比與每一個可見縮圖:
拖曳時用草稿品質,放開後以完整品質算繪。
- **本機優先。** 沒有帳號、沒有雲端、沒有遙測、沒有訂閱。你的目錄是一份只會附加寫入、可讀的操作紀錄,
可以比對差異、備份或重播。
<br>
## Feature status
LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,我們目前的進度是**(細節見[路線圖](ROADMAP.md)和
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **依功能數量計算,約達 Lightroom 的 81%**(核心功能 98%),逐行記錄在
[docs/parity-checklist.md](docs/parity-checklist.md)。
- **作為日常取代 Lightroom 的工具,整體產品更接近 61%,對一般全職攝影師約 51%,一般休閒用途約 65%。**
在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
- **最大的缺口:**
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
- **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
- **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **螢幕上的 HDR 顯示、影片,以及 Lightroom Classic 的列印、書冊與地圖模組。**
- **接下來做什麼:** 見[依影響力排序的缺口](docs/gaps.md)和[路線圖](docs/roadmap.md)。
| 領域 | 狀態 |
|---|---|
| 照片庫:相簿、資料夾、智慧型相簿、堆疊(含自動堆疊)、虛擬副本、評分、旗標、標籤、篩選列、搜尋、排序、格狀檢視、底片顯示窗格 | ✅ |
| 挑片:比較(同步縮放)與總覽檢視、自動前進、即時預覽 | ✅ |
| 光線、色彩、效果(暗角樣式)、色調曲線(+ 細化飽和度、目標調整)、混色器(+ 目標調整)、點色彩、色彩分級、校正、黑白 | ✅ |
| 遮色片:筆刷、線性、放射狀、明度/色彩範圍、新增/減去/交集 | ✅(AI 主體/天空目前使用傳統啟發式演算法) |
| 裁切、拉直工具 + 自動拉直、翻轉、旋轉、長寬比、裁切疊加參考線 | ✅ |
| 描述檔(色彩、中性、鮮豔、風景、人像、單色:我們自己的風格)、預設集、版本、歷史紀錄、複製/貼上/同步設定 | ✅ |
| 相機色彩:DNG 檔案使用自帶矩陣 | ✅ DNG · 🚧 自家 Sony/Fujifilm 描述檔;缺少實測校正資料庫 |
| 原生 macOS 選單列(由指令註冊表產生)、控制通道 + 每個控制項可定址、無介面 UI 快照 | ✅ |
| RAW:DNG、CR2、CR3(無損 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未壓縮 + 無損/有損壓縮,含有損壓縮經分割後的格式)、Fujifilm RAF(未壓縮 + 無損/有損壓縮,Bayer + X-Trans)、Panasonic RW2/Leica RWL/Panasonic RAW(所有 raw 格式,從 DMC-LX1 到 DC-S1RM2)、Pentax PEF、Olympus ORF(未壓縮,12.8 bpp)、Samsung SRW(未壓縮);所有格式皆支援內嵌預覽,含 CR3 | 🚧 · 其他 CR3 變體、壓縮 ORF/SRW 和 Nikon HE NEF 解碼 ⬜([raw 相容度](docs/raw-parity.md)) |
| 細節:銳利化、明度 + 色彩降噪 | ✅ · AI 降噪 🚧(Bayer,使用者自行安裝權重)、超解析度 ⬜ |
| 斑點移除/修復/仿製(自動取樣來源)、顯示斑點、紅眼與寵物眼校正(自動瞳孔偵測、眼神光) | ✅ · 內容感知填色、斑點圖釘編輯 🚧 |
| 匯出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸調整、檔案大小上限、輸出銳利化、命名範本、批次處理、中繼資料政策、文字或影像浮水印 | ✅ |
| HDR:附餘裕上限的 HDR 編輯、SDR 呈現、視覺化 HDR;匯出為 ISO 21496-1 增益圖 JPEG、PQ AVIF 或 32 位元浮點 TIFF | ✅ · HDR 顯示 ⬜ |
| 照片庫持久化(防當機的操作紀錄 + 快照、背景壓縮、儲存失敗會回報)、磁碟縮圖快取 | ✅ |
| 匯入:原位置加入/複製/移動、重新命名與資料夾範本、裝置、重複檢測、監看資料夾;本機資料夾瀏覽 | ✅ |
| MCP 伺服器(無介面或執行中的應用程式、持久化照片庫)、CLI、控制通道 | ✅ |
| XMP 附屬檔案(讀取/寫入、自動寫入)、讀取 `crs:` 顯影設定、預設集檔案(`.lcpreset`、XMP 預設集) | ✅ |
| 鏡頭校正(變形、暗角、自動 + 手動色差、去除邊緣色差、內嵌於 DNG 檔案的鏡頭校正資料、Panasonic/Leica RW2/RWL 變形資料以及 Nikon Z 6II/Z 8/Z f 變形資料)、幾何(變換、限制裁切範圍)、透視校正(自動/水平/垂直/完整/引導式) | ✅ · 相機鏡頭描述檔(我們自己的)⬜ |
| 照片合併:HDR(自動對齊、去除殘影)、全景(球面/圓柱/透視、邊界變形、自動裁切)、HDR 全景 → DNG | ✅ |
| GPU 管線(wgpu 計算,與 CPU 一致到 1/255 以內)、裝置限制/發生錯誤時改用 CPU | ✅ · 瀏覽器中的 WebGPU 🚧 |
| AI:分割遮色片、AI 降噪、超解析度、人臉;影片 | 🚧(見[缺口清單](docs/gaps.md)) |
| 網頁版(透過 WASM 在瀏覽器中使用同一套介面):照片庫持久化於 OPFS/IndexedDB、Web Worker 算繪、匯出下載 | ✅ · WebGPU、Safari/Firefox 測試 🚧 |
<sub>✅ 現已可用 · 🚧 進行中 · ⬜ 尚未開始</sub>
<br>
## Quick start
建置整個工作區(含桌面、網頁與 CLI 應用程式)需要 Rust 1.95 或更新版本。
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # 開啟你的照片庫(~/Pictures/LightCraft Library;新照片庫會先帶示範照片)
cargo run --release -p lightcraft -- ~/Pictures/trip # 匯入你的照片(掃描資料夾,跳過重複項目)
cargo run --release -p lightcraft -- --memory # 用完即丟的記憶體示範工作階段(不寫入任何內容)
cargo run --release -p lightcraft -- --control 7980 # 附帶自動化通道
cargo xtask web --serve # 在瀏覽器中執行同一個應用程式:http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt、clippy、測試、分層、wasm 檢查
```
CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開一個建置工作
(每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置,
而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑
一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 與 `RUST_TEST_THREADS` 會覆寫這些預設值。
日常開發指令保留各自的設定。
**記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
回報在一台 16 GB 的 Arch/AUR 機器上,同時建置桌面應用程式與 CLI,並啟用完整除錯資訊、thin LTO
及單一程式碼產生單元時,連結過程遭到 OOM 終止。上述 CI 預設值並不適用於一般的
`cargo build --release`。建議先用一個建置工作;若記憶體充足,可嘗試兩個:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
如果打包環境啟用了完整除錯資訊或較耗記憶體的 LTO 設定,可以使用僅對本條指令生效的
覆寫設定,降低原始碼建置所需的記憶體:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
這些 POSIX shell 賦值只影響這一條指令,不會變更全域 Cargo 設定或儲存庫的 release profile。
停用 LTO 可能改變效能與二進位檔大小;停用除錯資訊會減少可用的除錯細節。這是建置建議,
並不保證任何機器都有足夠的記憶體。下游打包者應逐一建置大型二進位檔、不要同時進行,並避免在低記憶體
的建置機器上同時使用完整除錯資訊、LTO 與單一程式碼產生單元。自行匯出 profile 設定的
PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替
`makepkg` 加上環境變數前綴就能優先生效。
測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」
這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因,
執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項
跳過的原因。它會寫入 `target/ci/report.json` 和 `target/ci/summary.md`,後者是可直接貼進 PR 的
Markdown 區塊(`--summary-md` 會把它印出來);`--hardware` 還會在每個有實體配接器的後端上執行
GPU 測試。不會上傳任何內容。
**中文與日文文字**需要共用字型儲存庫,這是一個可選的建置輸入(所有正式發布版都會包含它):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
不指定它時,LightCraft 一樣能建置與執行,只是中文和日文文字沒有字形。字型從不提交到
本儲存庫,字型的收錄規範記載在該儲存庫:見 [craft-fonts README](https://github.com/storytold/craft-fonts#readme)。
**介面語言:** **編輯 → 語言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français、Українська、Čeština、ქართული)或 **設定 → 一般 →
語言**;選擇後立即生效並會保留。詳見 [docs/localization.md](docs/localization.md)。
**記錄檔:** 桌面應用程式會把記錄檔寫到標準錯誤輸出,以及設定資料夾裡的 `logs/lightcraft.log`
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,預設為 `~/.config/lightcraft/logs/`;macOS
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),絕不會寫進照片庫裡。
**可攜模式:** 在執行檔旁放一個名為 `portable.txt`(或 `LightCraft.portable`)的檔案,會把設定
資料夾移到執行檔旁的 `LightCraftData`。從桌面選單或 Dock 啟動沒有終端機,所以請把這個檔案附在
錯誤回報裡(**說明 ▸ 開啟記錄資料夾** 會在檔案管理員中顯示它)。每次啟動會把
上一個記錄檔移到 `lightcraft.1.log`,再上一個移到 `lightcraft.2.log`,這樣當機那次的記錄檔就能在下次啟動後
保留下來;檔案到 16 MiB 就會停止增長,`--version` 和 `--help` 不會寫記錄檔,帶
`LIGHTCRAFT_NO_PREFS` 執行時只會寫到標準錯誤輸出。預設情況下,LightCraft 自己的 crate 會記錄到 `info`
層級,其他一切則記錄到 `warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行為和以前一樣(LightCraft 自己的 crate
用該層級,其餘只記警告與錯誤;其他值:只記警告與錯誤),並且優先於 `RUST_LOG`;未設定 `LIGHTCRAFT_LOG` 時,`RUST_LOG` 會把預設值換成
env_logger 風格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
`*` 結尾的指令會涵蓋所有以它開頭的目標,如 `lightcraft*=debug`)。panic 一樣會記錄在那裡,
並且仍然會寫在暫存資料夾裡的 `lightcraft-panics.log`。記錄器程式碼位於 `apps/lightcraft/src/logging.rs`。
網頁版需要 `wasm32-unknown-unknown` target 以及對應版本的 `wasm-bindgen` CLI
(`cargo xtask web` 會印出確切的安裝指令);詳見 [docs/web.md](docs/web.md)。
**Nix** 會建置桌面應用程式與 `lightcraft-cli`(Nix 建置永遠包含 craft-fonts 輸入,所以日文
文字有字形):
```sh
nix run github:storytold/lightcraft # 桌面應用程式
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust 工具鏈 + 原生相依套件 + 字型
```
在 NixOS 上,請從 `nix develop` 執行 `cargo` 建置:在它之外,應用程式找不到 `libvulkan.so.1`,
偵測不到 GPU,會改用 CPU 算繪(啟動時會印出一次提示,說明這樣會慢很多)。`nix run` 和 `nix build`
會自行設定好路徑。
在 flake 設定中(NixOS、home-manager、nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# 選用:使用你自己的 nixpkgs,而不是 LightCraft 鎖定的那個
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# 然後在 NixOS 或 home-manager 模組中(`inputs` 在作用範圍內):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 讓 `pkgs.lightcraft` 可用
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
```
`nix build` 會安裝與 .deb/.rpm 相同的 desktop 檔案、hicolor 圖示和 AppStream 中繼資料,並執行
`cargo test --workspace` 作為其檢查階段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳過)。
**鍵盤:** <kbd>G</kbd> 格狀檢視 · <kbd>D</kbd> 詳細檢視 · <kbd>E</kbd> 編輯 · <kbd>C</kbd> 裁切 · <kbd>M</kbd> 遮色片 ·
<kbd>Shift</kbd>+<kbd>P</kbd> 預設集 · <kbd>\\</kbd> 原圖 · <kbd>Y</kbd> 編輯前/後 · <kbd>Z</kbd> 縮放 ·
<kbd>J</kbd> 顯示溢色 · <kbd>⌘Z</kbd> 復原 · <kbd>⌘/</kbd> 全部快速鍵。
## How it's built
一個引擎優先的 Cargo 工作區,由一群小而經過測試的 crate 組成,並強制分層(`cargo xtask layers`):`geom`、
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI 的存在。`mcp` 和各個應用程式(`lightcraft`、
`lightcraft-cli`)則坐落在 `engine` 之上。
## Contributing
人類與代理遵循同一套規則,所以請先讀 [AGENTS.md](AGENTS.md)。重點如下:
- **淨室。** 絕不閱讀 Adobe 的二進位檔或 GPL 的 raw/照片程式碼(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依據公開規格與黑盒觀察來工作。
- **絕不使用 Adobe 資產:** 不使用 Adobe 產品的圖示、截圖、預設集、描述檔、LUT 或字型。儲存庫中的每一張
影像、圖示和字型都是原創、公眾領域、創用 CC、OFL 或寬鬆授權的,並在同一次提交中
於 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 新增條目。新的字型要提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是這裡。
- **純 Rust**、強制的 crate 分層、一切皆指令,並且每次提交前 `cargo xtask ci` 都必須通過
(一次提交對應一個任務 ID)。
- **絕不當機。** 非測試程式碼要回傳錯誤而不是 panic:不使用 `unwrap()`、`expect()`、`panic!` 或
`unsafe`,對任何來自輸入的資料都做受檢的索引存取,並且每一個當機修復都要附一個回歸測試。細節見
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
有問題、想法,或是想先討論一個 bug?來 [Discord](https://discord.gg/artcraft)。
<br>
## Downloads
**第一次使用 LightCraft?** 從 [getartcraft.com 的 LightCraft 頁面](https://getartcraft.com/apps/lightcraft)下載。那是最簡單的安裝方式。
**想要特定的建置版本或格式?** 在 GitHub 上,[最新發布版](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有建置版本,[所有發布版](https://github.com/storytold/lightcraft/releases)則有更早的版本與其說明。檔名中的 `<ver>` 是版本號,`SHA256SUMS.txt` 列出了每個檔案的檢查碼。
### Windows
| 建置版本 | 安裝程式 | 可攜版 |
|---|---|---|
| x64(64 位元 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64(Snapdragon 及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86(32 位元) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為
`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝,
並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
### macOS
| 建置版本 | 檔案 | 備註 |
|---|---|---|
| 應用程式,通用版(Apple 晶片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已簽章並經過公證 |
| 命令列工具,通用版 | `lightcraft-cli-<ver>-macos-universal.zip` | 已簽章並經過公證 |
### Linux
| 格式 | x86_64 | aarch64(ARM64) | 備註 |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 隨處可執行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 檔案)自我更新 |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| 壓縮檔 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解壓到任意位置即可使用 |
RISC-V(riscv64):`lightcraft-<ver>-linux-riscv64.tar.gz`,僅提供壓縮檔(glibc 2.39 或更新版本,例如 Ubuntu 24.04+)。
在 Wayland 桌面環境中,LightCraft 會在可用時透過 XWayland 執行,因為只有 X11 支援
視窗的檔案拖放;設定 `LIGHTCRAFT_WAYLAND=1` 可改用原生 Wayland(更清晰的小數縮放,但無法拖放)。
### FreeBSD
| 建置版本 | 檔案 |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| 建置版本 | 檔案 | 備註 |
|---|---|---|
| 靜態網站 | `lightcraft-web-<ver>.zip` | 可在現代瀏覽器中執行;可託管在任何靜態伺服器上 |
<br>
## The Crafting Apps
LightCraft 是 **Crafting Apps** 之一:來自 [ArtCraft](https://getartcraft.com/) 團隊的免費開源創作工具,
每一個都用 Rust 從零開始撰寫,每一個都能獨立使用。
| | 應用程式 | 用途 | 程式碼 | 了解更多 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 影像編輯:圖層、遮色片、文字和真正的 PSD 檔案 | [GitHub](https://github.com/storytold/photocraft) | [網站](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 向量插畫 | [GitHub](https://github.com/storytold/vectorcraft) | [網站](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 影片剪輯、調色與聲音 | [GitHub](https://github.com/storytold/filmcraft) | [網站](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片庫與 raw 顯影 · 你現在在這裡** | [GitHub](https://github.com/storytold/lightcraft) | [網站](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 閱讀、整理與保護 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [網站](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 動態圖形與視覺特效 | [GitHub](https://github.com/storytold/effectcraft) | [網站](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 版面編排與出版 | [GitHub](https://github.com/storytold/designcraft) | [網站](https://getartcraft.com/apps/designcraft) |
還有 [**ArtCraft**](https://getartcraft.com/) 本身,我們為想要真正掌控創作的人打造的 AI 影像與影片工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
我們的 Discord 是各種創作者聚集的地方:畫畫的人、拍照的人、做動畫的人、剪片子的人、
排版的人,還有仍在摸索自己喜歡做什麼的人。分享你在做的東西,
尋求幫助,告訴我們哪裡壞了,或者告訴我們你希望這些工具能做到什麼。
不管你用什麼媒材,也不管你做了多久,這裡都歡迎你。
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
</p>
<br>
## License and credits
LightCraft 採用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 雙重授權,任你選擇。
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors。必要的聲明見 [NOTICE](NOTICE)。
隨附的字型、圖示、影像與其他資產保留各自的開放授權;每一項都在
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、來源與授權。
展示用照片皆為公眾領域作品,取自 Wikimedia Commons:安瑟·亞當斯,*《提頓山脈與蛇河》*
(1942,美國國家檔案館);桃樂絲·蘭格,*《移民母親》*(1936,美國國會圖書館);比爾·安德斯/NASA,
*《地出》*(1968);NASA,*《藍色大理石》*(1972)。示範照片庫由 LightCraft 程式產生。介面字型:
Inter(SIL OFL 1.1)。使用 [craft-fonts](https://github.com/storytold/craft-fonts) 建置的版本(所有正式發布版)
還會內嵌其中文與日文字型(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列於其
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有圖示皆為原創。
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名稱、字標與標誌是
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>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
照片庫、工具與預設集面板可以重新分組、拆分、浮動於應用程式視窗內、關閉與重新開啟。工具面板會保留目前的編輯/裁切/遮色片模式;照片檢視區與底片顯示窗格則維持在一起。拖曳面板標題到某個目標邊緣或另一個標題上即可調整。右鍵選單提供「浮動面板」、「移至群組」與「關閉面板」。既有的面板切換按鈕會在上次儲存的位置重新開啟面板;「重設面板配置」則會還原應用程式的版面配置。浮動面板會保持在目前的應用程式視窗之內。
+13 -6
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -49,7 +49,7 @@ Hours are calibrated on this repo's PR history; see
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Localization | ~64% of the 12 key languages | 8 of 12 present | 130–210 h (+135–239 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
@@ -78,7 +78,7 @@ Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features)
## Languages
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated; German counts 3,644 of 3,754, because its What's New lines add keys); none is `full`
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Translated | Status |
@@ -90,13 +90,13 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
| Arabic | ar | 0% | none (no RTL / shaping) |
| French | fr | 88% | partial |
| Portuguese (Brazil) | pt-br | 85% | partial |
| Indonesian | id | 0% | none |
| Indonesian | id | 100% (What's New in English) | partial |
| Japanese | ja | 87% | partial |
| German | de | 91% | partial |
| German | de | 97% (3,644/3,754) | partial |
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%), Finnish (79%).
## Upcoming
@@ -117,6 +117,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
- 2026-10-18: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words,
RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on
CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged.
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
photographers on a verified body; headline numbers unchanged.
@@ -183,17 +186,21 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-16 | minor | German 91% → 97% (217 interface strings and 862 What's New lines added); headline numbers unchanged |
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-17 | minor | Finnish (`fi`) interface language added: 2,610 of 3,308 messages (79%), locale, menu command and tests; headline numbers unchanged |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
+54
View File
@@ -0,0 +1,54 @@
# Security Policy
LightCraft reads camera raw files, image formats, metadata and sidecars, imports other photo catalogs, downloads and runs machine-learning models, and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, catalogs, model files, automation requests, and command parameters as potentially malicious.
## Reporting a vulnerability
Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files.
This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment.
If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact.
## What to include
A useful report includes:
- affected LightCraft version, commit, or release artifact;
- operating system, architecture, build profile (desktop or web), and relevant feature flags;
- the affected component or path;
- security impact and required attacker access;
- exact reproduction steps;
- expected behavior and actual behavior;
- crash logs, backtraces, sanitizer output, or resource measurements when applicable;
- whether the problem reproduces from a clean checkout;
- any known workaround or containment.
Remove credentials, personal paths, private photos, and unrelated system information from logs.
## Malicious test files
Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample.
Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical.
## Scope
Security reports may cover:
- camera raw, DNG, TIFF, HEIF, JPEG and other image decoding, and EXIF/XMP metadata and sidecar parsing;
- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs;
- the LightCraft catalog, and importing other catalogs and libraries (such as Lightroom catalogs);
- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite or move behavior;
- downloading, verifying, unpacking and running machine-learning models (denoise, faces, masks);
- engine command validation and serialization/deserialization;
- the desktop control channel, the MCP server, and the MCP-to-app connection;
- the WebAssembly build;
- GPU resource validation or driver-facing input handling;
- dependencies, CI, packaging, signing, checksums, and release artifact integrity.
General bugs without a security impact should use the project's normal issue and contribution process.
## Current limitations
The control channel (`lightcraft --control PORT`) listens on loopback only and has no authentication, so any local process can drive the app while it is enabled; only enable it when you need it ([control-protocol.md](docs/control-protocol.md)).
+1
View File
@@ -26,6 +26,7 @@ lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
# probing the window's renderer before it opens (`window_gpu`)
pollster = { workspace = true }
log = { workspace = true }
+159 -17
View File
@@ -39,22 +39,33 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
for stream in listener.incoming().flatten() {
let _ = spawn_connection(stream, tx.clone(), ctx.clone(), Arc::clone(&open), IDLE_TIMEOUT);
}
}
/// Admit and serve one connection, releasing its slot when serving ends.
fn spawn_connection(
mut stream: TcpStream,
tx: Sender<ControlRequest>,
ctx: egui::Context,
open: Arc<AtomicUsize>,
idle_timeout: Duration,
) -> Option<std::thread::JoinHandle<()>> {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
return None;
}
Some(std::thread::spawn(move || {
serve(stream, &tx, &ctx, idle_timeout);
open.fetch_sub(1, Ordering::SeqCst);
}))
}
/// Five minutes allows pauses between automation commands without retaining abandoned slots forever.
const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
@@ -66,9 +77,17 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no
/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts
/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a
/// copy of the connection: closing it then reached the client only when that program exited, as a
/// reset instead of a clean close.
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, idle_timeout: Duration) {
if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) {
log::warn!("control connection closed: could not set idle read timeout: {e}");
return;
}
serve_lines(&stream, &stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
@@ -157,7 +176,10 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -209,6 +231,95 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
/// Own the client and worker together: even a failing timeout assertion closes the socket
/// before joining, so the regression cannot leave a worker stuck in a read.
struct Connection {
client: BufReader<TcpStream>,
requests: Receiver<ControlRequest>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl Connection {
fn new(open: &Arc<AtomicUsize>, idle: Duration) -> Self {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let (stream, _) = listener.accept().unwrap();
let (tx, requests) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
Self { client: BufReader::new(client), requests, worker: Some(worker) }
}
fn join(&mut self) {
if let Some(worker) = self.worker.take() {
worker.join().unwrap();
}
}
}
impl Drop for Connection {
fn drop(&mut self) {
let _ = self.client.get_ref().shutdown(std::net::Shutdown::Both);
while let Ok(req) = self.requests.try_recv() {
let _ = req.reply.send(json!({"ok": false, "error": "test closing"}));
}
self.join();
}
}
#[test]
fn idle_connections_release_their_slots_without_dispatching() {
for input in ["", "{\"method\":\"engine.execute\""] {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
assert_eq!(open.load(Ordering::SeqCst), 1);
connection.client.get_mut().write_all(input.as_bytes()).unwrap();
let mut reply = String::new();
let closed = connection.client.read_to_string(&mut reply);
assert!(closed.is_ok(), "idle connection stayed open: {closed:?}");
connection.join();
assert_eq!(open.load(Ordering::SeqCst), 0, "the production worker releases its slot");
assert!(connection.requests.try_recv().is_err(), "incomplete input never runs");
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply["ok"], false);
assert!(reply["error"].as_str().unwrap().contains("idle read timed out"));
// A subsequent connection is admitted through the same accounting path.
let mut next = Connection::new(&open, Duration::from_secs(1));
next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut eof = String::new();
next.client.read_to_string(&mut eof).unwrap();
next.join();
assert!(eof.is_empty());
assert_eq!(open.load(Ordering::SeqCst), 0);
}
}
#[test]
fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
for id in [1, 2] {
writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap();
let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap();
assert_eq!(req.method, "engine.commands");
assert!(req.take());
// A dispatched command waits for its reply, not for socket input.
std::thread::sleep(Duration::from_millis(250));
req.reply.send(json!({"ok": true, "result": id})).unwrap();
let mut reply = String::new();
connection.client.read_line(&mut reply).unwrap();
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply, json!({"id": id, "ok": true, "result": id}));
}
connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut eof = String::new();
connection.client.read_to_string(&mut eof).unwrap();
connection.join();
assert!(eof.is_empty());
assert_eq!(open.load(Ordering::SeqCst), 0);
}
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
@@ -337,6 +448,37 @@ mod tests {
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
/// A program the app starts while a client is connected doesn't keep the connection open:
/// when the server closes it, the client sees that at once, not when the program exits.
#[cfg(windows)]
#[test]
fn a_program_started_meanwhile_does_not_hold_the_connection_open() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), IDLE_TIMEOUT);
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
// the connection is being served: start a program that lives about 4 s
std::thread::sleep(Duration::from_millis(200));
let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap();
std::thread::sleep(Duration::from_millis(200));
// junk makes the server answer once and close
c.write_all(b"hello\n").unwrap();
let t0 = std::time::Instant::now();
let mut text = String::new();
let read = c.read_to_string(&mut text);
let waited = t0.elapsed();
server.join().unwrap();
let _ = child.wait();
assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}");
assert!(text.contains("closing the connection"), "{text}");
assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited");
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
+40 -18
View File
@@ -113,28 +113,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -483,6 +491,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+4
View File
@@ -33,7 +33,11 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
| `crates/ui-egui/locales/id.json`, `crates/ui-egui/locales/id-formats.json` | Indonesian interface catalogs | @RevDonz with Claude Code; PhotoCraft, VectorCraft, FilmCraft and FigCraft translation contributors for reused labels | original translations from English source; 356 labels match the sibling apps' Indonesian wording, reviewed from [PhotoCraft c39d752](https://github.com/storytold/photocraft/blob/c39d752758155492bbaba71d802cfaa03206fb4a/crates/ui-egui/src/i18n/id.tsv), [VectorCraft 9fe0f03](https://github.com/storytold/vectorcraft/blob/9fe0f03de2bd29e8c86a1179f977c3e651069424/crates/ui-egui/src/i18n/id.tsv), [FilmCraft fae7f8a](https://github.com/storytold/filmcraft/blob/fae7f8a9be09bd74f95fbe08c3d98adc17fe1030/crates/ui-egui/src/i18n/id.tsv) and [FigCraft 8482c3e](https://github.com/storytold/figcraft/blob/8482c3e85bc5f2a430e2f3be2826674872f6d084/crates/ui-egui/locales/id.toml) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-19 | adapted to LightCraft's photo context, count-neutral nouns, reviewed placeholders |
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
| `crates/ui-egui/locales/sv.json`, `crates/ui-egui/locales/sv-formats.json` | Swedish interface catalogs | LightCraft contributors | original work: clean-room Swedish translations of English source phrases | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-15 | none |
| `crates/ui-egui/locales/fi.json`, `crates/ui-egui/locales/fi-formats.json` | Finnish interface catalogs | @roopeg with Claude Code (AI-assisted; native-speaker review by @roopeg) | original work: translations of the English source phrases, using common Finnish photo-editing vocabulary; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-17 | count-neutral labels for Finnish partitive agreement, Finnish dates and times (`20.9.2026`, `16:04`), reviewed placeholders |
| `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+3
View File
@@ -61,3 +61,6 @@ sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
[people.ronibalthazar]
sync_github_name = true
+1
View File
@@ -17,6 +17,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
crc32fast = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
+76 -49
View File
@@ -2,6 +2,7 @@
//! weekday and month names, shifting a time by an offset, and grouping a photo list into runs of
//! the same day / month / year (the date headers of the photo grid).
use chrono::Datelike;
use serde::{Deserialize, Serialize};
pub use crate::stacks::iso_seconds;
@@ -11,32 +12,32 @@ pub const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "
pub const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (proleptic Gregorian; H. Hinnant's algorithm).
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC, proleptic Gregorian). A time outside chrono's year
/// range still converts: the calendar repeats every 400 years, so the instant is moved into range
/// by whole cycles and only the year is carried (matching the old `civil_from_days`), rather than
/// resetting to the epoch. The callers here stay well inside years 0000–9999.
pub fn civil(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
}
/// Days since 1970-01-01 → (year, month 1..=12, day 1..=31).
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not).
format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%S"))
}
_ => "1970-01-01T00:00:00".to_string(), // unreachable
}
}
/// Weekday name of an ISO date (`None` if it doesn't parse).
pub fn weekday(iso: &str) -> Option<&'static str> {
let days = iso_seconds(iso.get(..10)?)?.div_euclid(86_400);
// 1970-01-01 was a Thursday
Some(WEEKDAYS[(days + 3).rem_euclid(7) as usize])
let date = chrono::NaiveDate::from_num_days_from_ce_opt(i32::try_from(days.checked_add(719_163)?).ok()?)?;
Some(WEEKDAYS[date.weekday().num_days_from_monday() as usize])
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
@@ -156,38 +157,56 @@ impl Catalog {
/// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`]
/// ([`GroupBy::Auto`] means days here).
pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec<DateRun> {
if by == GroupBy::None || ids.is_empty() {
if !groups_by_date(key, by) || ids.is_empty() {
return Vec::new();
}
let n = by.prefix();
let date_of = |id: &PhotoId| -> String {
let Some(p) = self.photo(*id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
};
d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string()
};
if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) {
return Vec::new();
}
let stacks = self.stack_index();
let mut runs: Vec<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = Default::default();
for (i, id) in ids.iter().enumerate() {
let k = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(),
None => date_of(id),
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }),
}
}
runs
date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by))
}
/// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` /
/// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its
/// first photo in the list there.
pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String {
let Some(p) = self.photo(id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
};
d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string()
}
}
/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`.
pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool {
by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate)
}
/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see
/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]).
pub fn date_runs_of<K: AsRef<str>>(
ids: &[PhotoId],
stacks: &std::collections::HashMap<PhotoId, (crate::StackId, usize)>,
mut group: impl FnMut(usize, PhotoId) -> K,
) -> Vec<DateRun> {
let mut runs: Vec<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = Default::default();
for (i, id) in ids.iter().enumerate() {
let own;
let k: &str = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()),
None => {
own = group(i, *id);
own.as_ref()
}
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }),
}
}
runs
}
#[cfg(test)]
@@ -199,6 +218,14 @@ mod tests {
fn calendar() {
assert_eq!(civil(0), "1970-01-01T00:00:00");
assert_eq!(civil(951_782_400), "2000-02-29T00:00:00");
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
assert_eq!(civil(253_402_300_800), "10000-01-01T00:00:00");
// Beyond chrono's year range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the i64 extremes.
assert_eq!(civil(10_000_000_000_000), "318857-05-20T17:46:40");
assert_eq!(civil(-10_000_000_000_000), "-314918-08-13T06:13:20");
assert_eq!(civil(i64::MAX), "292277026596-12-04T15:30:07");
assert_eq!(civil(i64::MIN), "-292277022657-01-27T08:29:52");
assert_eq!(weekday("2026-09-30"), Some("Wednesday"));
assert_eq!(weekday("2026-10-01T09:00:00"), Some("Thursday"));
assert_eq!(weekday("2000-02-29"), Some("Tuesday"));
+3 -1
View File
@@ -329,7 +329,7 @@ pub struct Analysis {
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
@@ -610,6 +610,7 @@ mod edited_tests {
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.erf", "ERF", true),
("a.mrw", "MRW", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -653,6 +654,7 @@ mod edited_tests {
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
("NIKON CORPORATION NIKON Z5_2", "NEF", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
+41 -6
View File
@@ -141,9 +141,40 @@ pub(crate) struct Prepared {
only: Option<std::collections::HashSet<PhotoId>>,
}
/// The tokens of a search text: split at whitespace, except inside double quotes, so
/// `person:"Jane Doe"` is one token (issue #1449). An unclosed quote runs to the end of the text; a
/// backslash is an ordinary character. One pass over the text, nothing allocated.
fn search_tokens(text: &str) -> impl Iterator<Item = &str> {
let mut rest = text;
std::iter::from_fn(move || {
rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let mut quoted = false;
let end = rest
.char_indices()
.find(|&(_, c)| {
quoted ^= c == '"';
!quoted && c.is_whitespace()
})
.map_or(rest.len(), |(i, _)| i);
let (tok, tail) = rest.split_at(end);
rest = tail;
Some(tok)
})
}
/// `s` without the double quotes around it (a missing closing quote is allowed).
fn unquote(s: &str) -> &str {
s.strip_prefix('"').map_or(s, |inner| inner.strip_suffix('"').unwrap_or(inner))
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
// a token that starts with a quote is a phrase, even with a colon in it
if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) {
let val = unquote(quoted);
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
@@ -180,18 +211,22 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
// Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted
// (`re:"a b"` is the text `re:a b`).
_ if val.len() != quoted.len() => text_matches(p, &format!("{field}:{val}")),
_ => text_matches(p, &t),
};
}
text_matches(p, &t)
text_matches(p, unquote(&t))
}
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
/// format or a keyword of `p`.
/// format, a keyword or the name of a person (a named face) of `p`.
fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
hay.iter().any(|h| h.to_lowercase().contains(t))
|| p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
|| p.people().iter().any(|n| n.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
@@ -409,7 +444,7 @@ impl Filter {
{
return false;
}
self.text.split_whitespace().all(|tok| token_matches(p, tok))
search_tokens(&self.text).all(|tok| token_matches(p, tok))
}
}
+194 -14
View File
@@ -794,12 +794,90 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
}
}
/// One byte as the `filePath` rules compare it: `/` and `\` both separate, case doesn't matter.
fn fold_path_byte(b: u8) -> u8 {
if b == b'\\' { b'/' } else { b.to_ascii_lowercase() }
}
/// Whether `a` and `b` are equal under [`fold_path_byte`].
fn path_bytes_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| fold_path_byte(*x) == fold_path_byte(*y))
}
/// `needle` inside `hay`, under [`fold_path_byte`]; allocates nothing.
fn path_bytes_contains(hay: &[u8], needle: &[u8]) -> bool {
let Some((&first, rest)) = needle.split_first() else { return true };
// the spellings of the needle's first byte that fold to it: a plain two-way compare per byte
let (a, b) = match fold_path_byte(first) {
b'/' => (b'/', b'\\'),
c if c.is_ascii_lowercase() => (c, c.to_ascii_uppercase()),
c => (c, c),
};
let Some(starts) = hay.len().checked_sub(needle.len()).and_then(|last| hay.get(..=last)) else { return false };
let at = |i: usize, c: u8| (c == a || c == b) && hay.get(i + 1..i + needle.len()).is_some_and(|tail| path_bytes_eq(tail, rest));
// eight bytes at a time: does any of them spell the first byte (a zero byte after xor)?
const ONES: u64 = 0x0101_0101_0101_0101;
let has_zero = |v: u64| v.wrapping_sub(ONES) & !v & (ONES << 7) != 0;
let (chunks, tail) = starts.as_chunks::<8>();
let tail_from = starts.len() - tail.len();
for (k, chunk) in chunks.iter().enumerate() {
let w = u64::from_le_bytes(*chunk);
if (has_zero(w ^ (ONES * u64::from(a))) || has_zero(w ^ (ONES * u64::from(b)))) && chunk.iter().enumerate().any(|(j, &c)| at(k * 8 + j, c)) {
return true;
}
}
tail.iter().enumerate().any(|(j, &c)| at(tail_from + j, c))
}
/// A `filePath` rule. The common case (an ASCII path and an ASCII text) is compared in place, folding
/// case and `\` as it goes, with nothing allocated per photo; any other text takes
/// [`file_path_matches_slow`], so Unicode lower-casing (ß, İ, final sigma…) behaves as it always did.
fn file_path_matches(op: &str, source: &Source, value: &Value) -> bool {
let path = match source {
Source::File { path } => path.as_str(),
Source::Demo { .. } => "",
};
let Value::String(want) = value else { return file_path_matches_slow(op, path, value) };
if !path.is_ascii() || !want.is_ascii() {
return file_path_matches_slow(op, path, value);
}
let (path, want) = (path.trim().as_bytes(), want.trim().as_bytes());
match op {
// the whole string, spaces included (not word by word like the other text fields)
"contains" => !want.is_empty() && path_bytes_contains(path, want),
"notContains" => want.is_empty() || !path_bytes_contains(path, want),
"is" => path_bytes_eq(path, want),
"isNot" => !path_bytes_eq(path, want),
"startsWith" => path.get(..want.len()).is_some_and(|head| path_bytes_eq(head, want)),
"endsWith" => path.len().checked_sub(want.len()).and_then(|from| path.get(from..)).is_some_and(|tail| path_bytes_eq(tail, want)),
"isEmpty" => path.is_empty(),
"isNotEmpty" => !path.is_empty(),
_ => false,
}
}
/// [`file_path_matches`] for any text: the path and the text are normalised into new strings.
fn file_path_matches_slow(op: &str, path: &str, value: &Value) -> bool {
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
let path = path.replace('\\', "/");
let want = lower(value).replace('\\', "/");
match op {
// the whole string, spaces included (not word by word like the other text fields)
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
if field == "filePath" {
return file_path_matches(op, &p.source, value);
}
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
@@ -854,20 +932,6 @@ impl Rule {
"fileName" => text(&p.file_name),
"extension" => text_op(op, p.extension(), want.trim_start_matches('.')),
"duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value),
"filePath" => {
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
let path = match &p.source {
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
let want = want.replace('\\', "/");
match op {
// the whole string, spaces included (not word by word like the other text fields)
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
@@ -1693,4 +1757,120 @@ mod tests {
assert_eq!(field_label("filePath"), Some("File Path"));
assert_eq!(field_label("nope"), None);
}
/// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo.
fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool {
let want = lower(value);
let path = match source {
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
let want = want.replace('\\', "/");
match op {
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
#[test]
fn file_path_rules_match_the_old_strings_for_any_path() {
let paths = [
"",
" ",
" /a/b.jpg \t",
"/Volumes/share/photos/Trip/IMG_0001.jpg",
"/Volumes/share/PHOTOS/trip/img_0001.JPG",
r"D:\Photos\Lake Como\IMG_0001.JPG",
r"d:/photos/lake como/img_0001.jpg",
r"\\nas\share\x.jpg",
"/photos/Straße/a.jpg",
"/photos/STRASSE/a.jpg",
"/photos/İstanbul/a.jpg",
"/photos/i̇stanbul/a.jpg",
"/photos/ΟΔΟΣ/a.jpg",
"/photos/οδος/a.jpg",
"/photos/ΟΔΌΣ/a.jpg",
"/photos/日本/東京.jpg",
"/p/\u{0B}x\u{0B}",
"/p/\u{85}x\u{85}",
"a",
];
let wants = [
"",
" ",
"a",
"A",
"/",
r"\",
r"\photos\",
"/photos/",
"photos",
"PHOTOS",
"Lake Como",
" lake como ",
"lake como",
r"d:\photos\lake",
"IMG_0001.JPG",
".jpg",
"straße",
"STRASSE",
"strasse",
"İstanbul",
"i̇stanbul",
"istanbul",
"ΟΔΟΣ",
"οδος",
"οδοσ",
"ΟΔΌΣ",
"東京",
"日本",
"x",
"\u{0B}x",
"/a/b.jpg",
"/a/b.jpg \t",
"\u{85}x",
];
let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"];
let mut values: Vec<serde_json::Value> = wants.iter().map(|w| json!(w)).collect();
values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]);
let mut sources: Vec<Source> = paths.iter().map(|p| Source::File { path: (*p).into() }).collect();
sources.push(Source::Demo { scene: 0 });
for source in &sources {
for op in ops {
for value in &values {
assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}");
}
}
}
}
#[test]
fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() {
let mut p = photo(1);
p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() };
let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]}));
assert!(r.matches(&p, &Catalog::default()));
}
/// Every piece of a long path, in both cases and both slash directions, wherever it starts
/// (the search skips eight bytes at a time, so starts and ends at every offset matter).
#[test]
fn file_path_contains_finds_every_piece_of_a_long_path() {
let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG";
let source = Source::File { path: path.into() };
let shout = |s: &str| s.to_uppercase();
let swap = |s: &str| s.replace('\\', "/");
for from in 0..path.len() {
for to in from + 1..=path.len() {
let piece = &path[from..to];
for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] {
let value = json!(want);
for op in ["contains", "notContains"] {
assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}");
}
}
}
}
}
}
+6 -8
View File
@@ -299,14 +299,12 @@ pub fn iso_seconds(s: &str) -> Option<i64> {
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);
let yoe = y2 - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
// Days since 1970-01-01 (proleptic Gregorian). A day past the month's length rolls over the
// way the old Hinnant arithmetic did (e.g. 2026-02-30 → 2026-03-02), which `normalize_iso`
// relies on to reject impossible dates.
let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
+65
View File
@@ -836,6 +836,71 @@ fn cmp_name_ignores_case_without_allocating() {
assert_eq!(cmp_name("a", "ab"), Less);
}
/// Search Photos finds named people in free text, and a quoted value keeps a name of several words
/// together: `person:"Jane Doe"` (issue #1449). Malformed quotes narrow the result or change nothing.
#[test]
fn search_text_finds_people_and_quoted_values() {
use lightcraft_meta::{Rect, Region, RegionKind};
let region = |name: &str, kind: RegionKind| Region {
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind,
name: Some(name.to_string()),
description: None,
};
let mut c = Catalog::new();
let mut add = |name: &str, regions: Vec<Region>, keywords: &[&str]| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.regions = regions;
p.meta.keywords = keywords.iter().map(|k| k.to_string()).collect();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let jane = add("a.jpg", vec![region("Jane Doe", RegionKind::Face), region("Rex", RegionKind::Pet)], &[]);
let both = add("b.jpg", vec![region("Jane Roe", RegionKind::Face), region("John Doe", RegionKind::Face)], &[]);
let zoe = add("c.jpg", vec![region("Zoë Ångström", RegionKind::Face)], &["New York"]);
let none = add("lunch 10:30 am.jpg", vec![], &["york minster"]);
let q = |text: &str| {
let mut ids = c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
ids.sort();
ids
};
// free text: a person's name, any case, any part of it; pets are not people
assert_eq!(q("jane"), vec![jane, both]);
assert_eq!(q("DOE"), vec![jane, both]);
assert_eq!(q("ångström"), vec![zoe]);
assert!(q("rex").is_empty(), "a pet is not a person");
// words are still ANDed: b.jpg has a Jane and a Doe, a quoted phrase asks for them together
assert_eq!(q("jane doe"), vec![jane, both]);
assert_eq!(q("\"jane doe\""), vec![jane]);
// fielded: the exact name, of several words when quoted
assert_eq!(q("person:\"Jane Doe\""), vec![jane]);
assert_eq!(q("who:\"jane roe\""), vec![both]);
assert_eq!(q("person:\"Zoë Ångström\""), vec![zoe]);
assert!(q("person:jane").is_empty(), "person: is the whole name, as before");
assert!(q("person:Jane Doe").is_empty(), "unquoted, the second word is its own token, as before");
assert_eq!(q("keyword:\"new york\""), vec![zoe], "other fields take quoted values too");
assert_eq!(q("person:\"Jane Doe\" a.jpg"), vec![jane], "tokens after a quoted one still count");
// malformed input never widens the match and never panics
assert_eq!(q("person:\"Jane Doe"), vec![jane], "an unclosed quote runs to the end");
assert!(q("person:\"Jane Doe a.jpg").is_empty(), "…and takes the rest of the text with it");
assert!(q("person:\"\"").is_empty());
assert!(q("person:").is_empty());
assert_eq!(q("\""), vec![jane, both, zoe, none], "a lone quote is an empty phrase, like an empty search");
assert_eq!(q("\"\" jane"), vec![jane, both]);
assert!(q("\"jane:doe\"").is_empty(), "a quoted phrase is free text even with a colon");
// an unknown field is plain text, its quoted value unquoted (#2099): `10:"30 am"` is the text `10:30 am`
assert_eq!(q("10:\"30 am\""), vec![none]);
assert!(q("10:\"30 pm\"").is_empty());
assert!(q("jane\\\"").is_empty(), "a backslash is an ordinary character");
let long = format!("person:\"{}", "é ".repeat(50_000));
assert!(q(&long).is_empty());
assert_eq!(q(" \t jane \u{3000} "), vec![jane, both], "any whitespace separates tokens");
}
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
+2
View File
@@ -46,6 +46,8 @@ lightcraft-segment = { workspace = true, optional = true }
# model downloads run natively only (the web build fetches no models)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
lightcraft-fetch = { workspace = true }
# capture-time parsing for face recognition (native only)
chrono = { workspace = true }
[features]
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
+602 -52
View File
@@ -35,7 +35,11 @@ use lightcraft_raw::{
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
pub const LOOK_VERSION: u32 = 10;
/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699);
/// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a
/// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data
/// stored with each smart preview.
pub const LOOK_VERSION: u32 = 12;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -68,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
| RawFormat::Mrw
| RawFormat::Erf
)
}
@@ -89,7 +94,18 @@ pub(crate) fn starting_colour(
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> (Xy, CameraTransform, Option<CameraLook>) {
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
let (xy, t, look) = starting_colour_with(
raw,
|raw, t| fit_preview(raw, bytes, t, lens),
|raw, t| monochrome_look(raw, bytes, t, lens),
|raw, t| {
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?;
fit_dark_tone(&sensor, &reference, &clipped)
},
);
if look.is_none() && dng_tone_eligible(raw, &t) {
let look = fit_dng_tone(raw, bytes, &t, embedded);
return (xy, t, look);
@@ -118,11 +134,12 @@ pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
/// JPEG's tone fit passed in (tests pin the order with them).
/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them).
fn starting_colour_with(
raw: &mut RawImage,
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option<CameraLook>, bool),
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
dark: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
) -> (Xy, CameraTransform, Option<CameraLook>) {
let start = |raw: &RawImage| {
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
@@ -146,9 +163,9 @@ fn starting_colour_with(
}
None => (xy, t),
};
// only a black-and-white JPEG: a colour one that gave no look pays nothing more
// A black-and-white JPEG first tries its existing tone fit on the chosen colour.
let look = if black_and_white { monochrome(raw, &t) } else { None };
(xy, t, look)
(xy, t, look.or_else(|| dark(raw, &t)))
}
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
@@ -270,20 +287,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
// try its colour first and fit tone/chroma per photo (picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) {
Some(look) => look,
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
// (`monochrome_look` does it without a profile).
None if colour.is_some() && black_and_white => {
let unclipped = without_clipped(&sensor, &clipped);
let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) };
look
}
None => return (None, black_and_white),
};
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
@@ -302,6 +320,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
/// the render's highlight reconstruction.
const SENSOR_CLIP: f32 = 0.99;
/// [`fit_look_with`] for a photo without Sony's DRO.
#[cfg(test)]
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_look_with(sensor, reference, clipped, colour, false)
}
/// The photo's look, refitted without the proxy pixels whose sensor values are clipped.
///
/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip
@@ -320,17 +344,19 @@ const SENSOR_CLIP: f32 = 0.99;
/// replace a look fitted away from edges). A photo that had no look gets one when the search
/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly
/// closer to the camera JPEG than the neutral fallback.
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>, dro: bool) -> Option<CameraLook> {
let unclipped = without_clipped(sensor, clipped);
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
// from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them
let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false };
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else {
return unclipped
.as_ref()
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright))
.map(|(look, _)| look)
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright));
};
let Some(unclipped) = unclipped else { return Some(look) };
let refit = fit_attempt(&unclipped, reference, &colour, attempt);
let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright);
if refit.is_none() && lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels");
}
@@ -389,8 +415,14 @@ fn sony_dro_off_tone() -> Option<CameraTone> {
/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps
/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted.
fn with_dro_off_tone(look: CameraLook) -> CameraLook {
let Some(dro_off) = sony_dro_off_tone() else { return look };
let own = look.tone.knots();
CameraLook { tone: dro_off(&look.tone), ..look }
}
/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker,
/// or `own` unchanged when it can't be.
fn dro_off(own_tone: &CameraTone) -> CameraTone {
let Some(dro_off) = sony_dro_off_tone() else { return *own_tone };
let own = own_tone.knots();
let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]);
// Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
// (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
@@ -400,11 +432,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook {
let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
knots[30][1] = knots[30][1].max(floor).min(last[1]);
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look };
if !at_most(&tone, &look.tone) {
return look;
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone };
if !at_most(&tone, own_tone) {
return *own_tone;
}
CameraLook { tone, ..look }
tone
}
/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
@@ -452,10 +484,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
let unclipped = without_clipped(&sensor, &clipped);
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
let colour = Some((Mat3::IDENTITY, None));
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look = TONE_FITS.iter().find_map(|&tone| {
[false, true]
.into_iter()
.find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
[false, true].into_iter().find_map(|away_from_edges| {
fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright)
})
});
let Some(look) = look else { return refuse("fit") };
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
@@ -928,7 +961,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
Some(sensor) => sensor,
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
// Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution.
None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => {
fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box)
}
None => return None,
})
}
@@ -985,7 +1021,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
/// colours (see [`EDGE_CONTRAST`]).
type Pairs = Vec<([f64; 3], [f64; 3])>;
type Pair = ([f64; 3], [f64; 3]);
type Pairs = Vec<Pair>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
@@ -1147,7 +1184,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
/// The look from [`search_ordered`] without its attempt.
#[cfg(test)]
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> {
search_ordered(sensor, reference, colour, order).map(|(look, _)| look)
search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false })
.map(|(look, _)| look)
}
/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
@@ -1177,21 +1215,33 @@ struct Attempt {
/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole
/// search runs with one tone fit before the next is tried, so the conditional-median fallback
/// accepts exactly what the previous releases accepted.
fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> {
fn search_ordered(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: Option<(Mat3, Option<HsvTable>)>,
order: &[ToneFit],
bright: &HighlightCheck,
) -> Option<(CameraLook, Attempt)> {
let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] };
order.iter().find_map(|&tone| {
profiles.iter().find_map(|&profile| {
[false, true].into_iter().find_map(|away_from_edges| {
let attempt = Attempt { tone, profile, away_from_edges, dark: false };
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt))
})
})
})
}
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
fn fit_attempt(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: &Option<(Mat3, Option<HsvTable>)>,
attempt: Attempt,
bright: &HighlightCheck,
) -> Option<CameraLook> {
let colour = if attempt.profile { colour.clone() } else { None };
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark)
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright)
}
fn fit_pairs_on(
@@ -1201,6 +1251,7 @@ fn fit_pairs_on(
edge_limit: Option<f32>,
tone_fit: ToneFit,
dark: bool,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
@@ -1228,6 +1279,9 @@ fn fit_pairs_on(
look.tone = tone;
}
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
if !highlights_hold(&look.tone, highlights, colour) {
continue;
}
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
let corrected = displayed(colour(*x), &tone);
@@ -1342,11 +1396,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
/// and in gamma-encoded display values, and when the JPEG shows the same picture
/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly
/// closer to the camera JPEG than what the photo would get otherwise.
fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit))
fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights))
}
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>, highlights: &HighlightCheck) -> Option<CameraLook> {
// as for a known colour model: enough signal for the tone, but no monochrome reference
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?;
let related = rank_correlation(&pairs);
@@ -1370,6 +1424,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
look.tone = tone;
}
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) {
return None;
}
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
@@ -1420,7 +1477,7 @@ fn monochrome(reference: &Rgb32f) -> bool {
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
if !file_local_look(raw.format) {
@@ -1430,10 +1487,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
if !monochrome(&reference) {
return None;
}
let unclipped = without_clipped(&sensor, &clipped);
let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
let unclipped = without_clipped(&sensor, &clipped);
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?;
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
@@ -1450,18 +1508,103 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than
/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance).
fn fit_tone_only_with(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: Option<(Mat3, Option<HsvTable>)>,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights))
}
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas.
#[cfg(test)]
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true })
}
fn fit_tone_only_on(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: &Option<(Mat3, Option<HsvTable>)>,
edge_limit: Option<f32>,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
// every pair counts: the JPEG has no colour to require
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
let related = rank_correlation_ties(&pairs);
fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights)
}
/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs.
/// Try only after every existing look fails. Keep the colour of the source transform,
/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG
/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights.
fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option<CameraLook> {
if (sensor.width, sensor.height) != (reference.width, reference.height)
|| sensor.data.len() != reference.data.len()
|| clipped.len() != sensor.data.len()
|| sensor.data.is_empty()
|| sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite()))
|| reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85)
{
return None;
}
let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count();
if (dark as f64) < reference.data.len() as f64 * 0.9 {
return None;
}
// no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold
let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true };
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| {
let pairs: Pairs = sensor
.data
.iter()
.zip(&reference.data)
.enumerate()
.filter(|(i, (input, output))| {
// The same signal floors as the camera toe, without colour ratios or counts.
!clipped.get(*i).copied().unwrap_or(true)
&& input.iter().all(|v| *v > 0.0001 && *v < 1.5)
&& (0.0003..0.85).contains(&luminance_2020(**output))
&& edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit)
})
.map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from)))
.collect();
if pairs.len() < 256 {
return None;
}
let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?;
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) {
if *clipped {
let target = luma(target.map(f64::from));
let before = luma(displayed(input.map(f64::from), &neutral));
let after = luma(displayed(input.map(f64::from), &tone));
if !after.is_finite() || (after - target).abs() > (before - target).abs() {
return None;
}
}
}
Some(look)
})
}
fn fit_tone_only_pairs(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: &Option<(Mat3, Option<HsvTable>)>,
edge_limit: Option<f32>,
pairs: &Pairs,
dark: bool,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
let kind = if dark { "near-black scene" } else { "black-and-white JPEG" };
let related = rank_correlation_ties(pairs);
if related < PARTIAL_MIN_RANK_CORRELATION {
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture");
}
return None;
}
@@ -1471,8 +1614,19 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
let tone = if dark {
// Split this wider set once. The camera toe's separate colour/extra split belongs to
// the original colour pairs and would put some of these held-out pixels into training.
fit_tone(
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(),
)?
} else {
fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)?
};
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
if !highlights_hold(&look.tone, highlights, coloured) {
return None;
}
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
@@ -1486,7 +1640,7 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
}
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
"[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
(before / samples.max(1) as f64).sqrt(),
(after / samples.max(1) as f64).sqrt(),
if edge_limit.is_some() { ", away from edges" } else { "" }
@@ -1780,6 +1934,89 @@ impl ToneFit {
}
}
/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel
/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped
/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped
/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can
/// leave them out.
fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs {
if sensor.data.len() != reference.data.len() {
return Vec::new();
}
sensor
.data
.iter()
.zip(&reference.data)
.filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85)
.map(|(x, y)| (x.map(f64::from), y.map(f64::from)))
.collect()
}
/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the
/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are
/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be
/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance
/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very
/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial
/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of
/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0.
/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`].
const MIN_TONE_RANGE: f32 = 1.2;
/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also
/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does
/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG
/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted
/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed
/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has
/// 0.93.
const NARROW_TONE_RANGE: f32 = 1.5;
/// What a narrow tone curve is checked against ([`highlights_hold`]).
struct HighlightCheck {
/// The frame's [`highlights`].
pairs: Pairs,
/// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the
/// fit, so the check looks at it lowered.
dro: bool,
/// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that
/// fit's gates do, since the JPEG has no colour to match.
grey: bool,
}
/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps
/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further
/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get
/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than
/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels.
fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool {
let knots = tone.knots();
if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE {
return true;
}
// the curve as the photo will get it
let used = if check.dro { dro_off(tone) } else { *tone };
let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let (mut look, mut fallback) = (0.0, 0.0);
for (x, target) in &check.pairs {
let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral));
if check.grey {
look += (luma(rendered) - luma(*target)).powi(2);
fallback += (luma(base) - luma(*target)).powi(2);
continue;
}
for c in 0..3 {
look += (rendered[c] - target[c]).powi(2);
fallback += (base[c] - target[c]).powi(2);
}
}
let hold = look.is_finite() && look <= fallback;
if !hold && lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})");
}
hold
}
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
@@ -1807,7 +2044,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
let mid = a + (b - a) / 2;
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
}
if knots[31][0] < knots[0][0] * 1.5 {
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
CameraTone::new(knots)
@@ -1835,7 +2072,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
}
if knots[31][0] < knots[0][0] * 1.5 {
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
@@ -2558,6 +2795,7 @@ mod tests {
&mut raw,
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
|_, _| panic!("no dark tone fit for a raw with its own matrices"),
);
assert_eq!(raw.color, color);
assert!(!t.matrix_is_fallback && look.is_none());
@@ -2579,6 +2817,7 @@ mod tests {
(Some(fitted.clone()), false)
},
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
|_, _| panic!("no dark tone fit after an accepted fit"),
);
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
@@ -2601,7 +2840,8 @@ mod tests {
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"));
let (xy, t, look) =
starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None);
assert!(look.is_none());
assert_eq!(raw.color.illuminant, [17, 21]);
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
@@ -2631,6 +2871,7 @@ mod tests {
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
Some(toned.clone())
},
|_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
assert!(!t.matrix_is_fallback);
@@ -2644,6 +2885,7 @@ mod tests {
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
Some(toned.clone())
},
|_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.is_some() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
@@ -2734,6 +2976,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
RawFormat::Mrw,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
@@ -3504,7 +3747,8 @@ mod tests {
let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap();
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap();
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
}
@@ -3591,6 +3835,110 @@ mod tests {
assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8));
}
fn near_black_scene() -> (Rgb32f, Rgb32f) {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 };
let rgb = [1.15, 1.0, 0.75];
*input = rgb.map(|v| v * y / luminance_2020(rgb));
*output = input.map(|v| v * 0.08);
}
(sensor, reference)
}
#[test]
fn a_near_black_scene_gets_tone_without_learning_colour() {
let (sensor, reference) = near_black_scene();
assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look");
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default());
let (_, _, look) = starting_colour_with(
&mut raw,
|_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false),
|_, _| None,
|_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]),
);
let look = look.expect("tone from the dark pixels");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = |tone: &ToneMap| {
sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2))
.sum::<f64>()
};
assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001);
assert_eq!(look.tone.apply(0.0), 0.0);
let mut previous = 0.0;
for i in 0..10_000 {
let y = look.tone.apply(i as f32 / 10_000.0);
assert!(y.is_finite() && y >= previous && y <= 1.0);
previous = y;
}
// Models with spectral matrices keep them before the dark tone is fitted.
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, false),
|_, _| None,
|raw, t| {
assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color));
fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()])
},
);
assert!(!t.matrix_is_fallback);
assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY);
}
#[test]
fn dark_tone_holdout_never_enters_training() {
let (sensor, mut reference) = near_black_scene();
let none = vec![false; sensor.data.len()];
let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone");
// Every pixel clears the wider signal floors. Changing only its held-out third must
// change the measured error, but cannot change the fitted curve.
for p in reference.data.iter_mut().step_by(3) {
*p = p.map(|v| v * 1.03);
}
let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback");
assert_eq!(changed.tone, original.tone);
}
#[test]
fn dark_tone_requires_signal_correlation_and_unclipped_highlights() {
let (sensor, reference) = near_black_scene();
let none = vec![false; sensor.data.len()];
let mut unrelated = reference.clone();
for (i, p) in unrelated.data.iter_mut().enumerate() {
*p = reference.data[(i * 3571) % reference.data.len()];
}
assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG");
let mut flat = reference.clone();
flat.data.fill([0.002; 3]);
assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone");
let mut sparse = reference.clone();
sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]);
assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs");
let mut bright = reference.clone();
bright.data[0] = [0.95; 3];
assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight");
let mut clipped = none;
clipped[0] = true;
let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel");
let mut changed = sensor.clone();
changed.data[0] = [0.18; 3];
let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG");
assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve");
let mut highlight = reference.clone();
highlight.data[0] = [0.8; 3];
assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG");
assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions");
assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask");
}
/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is
/// learnt and the full look is never tried) whose camera renders it two stops brighter than the
/// neutral fallback: the partial look is the tone curve (and chroma curve) alone.
@@ -3636,7 +3984,8 @@ mod tests {
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let mut reference = sensor.clone();
reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32));
assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback");
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback");
// an unrelated picture
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
@@ -3740,6 +4089,7 @@ mod tests {
assert!(!t.matrix_is_fallback, "on the spectral matrices");
own.clone()
},
|_, _| None,
);
assert!(look.is_some() && !t.matrix_is_fallback);
}
@@ -3895,8 +4245,10 @@ mod tests {
}
}
let colour = Some((Mat3::IDENTITY, None));
let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
.expect("tone fitted");
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look =
fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check)
.expect("tone fitted");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
@@ -3959,6 +4311,27 @@ mod tests {
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
}
/// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's
/// full-resolution corrections applied once rather than silently losing the tone fit.
#[test]
fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() {
use lightcraft_raw::Opcode;
let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]);
raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none());
let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable");
assert_eq!((proxy.width, proxy.height), (32, 24));
let center = proxy.data[12 * 32 + 16][1];
let corner = proxy.data[0][1];
assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}");
let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development");
let small = sensor_proxy(&raw, 2, 16).unwrap();
assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce");
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, with or without a look table; and no other raw format.
#[test]
@@ -3993,4 +4366,181 @@ mod tests {
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped
/// to the JPEG through a steep camera curve (a hazy scene the camera stretches).
fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> {
(0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect()
}
/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that
/// of the darkest 1/32 (their last and first knots).
fn knot_range(mut scene: Vec<f64>) -> f64 {
scene.sort_by(f64::total_cmp);
let n = scene.len();
scene[31 * n / 32 + n / 64] / scene[n / 64]
}
/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets
/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit.
#[test]
fn tone_fits_accept_low_contrast_scenes() {
for fit in [fit_tone, fit_tone_conditional] {
let pairs = low_contrast_pairs(1.47);
let range = knot_range(pairs.iter().map(|p| p.0).collect());
assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}");
let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631");
assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1));
assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame");
}
}
/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera
/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback.
#[test]
fn a_low_contrast_scene_gets_a_camera_look() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y);
}
let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback");
let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error: f64 = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.02, "{}", error.sqrt());
}
/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the
/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out
/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky
/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries'
/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright
/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range).
#[test]
fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
for (case, sky_value, is_sky) in [
("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool),
("clipped sky", 1.0, &|i: usize| i < 5600),
("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)),
("sky over the pairs' range", 1.6, &|i: usize| i < 5600),
] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
let mut clipped = vec![false; sensor.data.len()];
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if is_sky(i) {
// bright, nearly white in the camera JPEG
*src = [sky_value; 3];
*dst = [0.93; 3];
clipped[i] = case == "clipped sky";
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
// nearly flat midtones: the fitted curve's extrapolation can't reach the sky
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y);
}
let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue };
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([f64::from(sky_value); 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}");
}
}
/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to
/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the
/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the
/// sky at 0.61 where the camera JPEG has 0.93.
#[test]
fn a_narrow_curve_is_checked_as_lowered_for_dro() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y);
}
let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0)));
if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) {
let look = with_dro_off_tone(look);
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([0.9; 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}");
}
}
/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning
/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted.
#[test]
fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3];
}
let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2));
let rms = (error.sum::<f64>() / sensor.data.len() as f64).sqrt();
assert!(rms < 0.02, "{rms}");
}
/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is
/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit.
#[test]
fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3];
}
if let Some(look) = fit_tone_only(&sensor, &reference, None) {
let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)));
assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}");
}
}
}
+57 -23
View File
@@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option<Arc<CameraProfile>> {
/// keys, so thumbnails rendered before a profile existed are not reused after.
pub fn cache_key() -> u64 {
static KEY: OnceLock<u64> = OnceLock::new();
*KEY.get_or_init(|| {
let entries = dir().and_then(|d| std::fs::read_dir(d).ok());
let mut files: Vec<(String, u64, u64)> = entries
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
})
.collect();
files.sort();
let mut h = lightcraft_preview::Hasher128::new();
for (name, len, modified) in &files {
h.str(name).u64(*len).u64(*modified);
}
for (model, json) in BUNDLED {
h.str(model).str(json);
}
h.finish().0 as u64
})
*KEY.get_or_init(|| cache_key_for(dir().as_deref()))
}
fn cache_key_for(directory: Option<&Path>) -> u64 {
let entries = directory.and_then(|d| std::fs::read_dir(d).ok());
let mut files: Vec<(String, u64, std::time::Duration)> = entries
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
})
.collect();
files.sort();
let mut h = lightcraft_preview::Hasher128::new();
for (name, len, modified) in &files {
h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos()));
}
for (model, json) in BUNDLED {
h.str(model).str(json);
}
h.finish().0 as u64
}
/// Per camera model: the photos read and their pooled colour pairs.
@@ -294,6 +296,38 @@ mod tests {
assert!(bundled("No Such Camera").is_none());
}
#[test]
fn same_second_profile_replacement_changes_render_cache_key() {
let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id()));
let mut first = profile();
first.model = "ILCE-7CR".into();
let path = save(&first, &dir).unwrap();
let set_modified = |millis| {
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis);
let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap();
file.metadata().unwrap()
};
let before = set_modified(100);
assert_eq!(load(&path).unwrap(), first);
let before_key = cache_key_for(Some(&dir));
let mut second = first;
second.matrix[0][0] = 1.7;
save(&second, &dir).unwrap();
let after = set_modified(350);
assert_eq!(load(&path).unwrap(), second);
assert_eq!(before.len(), after.len());
let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
assert_eq!(before_time.as_secs(), after_time.as_secs());
assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos());
let after_key = cache_key_for(Some(&dir));
let _ = std::fs::remove_dir_all(dir);
assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache");
}
#[test]
fn file_names_are_safe() {
assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");
+15 -1
View File
@@ -114,6 +114,16 @@ fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -
forgotten
}
impl Session {
/// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what
/// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched
/// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that
/// isn't recorded lets a folder in daily use be forgotten.
pub fn note_browsed(&mut self, root: &str, files: &[String]) {
stamp_browsed(self, root, files);
}
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
/// growing the log; far finer than the days the eviction counts in).
const RESTAMP_SECS: i64 = 3600;
@@ -148,7 +158,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
// Missing Photos lists library photos only, never Local records, so it keeps nothing here; and
// working it out checks every original (minutes on a sleeping NAS when the library opens on it)
if s.source != crate::LibrarySource::Missing {
keep.extend(s.visible().iter().copied());
}
keep
}
+4 -1
View File
@@ -21,6 +21,9 @@ fn secs(iso: &str) -> Option<i64> {
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
// a photo listed twice is one photo, not a burst of two look-alikes (first occurrence kept)
let mut seen = std::collections::HashSet::new();
let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
@@ -87,7 +90,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findSimilar";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
+3 -7
View File
@@ -513,10 +513,6 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
}
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
let (files, bytes) = s.denoise_cache_usage();
let st = &s.denoise.settings;
@@ -594,7 +590,7 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
if s.denoise_products_dir().is_none() {
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
}
let ids = match ids_param(p) {
let ids = match super::photo_ids(p, C)? {
Some(ids) => ids,
None => match str_param(p, "scope").unwrap_or("selected") {
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
@@ -612,14 +608,14 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
}
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
let photo = super::photo_id(p, "denoise.cancel")?;
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
Ok(json!({"cancelled": n}))
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
s.denoise_refresh_active(false);
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
Ok(status_json(s, super::photo_id(p, "denoise.status")?))
}
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
+21 -5
View File
@@ -2,7 +2,7 @@
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use lightcraft_geom::{CropGeometry, CropHandle, MIN_CROP_FRACTION, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
@@ -208,8 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
refit_crop_angle(angle, before, d, dims);
};
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
if let Some(ids) = super::photo_ids(p, "develop.set")? {
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -533,13 +532,24 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("crop.set", "Set Crop", [], None, "{rect?: [x0,y0,x1,y1] normalized in the straightened frame, angle?: degrees}", has_active, |s, p| {
let id = active(s, "crop.set")?;
let (w, h) = crop_dims(s, id);
// the smallest crop a drag of the crop tool can make: below it an export comes out 1×1 (#1952)
let min_side = MIN_CROP_FRACTION * w.min(h) * (1.0 - 1e-9);
let too_small = |r: Rect| {
(r.width() * w < min_side || r.height() * h < min_side)
.then(|| bad("crop.set", format!("rect is smaller than the smallest crop ({} % of the shorter side)", MIN_CROP_FRACTION * 100.0)))
};
edit(s, "crop.set", "Crop", |d| {
if let Some(a) = p.get("rect").and_then(Value::as_array) {
let explicit = p.get("rect").and_then(Value::as_array);
if let Some(a) = explicit {
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if v.len() != 4 {
return Err(bad("crop.set", "rect needs 4 numbers"));
}
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
let r = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
if let Some(e) = too_small(r) {
return Err(e);
}
d.crop.geometry.rect = r;
}
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
d.crop.geometry = match (p.get("rect"), angle) {
@@ -547,6 +557,12 @@ pub fn specs() -> Vec<CommandSpec> {
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
};
// a rect far larger than the photo is shrunk to fit it, and can come out a sliver
if explicit.is_some()
&& let Some(e) = too_small(d.crop.geometry.rect)
{
return Err(e);
}
Ok(())
})
}),
+8 -10
View File
@@ -82,7 +82,7 @@ fn name_faces(s: &mut Session, p: &Value) -> Result<Value> {
/// makes the face yours: a later "Detect Faces" run no longer replaces it.
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.setName";
let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "missing or invalid `index`"))?;
let name = name_param(p, C)?;
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
@@ -470,8 +470,7 @@ mod imp {
let embedder = current(s, C)?;
collect(s);
let budget = Duration::from_millis(p.get("budgetMs").and_then(Value::as_u64).unwrap_or(200).min(600_000));
let first: Vec<PhotoId> =
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()).unwrap_or_default();
let first: Vec<PhotoId> = super::super::photo_ids(p, C)?.unwrap_or_default();
let todo = pending(s, &first, false);
let started = Instant::now();
let (mut embedded, mut failed, mut done) = (0, 0, 0);
@@ -509,13 +508,12 @@ mod imp {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
// days from civil (proleptic Gregorian)
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some((era * 146_097 + doe - 719_468) * 86_400 + hh * 3600 + mm * 60 + ss)
// Days since 1970-01-01 (proleptic Gregorian); a day past the month's length rolls over as
// the old Hinnant arithmetic did.
let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
/// `faces.evaluate`: leave-one-out over the library's named faces, to see how well recognition works on *your*
+28 -15
View File
@@ -145,10 +145,6 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
}
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
/// The targets an album can hold and how many were browsed (Local) photos, which album views
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
@@ -623,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
always,
|s, p| {
let ids = ids_param(p).unwrap_or_default();
let ids = super::photo_ids(p, "library.select")?.unwrap_or_default();
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
let mode = str_param(p, "mode").unwrap_or("replace");
// An id that is not in the library is an error, and the selection stays as it was:
@@ -808,9 +804,13 @@ pub fn specs() -> Vec<CommandSpec> {
"{label: red|yellow|green|blue|purple|none, ids?}",
has_selection,
|s, p| {
let l = match str_param(p, "label") {
None | Some("none") => None,
Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
// `none` (or null) clears; a missing label or one that isn't text is an error, not a clear
let l = match p.get("label") {
Some(Value::Null) => None,
Some(Value::String(x)) if x == "none" => None,
Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")),
Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")),
};
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
}
@@ -829,8 +829,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
always,
|s, p| {
let id =
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
@@ -856,7 +855,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
const C: &str = "photo.setRegion";
// a drag is one undo step however many frames it took: the caller commits once, on release
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
@@ -1267,7 +1266,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_library_selection,
|s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.selected());
let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
@@ -1296,7 +1295,8 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
has_library_selection,
|s, p| {
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
let targets =
album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?;
let (targets, skipped) = targets;
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
@@ -1458,7 +1458,7 @@ pub fn specs() -> Vec<CommandSpec> {
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
let targets = ids_param(p).unwrap_or_else(|| s.selected());
let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
@@ -1709,12 +1709,21 @@ fn has_library(s: &Session) -> std::result::Result<(), String> {
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
// typographic minus signs and dashes mean a negative value, like ASCII '-'
let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-");
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
let lead = t.starts_with(['N', 'S', 'n', 's']);
let i = if lead {
// hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter
t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i)
} else {
t.find(['N', 'S', 'n', 's']).map(|i| i + 1)
}
.or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
@@ -1746,6 +1755,10 @@ mod gps_tests {
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1)));
assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276)));
assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093)));
assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093)));
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
+4 -7
View File
@@ -17,7 +17,7 @@ pub struct LutProfile {
pub id: String,
pub name: String,
pub group: String,
/// The `.cube` file (the library's copy when there is a library on disk).
/// The library's copy of the `.cube` file (empty without a library on disk).
pub file: String,
}
@@ -123,12 +123,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
f.to_string_lossy().to_string()
}
None => {
// no library on disk: remember the original (the in-memory session ends with the app)
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
f.to_string_lossy().to_string()
}
// no library on disk: the registered LUT is all this session needs (a copy would only
// be read back when a library opens, and that takes its own list from its prefs)
None => String::new(),
};
lightcraft_pipeline::lut::register(&id, lut);
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
+1 -1
View File
@@ -58,7 +58,7 @@ pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.relink";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let abs = std::path::absolute(path).map_err(|e| bad(C, e.to_string()))?;
if !abs.is_file() {
+26
View File
@@ -193,6 +193,32 @@ pub(crate) fn names_photos(p: &Value) -> bool {
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
}
/// The photos `ids` names: `None` when it is missing or null (the command then falls back to the
/// selection, or its own default), else every entry as a photo id. An `ids` that isn't a list of
/// whole numbers is an error, never `None`: falling back would act on the selection instead of
/// the photos the call meant (`{"ids": 3}`, `{"ids": ["3"]}`).
pub(crate) fn photo_ids(p: &Value, cmd: &str) -> Result<Option<Vec<lightcraft_catalog::PhotoId>>> {
match p.get("ids") {
None | Some(Value::Null) => Ok(None),
Some(Value::Array(a)) => a
.iter()
.map(|v| v.as_u64().map(lightcraft_catalog::PhotoId).ok_or_else(|| bad(cmd, "`ids` must be photo ids (whole numbers)")))
.collect::<Result<Vec<_>>>()
.map(Some),
Some(_) => Err(bad(cmd, "`ids` must be a list of photo ids")),
}
}
/// The photo `id` names: `None` when it is missing or null (the command then uses the active
/// photo), else that id. An `id` that isn't a whole number is an error, never `None`: falling back
/// would act on the active photo instead of the one the call meant (`{"id": "5"}`).
pub(crate) fn photo_id(p: &Value, cmd: &str) -> Result<Option<lightcraft_catalog::PhotoId>> {
match p.get("id") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| bad(cmd, "`id` must be a photo id (a whole number)")),
}
}
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
+25 -10
View File
@@ -161,17 +161,17 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
}),
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
let id = super::photo_id(p, "stack.setTop")?.or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let id = super::photo_id(p, "stack.moveUp")?.or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, -1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let id = super::photo_id(p, "stack.moveDown")?.or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, 1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
@@ -183,17 +183,32 @@ pub fn specs() -> Vec<CommandSpec> {
"{id?} — this photo and the ones after it become their own stack",
in_stack,
|s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let id = super::photo_id(p, "stack.split")?.or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
}
),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p)?);
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
let collapsed = bool_or(p, "collapsed", !first);
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
}),
// `ids` names the stacks' photos without selecting them (a click on a stack badge)
CommandSpec {
explicit_targets: true,
..cmd!(
"stack.toggle",
"Expand/Collapse Stack",
["Photo", "Stack"],
Some("S"),
"{ids?, collapsed?: bool} — the stacks of the named photos (else of the selection)",
in_stack,
|s, p| {
let stacks = stacks_of(s, &s.targets(p)?);
if stacks.is_empty() {
return Err(bad("stack.toggle", "none of the photos is in a stack"));
}
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
let collapsed = bool_or(p, "collapsed", !first);
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
}
)
},
cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
set_collapsed(s, all, false, "Expand All Stacks")
+42 -26
View File
@@ -176,12 +176,18 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
}
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
/// how to load the original at preview size, whether the photo takes the per-file camera look —
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
/// proxies go stale when the fit changes).
/// how to load the original at preview size, whether the photo can take a preview-fitted look:
/// vendor raws and DNGs (whose tone can come from their preview); only those proxies go stale when
/// the fit changes; an older encoding makes any proxy stale).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale.
#[cfg(not(target_arch = "wasm32"))]
fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool {
photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG"))
}
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
#[cfg(not(target_arch = "wasm32"))]
@@ -205,15 +211,18 @@ struct SmartCounts {
failed: Vec<Value>,
}
/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
/// fallback of a file source are not the original.
/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview
/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the
/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them
/// would stamp its older look as current.
#[cfg(not(target_arch = "wasm32"))]
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
use crate::media::SourceRef;
match source {
SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
SourceRef::File { path, max_edge, loader, denoise, .. } => {
Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None })
}
SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy && !d.reduced => Some(SourceRef::Loaded(d)),
_ => None,
}
}
@@ -252,10 +261,11 @@ fn smart_run(
}
} else {
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
// full drive) is (re)built, and so is one written by an older camera-look fit (see
// `camera_preview::LOOK_VERSION`) when its original can be read
// full drive) is (re)built, and so is one written by an older encoding or camera-look
// fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its
// original can be read
let damaged = path.exists();
let stale = look && damaged && crate::smart::is_stale(&path);
let stale = damaged && crate::smart::is_stale_for(&path, look);
if damaged && !stale && crate::smart::is_valid(&path) {
n.built += 1;
} else {
@@ -268,7 +278,10 @@ fn smart_run(
let r = source
.ok_or_else(|| "nothing to build from".to_string())
.and_then(|s| s.load_source())
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
.and_then(|src| {
let info = src.info_or(Default::default());
crate::smart::encode(&src.image, Some(&info))
})
.and_then(|b| {
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
// Only create the leaf shard: do not recreate a missing external drive.
@@ -333,7 +346,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
}
let path = crate::smart::sharded_file_path(&dir, &ph);
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source, ph.fitted_look()));
jobs.push((id, path, source, preview_fitted_look(&ph)));
}
let custom = s.smart_previews_dir.is_some();
if background {
@@ -360,14 +373,14 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
/// is built, and a missing folder is never created. Returns the thread, if started.
/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews
/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]),
/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk
/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding
/// (every photo), or, for a raw that takes the per-file camera look or a DNG, one written by an older fit.
/// One whose original can't be read (offline) is kept as it is, still marked stale in its header:
/// Build Smart Previews rebuilds it once the original is back. Only existing proxies are touched:
/// none is built, and a missing folder is never created. Returns the thread, if started.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::thread::JoinHandle<()>> {
let dir = s.media.smart_dir.clone()?;
@@ -375,9 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
return None;
}
let max_edge = crate::media::SourceLevel::Preview.max_edge();
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect();
let jobs: Vec<SmartJob> =
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect();
let jobs: Vec<SmartJob> = photos
.iter()
.map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), preview_fitted_look(p)))
.collect();
std::thread::Builder::new()
.name("lc-smart-look".into())
.spawn(move || refresh_stale(&dir, jobs))
@@ -391,7 +406,7 @@ fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
let stale: Vec<SmartJob> = jobs
.into_iter()
.map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look))
.filter(|(_, path, _, _)| crate::smart::is_stale(path))
.filter(|(_, path, _, look)| crate::smart::is_stale_for(path, *look))
.collect();
if !stale.is_empty() {
// `custom`: the folder is there already, and must not be recreated if it went away since
@@ -548,7 +563,8 @@ mod tests {
let mut jobs = jobs_for(&mut s, id, Some(512));
assert_eq!(jobs.len(), 2, "view and thumbnail");
assert!(jobs[0].view_cache.is_some(), "the view renders first");
jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None };
jobs[1].source =
SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None, reduced: None };
assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source");
}
+6 -3
View File
@@ -62,7 +62,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth
/// The photo a query is about: `id` when given (which must be in the library), else the active one.
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
match p.get("id").and_then(Value::as_u64).map(PhotoId) {
match super::photo_id(p, c)? {
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
Some(id) => Err(bad(c, format!("no such photo {}", id.0))),
None => s.active().ok_or_else(|| bad(c, "no photo")),
@@ -139,8 +139,11 @@ pub fn specs() -> Vec<CommandSpec> {
.into_iter()
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
.collect();
// XMP: the sidecar if there is one, else the file's own packet
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
// Keep sidecar precedence; reuse the loaded bytes for embedded XMP.
let packet = match crate::sidecar::find_sidecar(path, s.sidecar_naming(ph.id)) {
Some(sc) => std::fs::read_to_string(sc).ok(),
None => lightcraft_meta::embedded(&bytes).xmp,
};
let xmp: Vec<Value> = packet
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
"{ids?} — reads each photo's XMP sidecar, or embedded XMP in raw/DNG and JPEG files when no sidecar exists: metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on; JPEG uses the first complete standard XMP APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; an unreadable sidecar fails without embedded fallback → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
has_selection,
read
),
+28
View File
@@ -93,6 +93,17 @@ const NON_ADJUSTMENT: &[&str] = &[
// the file's as-shot white, recorded next to the edit (ours comes from the file itself)
"crs:AsShotTemperature",
"crs:AsShotTint",
// which Lightroom version may read the edit, and checksums of the profiles it names
"crs:CompatibleVersion",
"crs:CameraProfileDigest",
"crs:LensProfileDigest",
// the lens profile Lightroom picked: ours is the file's own, only `LensProfileEnable` carries over
"crs:LensProfileName",
"crs:LensProfileFilename",
"crs:LensProfileIsEmbedded",
"crs:LensProfileSetup",
// a crop tool option (keep the crop inside the photo), not the crop itself
"crs:CropConstrainToUnitSquare",
];
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
@@ -492,6 +503,8 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|| k == "AutoTone"
|| k == "AutoGrayscaleMix"
|| (k == "PointColors" && value_is(k, &empty_points))
// the lens profile's strength, written at its default (100 %) next to every profile
|| (matches!(k, "LensProfileDistortionScale" | "LensProfileVignettingScale") && value_is(k, &|v| v.iter().all(|s| s.trim() == "100")))
};
let mut unmapped: Vec<String> = props
.keys()
@@ -869,6 +882,21 @@ mod tests {
assert_eq!(got, ["FutureSlider", "PointColors"]);
}
#[test]
fn lightroom_profile_bookkeeping_is_not_reported() {
// what Lightroom Classic writes next to every raw's edit: none of it changes the rendering
let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D"
crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False"
crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100"
crs:CropConstrainToUnitSquare="1""#;
let look = r#"<crs:Look rdf:parseType="Resource"><crs:Name>Vivid</crs:Name></crs:Look>"#;
assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::<String>::new());
assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap");
// a lens profile's strength away from 100 % is an adjustment we don't carry over
let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, "");
assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]);
}
#[test]
fn disabled_lens_profile_switch_keeps_embedded_corrections() {
// Lightroom applies a file's built-in lens corrections regardless of its profile switch,
+25 -3
View File
@@ -82,7 +82,11 @@ pub enum ResizeMode {
Width,
/// `value` = height in pixels.
Height,
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the larger).
/// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤
/// `height` (Lightroom's "Width & Height").
WidthHeight,
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the
/// larger; Lightroom's "Dimensions").
Dimensions,
/// `value` = megapixels.
Megapixels,
@@ -96,7 +100,7 @@ pub enum ResizeMode {
pub struct Resize {
pub mode: ResizeMode,
pub value: f32,
/// Second dimension for [`ResizeMode::Dimensions`].
/// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`].
pub height: u32,
/// Never upscale past the photo's own (cropped) size.
pub dont_enlarge: bool,
@@ -127,6 +131,7 @@ impl Resize {
ResizeMode::ShortEdge => v / short,
ResizeMode::Width => v / w,
ResizeMode::Height => v / h,
ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h),
ResizeMode::Dimensions => {
let (a, b) = (v, self.height as f64);
(a.max(b) / long).min(a.min(b) / short)
@@ -721,6 +726,7 @@ pub const OPTION_PARAMS: &[&str] = &[
"height",
"megapixels",
"percent",
"fit",
"dontEnlarge",
"ppi",
"limitKb",
@@ -940,6 +946,7 @@ impl ExportOptions {
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
num(k, v, 0.0, 1.0e9)?;
}
"fit" => one_of(k, v, &["dimensions", "widthHeight"])?,
"resize" => {
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
@@ -1133,8 +1140,12 @@ impl ExportOptions {
return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r });
}
let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge };
// `fit: widthHeight` binds each edge to its own number; the default fits either orientation
let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight"));
Some(match (f("width"), f("height")) {
(Some(w), Some(h)) => Resize { height: h.min(65_535.0) as u32, ..r(ResizeMode::Dimensions, w) },
(Some(w), Some(h)) => {
Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) }
}
(Some(w), None) => r(ResizeMode::Width, w),
(None, Some(h)) => r(ResizeMode::Height, h),
(None, None) => {
@@ -2881,6 +2892,13 @@ mod tests {
assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667)));
assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000)));
assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500)));
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225)));
// Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620)
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180)));
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337)));
assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160)));
// `fit` only matters with both numbers
assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667)));
let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap();
assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}");
assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000)));
@@ -2895,6 +2913,8 @@ mod tests {
let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false };
let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize;
assert_eq!(back, Some(r));
let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true };
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r));
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
// never larger than the encoders' limit, never empty
@@ -2966,6 +2986,7 @@ mod tests {
mode(ResizeMode::Width, value, 0),
mode(ResizeMode::Height, value, 0),
mode(ResizeMode::Dimensions, value, 1365),
mode(ResizeMode::WidthHeight, value, 1365),
mode(ResizeMode::Megapixels, value / 1000.0, 0),
mode(ResizeMode::Percent, value / 100.0, 0),
] {
@@ -3262,6 +3283,7 @@ mod tests {
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight"));
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
+291 -22
View File
@@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo};
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader};
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
@@ -366,20 +366,27 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usi
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
/// loops themselves when the machine is busy).
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info)))
}
/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded
/// (less memory held while it is developed).
pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info)))
}
/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and
/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether
/// it did: such a picture is not the full image, and an export never renders from it.
pub fn load_vec_reduced(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> {
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced)))
}
/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain
/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all
/// there is) and the source facts. Both pictures are the same size.
pub fn load_vec_pair(bytes: Vec<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec)))
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info)))
}
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
@@ -408,14 +415,25 @@ fn load_bytes_now(
bytes: std::borrow::Cow<'_, [u8]>,
max_edge: usize,
denoise: Option<&DenoiseSpec>,
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
allow_reduced: bool,
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo, bool), String> {
if lightcraft_raw::probe(&bytes).is_some() {
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
// a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image
// (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic
let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX)
.then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten())
.flatten();
let used_reduced = reduced.is_some();
let decoded = match reduced {
Some(r) => Ok(r),
None => lightcraft_raw::decode(&bytes),
};
let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) {
Ok(r) => r,
Err(Ok(why)) => {
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
return load_embedded_preview(&bytes, max_edge)
.map(|(img, info)| (img, None, info))
.map(|(img, info)| (img, None, info, false))
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
}
Err(Err(e)) => return Err(e),
@@ -426,13 +444,26 @@ fn load_bytes_now(
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
// (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it)
let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
// A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit,
// then remove them again: the rendered source leaves those corrections to the pipeline.
let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction()))
.then(|| raw.opcodes.list3.clone());
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
let ((xy, t, camera_look), mattes) = rayon::join(
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()),
|| {
let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops));
// With the opcodes restored, the proxy already includes vignetting; do not apply it twice.
let embedded = if removed.is_some() { None } else { lens.as_ref() };
let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded);
if let Some(ops) = removed {
raw.opcodes.list3 = ops;
}
colour
},
|| dng_mattes(&bytes, &info),
);
drop(bytes);
@@ -529,13 +560,14 @@ fn load_bytes_now(
// (the catalog knows the original's size: `media::source_info`)
native_long: None,
},
used_reduced,
));
}
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
drop(bytes);
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false))
}
/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from
@@ -1060,17 +1092,7 @@ pub fn fs_pair_loader() -> PairLoader {
/// interactive loads (the loupe, exports) never wait — they are counted, so background work
/// yields to them.
pub fn fs_hooks() -> (FileLoader, FileProbe) {
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 };
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let r = load_vec(bytes, max_edge);
// the file, the samples and the intermediate images are gone: give their pages back
crate::memory::release();
r
});
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge)));
let probe: FileProbe = Arc::new(|path: &str| {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let _permit = crate::memory::work_gate().acquire(len);
@@ -1082,6 +1104,24 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
(loader, probe)
}
/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader.
pub fn fs_reduced_loader() -> ReducedLoader {
Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge)))
}
/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains.
fn fs_load<T>(path: &str, max_edge: usize, load: impl FnOnce(Vec<u8>) -> Result<T, String>) -> Result<T, String> {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 });
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let r = load(bytes);
// the file, the samples and the intermediate images are gone: give their pages back
crate::memory::release();
r
}
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
/// filesystem doesn't say or the time is before 1970.
fn file_modified(path: &str) -> Option<String> {
@@ -1096,6 +1136,7 @@ impl crate::Session {
pub fn with_fs(mut self) -> Self {
let (l, p) = fs_hooks();
self.media.file_loader = Some(l);
self.media.reduced_loader = Some(fs_reduced_loader());
self.media.file_probe = Some(p);
self.media.preview_loader = Some(fs_preview_loader());
self.media.denoise.loader = Some(fs_pair_loader());
@@ -1183,6 +1224,21 @@ pub(crate) mod tests {
let (t, tint) = a99m2.as_shot(d65);
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
// a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a
// Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K)
let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([
(5651.0, g(1418.0, 1357.0)),
(7505.0, g(1653.0, 1171.0)),
(6594.0, g(1533.0, 1259.0)),
(3174.0, g(963.0, 2016.0)),
(8529.0, g(1807.0, 1080.0)),
]);
let with_canon = ColorData { kelvin_presets: canon, ..Default::default() };
let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III"));
assert!(!g1x3.relative && g1x3.scale_white.is_some());
let (t, tint) = g1x3.as_shot(d65);
assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III"));
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
assert!(bare.legacy_relative && bare.scale_white.is_none());
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
@@ -1659,6 +1715,49 @@ pub(crate) mod tests {
Some((best.1, best.0))
}
#[test]
fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() {
use lightcraft_geom::{Orientation, Point};
use lightcraft_pipeline::optics::Warp;
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] {
let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue };
let off = lightcraft_raw::probe_info(&bytes).unwrap();
assert!(embedded_lens(&off).is_none());
let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap();
let at = tiff.find(0x7036).unwrap().offset as usize;
let mut enabled = Vec::new();
tiff.order.put_u16(&mut enabled, 17);
bytes[at..at + 2].copy_from_slice(&enabled);
let mut raw = lightcraft_raw::probe_info(&bytes).unwrap();
raw.orientation = Orientation::Normal;
let mut settings = lightcraft_develop::DevelopSettings::default();
settings.optics.lens_profile = true;
let base_lens = embedded_lens(&raw).unwrap();
let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens));
for orientation in 1..=8 {
let o = Orientation::from_exif(orientation);
raw.orientation = o;
let lens = embedded_lens(&raw).unwrap();
for edge in [1200.0, 2560.0, 9504.0] {
let scale = edge / 9504.0;
let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) };
let oriented = Warp::from_settings(w, h, &settings, Some(&lens));
for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] {
let point = Point::new(x * 9504.0, y * 6336.0);
let source = base.corrected_to_source(point, 1);
let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0);
let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0);
let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1);
assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}");
}
}
}
}
}
/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the
/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile
/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide
@@ -1831,6 +1930,98 @@ pub(crate) mod tests {
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
}
/// Corrections used while fitting a DNG's preview must not be baked into its editable source.
/// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative.
#[test]
fn dng_preview_corrections_leave_the_render_source_uncorrected() {
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&plain).unwrap();
raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]);
let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let (before, expected) = load_bytes(&before, 64).unwrap();
let (after, info) = load_bytes(&corrected, 64).unwrap();
assert_eq!(before.data, after.data, "lens correction belongs to the pipeline");
assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some()));
assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved");
}
/// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit.
/// The source pixels stay uncorrected, including when the file is rotated.
#[test]
fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() {
use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb};
use lightcraft_raw::{Opcode, Orientation};
let (w, h) = (384usize, 256usize);
let data = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32;
let colour = match (i / w % 2, i % w % 2) {
(0, 0) => 1.2,
(1, 1) => 0.8,
_ => 1.0,
};
256 + (v * colour * 15744.0) as u16
})
.collect();
let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default());
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.color = lightcraft_raw::ColorData {
illuminant: [21, 0],
color_matrix: [Some(REC2020.from_xyz()), None],
as_shot_neutral: Some([1.0; 3]),
..Default::default()
};
let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
raw.opcodes.list3 = vec![
Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] },
Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] },
];
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
let to_srgb = REC2020.to_space(&SRGB);
let pixels: Vec<u8> = corrected
.data
.iter()
.flat_map(|p| {
let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i]));
let y = luminance_2020(p).max(1e-6);
let gain = (1.0 - (-3.0 * y).exp()) / y;
to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8)
})
.collect();
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
for orientation in [Orientation::Normal, Orientation::Rotate90] {
raw.orientation = orientation;
let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
// Append a preview IFD to the little-endian DNG written above.
let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize;
let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize;
let next = root + 2 + 12 * count;
let start = file.len() as u32;
file[next..next + 4].copy_from_slice(&start.to_le_bytes());
let jpeg_at = start + 2 + 4 * 12 + 4;
file.extend(4u16.to_le_bytes());
for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] {
file.extend(tag.to_le_bytes());
file.extend(4u16.to_le_bytes());
file.extend(1u32.to_le_bytes());
file.extend(value.to_le_bytes());
}
file.extend(0u32.to_le_bytes());
file.extend(&jpeg);
let (source, info) = load_bytes(&file, 96).unwrap();
let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview");
let target = 1.0 - (-3.0f32 * 0.18).exp();
assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18));
let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation);
assert_eq!(source.data, expected.data, "the editable source is not lens-corrected");
}
}
/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's
/// own white-balanced neutral comes out yellow (blue 12 % low).
fn yellowing_look() -> lightcraft_color::Mat3 {
@@ -2090,6 +2281,84 @@ pub(crate) mod tests {
}
}
/// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the
/// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The
/// copy's samples are much brighter than the image's here, so a picture says which one it came from.
#[test]
fn exports_never_develop_a_dngs_reduced_copy() {
use lightcraft_tiff::tags::{self as t, photometric};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
use serde_json::json;
let linear = |subfile: u32, w: u32, h: u32, sample: u16| {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768]));
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] });
ifd
};
// (the copy must cover a thumbnail source's 512 px)
let (w, h) = (1200u32, 600u32);
let mut main = linear(0, w, h, 1500);
main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2]));
main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4]));
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0]));
ifd0.set(t::MAKE, Value::Ascii("DJI".into()));
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB]));
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] });
ifd0.add_sub_ifd(main);
ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000));
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / img.data.len().max(1) as f64;
let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / img.data.len().max(1) as f64;
// the loaders: only the thumbnail's develops the copy, and only when it covers the size
let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap();
assert!(used, "a thumbnail develops the copy");
assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px");
let (full, _) = load_vec(bytes.clone(), 512).unwrap();
assert!(g(&copy) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(&copy), g(&full));
let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("merge-pano.dng");
std::fs::write(&path, &bytes).unwrap();
let mut s = crate::Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8);
// an export at a thumbnail's size, before any thumbnail
assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb);
let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image);
// the thumbnail develops the copy and its source stays in memory…
s.media.clear_sources();
let thumb = s.thumb_job(id, 64).unwrap().run();
assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced));
let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image);
s.accept(&thumb);
assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}");
// …and an export after it still reads the full image
let job = s.export_job(id, 64, 64, space, depth).unwrap();
assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy");
let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image);
assert!((again - export).abs() < 1.0, "{again} vs {export}");
let _ = std::fs::remove_dir_all(&dir);
}
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
/// heuristic would never look) is used; a matte with an unknown name is ignored.
@@ -2393,8 +2662,8 @@ pub(crate) mod tests {
}
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first
/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing.
#[test]
fn undecodable_container_with_a_preview_imports_as_preview_only() {
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
+9 -3
View File
@@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize};
use crate::Session;
use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
/// File extensions offered by import discovery and file pickers (lower case).
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
"jxl", "gif", "bmp",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef",
"srw", "psd", "jxl", "gif", "bmp",
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
// failed with the reason ("HEIC/HEIF support isn't included in this build").
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
@@ -691,6 +692,11 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &Sc
.iter()
.map(|f| {
let info = input.cache.remove(f)?;
// (✕ set while the files' cached probes are checked: no more round trips to the disk; what is
// not reached counts as not probed, like the files after it)
if progress.cancel.load(Relaxed) {
return None;
}
let size = std::fs::metadata(f).map(|m| m.len()).ok();
(size.is_none() || size == Some(info.file_size)).then_some(info)
})
+30 -1
View File
@@ -40,7 +40,15 @@ pub fn database() -> Option<&'static LensDb> {
pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option<EmbeddedLens> {
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
let lens = match m.lens_model.as_deref() {
Some(model) => db.find_lens(m.lens_make.as_deref(), model, camera)?,
// Nikon DSLRs record only the lens specification ("24-70mm f/2.8"); a profile must be named exactly that
// (a user's alias), since the specification fits other makers' lenses too
None => {
let name = lightcraft_lensdb::spec_name(m.lens_spec?);
db.find_lens_named(m.lens_make.as_deref(), &name, camera)?
}
};
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
let corr = lens.correction(&shot)?;
@@ -108,6 +116,27 @@ mod tests {
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
}
/// A Nikon DSLR raw names no lens, only its specification (maker note `0x0084`): it is looked up by the name
/// built from it, and only a profile named exactly that is used, never another maker's lens that merely shares
/// the focal range and aperture.
#[test]
fn lens_specification_needs_a_profile_named_by_it() {
let nikon = lightcraft_meta::Metadata { lens_model: None, lens_spec: Some([18.0, 55.0, 3.5, 5.6]), ..meta() };
let at = |db: &LensDb| lookup(db, &nikon, 6000.0, 4000.0, Orientation::Normal);
// "Testcam 18-55mm f/3.5-5.6" fits the specification, but so would any maker's 18-55 f/3.5-5.6: refused
assert!(at(&LensDb::parse(DB).unwrap()).is_none());
let aliased = LensDb::parse(
&DB.replace("<model>Testcam 18-55mm f/3.5-5.6</model>", "<model>Testcam 18-55mm f/3.5-5.6</model><model>18-55mm f/3.5-5.6</model>"),
)
.unwrap();
let lens = at(&aliased).unwrap();
assert!(lens.warp.is_some() && lens.vignette.is_some());
assert_eq!(Some(lens), lookup(&aliased, &meta(), 6000.0, 4000.0, Orientation::Normal), "same profile as by its name");
// a lens model, when the file has one, still decides
let named_other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..nikon.clone() };
assert!(lookup(&aliased, &named_other, 6000.0, 4000.0, Orientation::Normal).is_none());
}
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
/// corners don't get the full frame's corner correction.
+12 -1
View File
@@ -950,7 +950,16 @@ impl Session {
return Vec::new();
}
let Some(old) = self.develop_of(id) else { return Vec::new() };
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
let mut old_json = old.to_json();
let mut new_json = new.to_json();
// False is omitted from stored profile JSON. Keep it explicit for this comparison so
// switching camera local tone mapping off is a change without copying untouched values.
old_json["profile"]["camera_local_tone"] = Value::Bool(old.profile.camera_local_tone);
new_json["profile"]["camera_local_tone"] = Value::Bool(new.profile.camera_local_tone);
// Compare actual HDR leaves even when persisted defaults omit the whole HDR object.
old_json["hdr"] = serde_json::json!(old.hdr);
new_json["hdr"] = serde_json::json!(new.hdr);
let Some(mut delta) = json_delta(&old_json, &new_json) else { return Vec::new() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version", "process"] {
o.remove(k);
@@ -1328,6 +1337,8 @@ mod tests_history_cursor;
#[cfg(test)]
mod tests_hold_view;
#[cfg(test)]
mod tests_ids_params;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
+3
View File
@@ -558,7 +558,10 @@ impl Session {
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some());
}
// not on Missing Photos: working that view out checks every original (minutes on a
// sleeping NAS), and the app checks them in the background once it runs
if self.selection.active.is_none()
&& self.source != LibrarySource::Missing
&& let Some(first) = self.visible_cloned().first()
{
self.selection = Selection::single(*first);
+48 -9
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 = 65;
pub const RENDER_CACHE_VERSION: u64 = 69;
/// 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];
@@ -132,6 +132,11 @@ fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, o
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs)
/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export,
/// which must render the full image.
pub type ReducedLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>;
/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than
/// `max_edge` (set by the app). `None`: no usable preview.
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
@@ -183,11 +188,17 @@ pub struct DecodedSource {
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
/// The same picture developed from the AI-denoised mosaic, when the photo has one cached.
pub denoised: Option<Arc<Twin>>,
/// These pixels came from the photo's smart preview (an offline original), not from its file.
/// A refresh must never rebuild a proxy from itself: that would stamp its older look as current.
pub from_proxy: bool,
/// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image
/// itself: good for a thumbnail, never for an export.
pub reduced: bool,
}
impl DecodedSource {
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
DecodedSource { image, info, camera_tone: None, denoised: None }
DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false }
}
/// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one,
@@ -258,6 +269,8 @@ pub enum SourceRef {
fallback: Option<std::path::PathBuf>,
/// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]).
denoise: Option<(DenoiseSpec, PairLoader)>,
/// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job.
reduced: Option<ReducedLoader>,
},
/// A smart preview standing in for a missing original.
Smart {
@@ -283,10 +296,13 @@ impl SourceRef {
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
let plain = || match loader {
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
None => Err(format!("no decoder available for {path}")),
SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => {
let plain = || match (reduced, loader) {
(Some(l), _) => {
l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) })
}
(None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
(None, None) => Err(format!("no decoder available for {path}")),
};
// the denoised picture comes with the plain one from a single decode; when it cannot be read the
// pair loader gives the plain picture alone (the cached file is never a reason to fail a render)
@@ -308,6 +324,8 @@ impl SourceRef {
info: Some(info),
camera_tone: None,
denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))),
from_proxy: false,
reduced: false,
})
})
} else {
@@ -353,6 +371,8 @@ pub struct MediaCache {
/// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first.
budget: usize,
pub file_loader: Option<FileLoader>,
/// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`].
pub reduced_loader: Option<ReducedLoader>,
pub file_probe: Option<FileProbe>,
pub preview_loader: Option<PreviewLoader>,
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
@@ -428,6 +448,7 @@ impl Default for MediaCache {
preview_capacity: 0,
budget,
file_loader: None,
reduced_loader: None,
file_probe: None,
preview_loader: None,
file_bytes: None,
@@ -720,11 +741,11 @@ impl MediaCache {
}
match self.scenes.iter().find(|s| s.id == *scene) {
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None },
}
}
Source::File { path } => {
SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None }
SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None }
}
}
}
@@ -1238,7 +1259,12 @@ impl crate::Session {
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
let b = thumb_bucket(long_edge);
self.build_job(id, b, b, false, true, Some(b))
let mut job = self.build_job(id, b, b, false, true, Some(b))?;
// (a thumbnail may develop a smaller copy of the raw image that the file keeps)
if let SourceRef::File { reduced, .. } = &mut job.source {
reduced.clone_from(&self.media.reduced_loader);
}
Some(job)
}
fn build_job(
@@ -1650,6 +1676,17 @@ impl crate::Session {
depth: lightcraft_pipeline::OutputDepth,
) -> Result<RenderJob, String> {
let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
// An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw
// image that the file keeps (Lightroom's merged DNGs)
let reduced = match &job.source {
SourceRef::Loaded(s) => s.reduced,
SourceRef::Fit { from, .. } => from.reduced,
_ => false,
};
if reduced {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
job.source = self.media.source_ref_uncached(&p, job.level);
}
// An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the
// background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.)
if let Some(wanted) = self.denoise_for_export(id, &job.settings)? {
@@ -1928,6 +1965,7 @@ mod tests {
})),
fallback: None,
denoise: None,
reduced: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());
@@ -1983,6 +2021,7 @@ mod tests {
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
fallback: None,
denoise: None,
reduced: None,
};
let undo_before = s.undo.len();
let request = job.request;
+4 -2
View File
@@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
}
impl Session {
/// Plan a merge of `ids` (catalog order of the given ids is kept).
/// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once
/// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory.
pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result<MergeJob> {
let mut sources = Vec::new();
for id in ids {
let mut seen = std::collections::HashSet::new();
for id in ids.iter().filter(|id| seen.insert(**id)) {
let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?;
match &p.source {
Source::File { path } => sources.push((*id, path.clone())),
+283 -5
View File
@@ -27,8 +27,10 @@
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
//! 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.
//! Manual reads also accept the first complete standard XMP APP1 in a bounded JPEG header (see `read_jpeg_xmp`).
use std::collections::{HashMap, HashSet};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -520,18 +522,77 @@ fn file_path(p: &Photo) -> Option<&str> {
}
}
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
/// The XMP packet that describes a photo on disk: its sidecar, else embedded raw/DNG or JPEG XMP.
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
if let Some(sc) = find_sidecar(original, naming)
&& let Ok(s) = std::fs::read_to_string(&sc)
{
return Some((s, sc));
if let Some(sc) = find_sidecar(original, naming) {
// A discovered sidecar keeps precedence even when reading it fails.
return std::fs::read_to_string(&sc).ok().map(|s| (s, sc));
}
if kind == MediaKind::Raw {
let bytes = std::fs::read(original).ok()?;
let x = lightcraft_meta::embedded(&bytes).xmp?;
return Some((x, PathBuf::from(original)));
}
if kind == MediaKind::Image {
let mut file = std::fs::File::open(original).ok()?;
let x = read_jpeg_xmp(&mut file, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS)?;
return Some((x, PathBuf::from(original)));
}
None
}
const JPEG_XMP_HEADER_SPAN: u64 = 16 * 1024 * 1024;
const JPEG_XMP_HEADER_STEPS: usize = 4096;
/// Read from SOI at the current position, stopping at the first standard XMP packet or SOS/EOI.
/// The span includes SOI, segment headers and skipped payloads; each marker/fill step after SOI
/// consumes one iteration. Only a complete APP1 payload (at most 65533 bytes) reaches the shared
/// parser, in a single SOI+APP1 wrapper (at most 65539 bytes). This does not validate the whole JPEG.
fn read_jpeg_xmp(reader: &mut (impl Read + Seek), header_span: u64, marker_steps: usize) -> Option<String> {
let mut header = reader.take(header_span);
let mut soi = [0; 2];
header.read_exact(&mut soi).ok()?;
if soi != [0xff, 0xd8] {
return None;
}
let mut filled = false;
for _ in 0..marker_steps {
if !filled {
let mut prefix = [0];
header.read_exact(&mut prefix).ok()?;
if prefix != [0xff] {
return None;
}
}
let mut code = [0];
header.read_exact(&mut code).ok()?;
let [marker] = code;
filled = marker == 0xff;
match marker {
0xff | 0xd8 | 0xd0..=0xd7 | 0x01 => continue,
0xd9 | 0xda => return None,
_ => {}
}
let mut length = [0; 2];
header.read_exact(&mut length).ok()?;
let payload_len = u16::from_be_bytes(length).checked_sub(2)?;
let remaining = header.limit().checked_sub(u64::from(payload_len))?;
if marker != 0xe1 {
// Seeking must spend the same span budget as reading, before the seek is attempted.
header.set_limit(remaining);
header.get_mut().seek(SeekFrom::Current(i64::from(payload_len))).ok()?;
continue;
}
let segment_len = 6usize.checked_add(usize::from(payload_len))?;
let [hi, lo] = length;
let mut segment = Vec::with_capacity(segment_len);
segment.extend_from_slice(&[0xff, 0xd8, 0xff, 0xe1, hi, lo]);
segment.resize(segment_len, 0);
header.read_exact(segment.get_mut(6..)?).ok()?;
if let Some(xmp) = lightcraft_meta::jpeg_segments(&segment).xmp {
return Some(xmp);
}
}
None
}
@@ -1019,3 +1080,220 @@ mod tests_repair_provenance;
#[cfg(test)]
#[path = "sidecar/tests_stem_owners.rs"]
mod tests_stem_owners;
#[cfg(test)]
mod jpeg_header_tests {
use super::{JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS, read_jpeg_xmp};
use std::io::{self, Cursor, Read, Seek, SeekFrom};
struct CountedReader {
cursor: Cursor<Vec<u8>>,
bytes_read: usize,
largest_read: usize,
seeks: usize,
chunk: usize,
read_error_at: Option<u64>,
seek_error: bool,
}
impl CountedReader {
fn new(bytes: Vec<u8>) -> Self {
Self { cursor: Cursor::new(bytes), bytes_read: 0, largest_read: 0, seeks: 0, chunk: usize::MAX, read_error_at: None, seek_error: false }
}
}
impl Read for CountedReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.largest_read = self.largest_read.max(buf.len());
if self.read_error_at.is_some_and(|at| self.cursor.position() >= at) {
return Err(io::Error::other("injected header read failure"));
}
let len = buf.len().min(self.chunk);
let read = self.cursor.read(&mut buf[..len])?;
self.bytes_read += read;
Ok(read)
}
}
impl Seek for CountedReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.seeks += 1;
if self.seek_error {
return Err(io::Error::other("injected header seek failure"));
}
self.cursor.seek(pos)
}
}
fn segment(marker: u8, payload: &[u8]) -> Vec<u8> {
let len = u16::try_from(payload.len() + 2).unwrap();
let mut bytes = vec![0xff, marker];
bytes.extend_from_slice(&len.to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn packet(rating: i8) -> String {
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), ..Default::default() }, None)
}
fn xmp_segment(packet: &str) -> Vec<u8> {
let mut payload = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
payload.extend_from_slice(packet.as_bytes());
segment(0xe1, &payload)
}
fn jpeg(packet: &str) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
bytes.extend(xmp_segment(packet));
bytes
}
#[test]
fn first_standard_packet_skips_other_segments_and_stops_before_tail() {
let first = packet(2);
let first_payload_len = xmp_segment(&first).len() - 4;
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[7; 17]));
bytes.extend(segment(0xe1, b"Exif\0\0tiny"));
let mut extended = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
extended.extend_from_slice(&[0; 40]);
extended.extend_from_slice(b"not a standard packet");
bytes.extend(segment(0xe1, &extended));
bytes.extend(xmp_segment(&first));
let end = bytes.len();
bytes.extend(xmp_segment(&packet(5)));
bytes.extend_from_slice(&[0xff, 0xda, 0, 2]);
bytes.extend_from_slice(&[3; 128]);
let mut reader = CountedReader::new(bytes);
reader.read_error_at = Some(end as u64);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), Some(first));
assert_eq!(reader.cursor.position(), end as u64, "later packets and image bytes are not read");
assert_eq!(reader.bytes_read, end - 17, "APP0 payload is skipped, not buffered");
assert_eq!(reader.seeks, 1);
assert_eq!(reader.largest_read, first_payload_len, "reads at most one complete APP1 payload at a time");
}
#[test]
fn stops_at_sos_or_eoi_without_reading_later_xmp() {
for stop in [0xda, 0xd9] {
let mut bytes = vec![0xff, 0xd8, 0xff, stop];
bytes.extend(xmp_segment(&packet(3)));
let mut reader = CountedReader::new(bytes);
reader.read_error_at = Some(4);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (4, 4, 0));
}
}
#[test]
fn rejects_incomplete_app1_even_when_partial_xmp_parses() {
let expected = packet(4);
let mut bytes = jpeg(&expected);
let declared = u16::from_be_bytes([bytes[4], bytes[5]]) + 7;
bytes[4..6].copy_from_slice(&declared.to_be_bytes());
let partial = lightcraft_meta::jpeg_segments(&bytes).xmp.unwrap();
assert_eq!(partial, expected, "the shared extractor tolerates a truncated declared payload");
assert_eq!(super::parse_sidecar(&partial, crate::crs::Target::Rendered).unwrap().rating, Some(4));
let len = bytes.len();
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read, len);
}
#[test]
fn span_budget_counts_soi_headers_and_skipped_payloads() {
let expected = packet(3);
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[9; 23]));
bytes.extend(xmp_segment(&expected));
let end = bytes.len();
let mut exact = CountedReader::new(bytes.clone());
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
assert_eq!(exact.cursor.position(), end as u64, "a complete early APP1 does not require EOI");
assert_eq!(exact.bytes_read, end - 23);
let mut short = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut short, (end - 1) as u64, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((short.bytes_read, short.cursor.position(), short.seeks), (10, 33, 1), "reject before APP1 payload read");
assert_eq!(short.largest_read, 2);
}
#[test]
fn span_budget_rejects_skip_before_seeking() {
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[8; 18]));
bytes.extend(xmp_segment(&packet(1)));
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, 6, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (6, 6, 0));
}
#[test]
fn marker_budget_counts_fill_and_standalone_markers() {
let expected = packet(1);
// Two fill steps, SOI, TEM, RST3, then APP1: six steps after the initial SOI.
let mut bytes = vec![0xff, 0xd8, 0xff, 0xff, 0xff, 0xd8, 0xff, 0x01, 0xff, 0xd3];
bytes.extend(xmp_segment(&expected));
for (steps, end) in [(0, 2), (5, 10)] {
let mut reader = CountedReader::new(bytes.clone());
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, steps), None);
assert_eq!((reader.bytes_read, reader.cursor.position()), (end, end as u64));
}
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, 6), Some(expected));
}
#[test]
fn rejects_invalid_headers_and_segment_lengths() {
for bytes in [
vec![],
vec![0xff],
b"\x89PNG\r\n\x1a\n".to_vec(),
vec![0xff, 0xd8, 0, 0xe1, 0, 2],
vec![0xff, 0xd8, 0xff, 0xe1, 0],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 0],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 1],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 3],
] {
let len = bytes.len();
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert!(reader.bytes_read <= len);
}
}
#[test]
fn propagates_read_and_seek_failures() {
for at in [0, 6] {
let mut reader = CountedReader::new(jpeg(&packet(4)));
reader.read_error_at = Some(at);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read as u64, at);
}
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[1]));
bytes.extend(xmp_segment(&packet(4)));
let mut reader = CountedReader::new(bytes);
reader.seek_error = true;
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.seeks), (6, 1));
}
#[test]
fn handles_short_reads_without_bypassing_span_limit() {
let expected = packet(2);
let bytes = jpeg(&expected);
let end = bytes.len();
let mut exact = CountedReader::new(bytes.clone());
exact.chunk = 1;
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
assert_eq!(exact.bytes_read, end);
for (span, read) in [(0, 0), (1, 1), (end - 1, 6)] {
let mut reader = CountedReader::new(bytes.clone());
reader.chunk = 1;
assert_eq!(read_jpeg_xmp(&mut reader, span as u64, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read, read);
assert!(reader.cursor.position() <= span as u64);
}
}
}
+210 -34
View File
@@ -3,15 +3,19 @@
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
//! at render time and is not baked into the pixels.
//! at render time and is not baked into the pixels, and the file's other decoder facts (its colour
//! model, gain table map and semantic mattes) ride in a tail after the JPEG, so offline edits
//! interpret the stored pixels the same way the online source did.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::Photo;
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_pipeline::SourceInfo;
use lightcraft_pipeline::masks::{MatteKind, Mattes};
use lightcraft_pipeline::tone::CameraTone;
use lightcraft_raster::{Rgb32f, Rgba8};
use lightcraft_raster::{Image, Rgb32f, Rgba8};
const MAGIC: &[u8] = b"LCSP1\n";
@@ -70,15 +74,73 @@ const JPEG_QUALITY: u8 = 97;
/// tracks the camera-look fit that is baked into those pixels.
const ENC_FIELD: &str = "enc";
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
/// makes every proxy from before this version stale. Version 2 is the first to record the field at
/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping, JPEG
/// settings or stored facts change, so proxies an older build wrote are rebuilt: a missing field
/// reads as 0, which makes every proxy from before this version stale. Version 2 is the first to
/// record the field at all (a version 1 proxy is stored at JPEG quality 92, which this build
/// raises to [`JPEG_QUALITY`]); version 3 is the first to store the decoder facts in the tail. A
/// stale proxy whose original is offline is kept as it is (see [`is_stale`]).
/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
pub(crate) const ENCODE_VERSION: u64 = 2;
pub(crate) const ENCODE_VERSION: u64 = 3;
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
/// The decoder facts a render interprets its settings against, stored with the proxy's pixels so
/// an offline original edits like it did online (the catalog's header facts cannot carry them).
/// In the proxy's tail, not its header: a real file's gain table map runs far past any header
/// budget (up to 12 336 gains).
#[derive(serde::Serialize, serde::Deserialize)]
struct SmartFacts {
raw: bool,
as_shot_temp: f64,
as_shot_tint: f64,
relative_wb: bool,
legacy_relative_wb: bool,
lens: Option<lightcraft_develop::EmbeddedLens>,
camera_color: Option<lightcraft_pipeline::CameraColor>,
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
/// The semantic mattes whose bytes follow the facts, in this order.
#[serde(default)]
mattes: Vec<StoredMatte>,
}
/// One stored matte's shape; its `len` bytes of 0..255 alpha follow the facts.
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredMatte {
kind: u8,
w: usize,
h: usize,
len: usize,
}
/// The facts and mattes a proxy's tail holds: the length-prefixed JSON, then one byte plane per
/// stored matte. `None` for an old proxy or a malformed tail; like a missing curve, the offline
/// render then keeps its header facts.
fn stored_facts(head: &serde_json::Value, bytes: &[u8]) -> Option<(SmartFacts, Mattes)> {
let tail = usize::try_from(head.get("tail")?.as_u64()?).ok()?;
if tail == 0 || tail > bytes.len() {
return None;
}
let t = bytes.get(bytes.len() - tail..)?;
let n = u32::from_le_bytes(t.get(..4)?.try_into().ok()?) as usize;
let facts: SmartFacts = serde_json::from_slice(t.get(4..4 + n)?).ok()?;
let mut mattes = Mattes::default();
let mut at = 4 + n;
for m in &facts.mattes {
let kind = MatteKind::from_code(m.kind)?;
if m.w == 0 || m.h == 0 || m.w.checked_mul(m.h)? != m.len {
return None;
}
mattes.push(kind, Image { width: m.w, height: m.h, data: t.get(at..at.checked_add(m.len)?)?.to_vec() });
at += m.len;
}
if at != t.len() {
return None;
}
Some((facts, mattes))
}
/// Encode a source image and its decoder facts (the camera tone curve and the [`SourceInfo`] the
/// render interprets settings against, if given) as a smart preview.
pub fn encode(img: &Rgb32f, info: Option<&SourceInfo>) -> Result<Vec<u8>, String> {
// scale so all but the brightest 0.05 % fit into 0..1
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
let scale = if lum.is_empty() {
@@ -113,28 +175,80 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
ENC_FIELD: ENCODE_VERSION,
});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
// The tail: the decoder facts (and the file's semantic mattes) after the JPEG, so the header
// stays small. `tail` records its byte count from the end of the file, so a header rewritten
// in place (an edited look version) keeps pointing at it.
let mut tail: Vec<u8> = Vec::new();
if let Some(i) = info {
if let Some(t) = &i.camera_tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
let matte_list: Vec<StoredMatte> = i
.mattes
.as_deref()
.map(|m| m.iter().map(|(kind, a)| StoredMatte { kind: kind.code(), w: a.width, h: a.height, len: a.data.len() }).collect())
.unwrap_or_default();
let facts = SmartFacts {
raw: i.raw,
as_shot_temp: i.as_shot_temp,
as_shot_tint: i.as_shot_tint,
relative_wb: i.relative_wb,
legacy_relative_wb: i.legacy_relative_wb,
lens: i.lens,
camera_color: i.camera_color.as_deref().cloned(),
local_tone: i.local_tone.as_deref().cloned(),
mattes: matte_list,
};
let facts = serde_json::to_vec(&facts).map_err(|e| e.to_string())?;
let n = u32::try_from(facts.len()).map_err(|_| "smart preview facts too large")?;
tail.extend_from_slice(&n.to_le_bytes());
tail.extend_from_slice(&facts);
if let Some(m) = i.mattes.as_deref() {
for (_, a) in m.iter() {
tail.extend_from_slice(&a.data);
}
}
head["tail"] = serde_json::json!(tail.len());
}
out.extend_from_slice(head.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&jpg);
out.extend_from_slice(&tail);
Ok(out)
}
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
/// curve is ignored, like a missing one).
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
/// Decode a smart preview back into a source image, its stored camera tone curve and the stored
/// decoder facts (an invalid curve or tail is ignored, like a missing one).
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>, Option<SourceInfo>), String> {
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
let tail = head.get("tail").and_then(serde_json::Value::as_u64).and_then(|t| usize::try_from(t).ok()).unwrap_or(0);
let start = MAGIC.len() + nl + 1;
let end = bytes.len().saturating_sub(tail);
if end <= start {
return Err("bad smart preview".into());
}
let d = lightcraft_codecs::decode(bytes.get(start..end).ok_or("bad smart preview")?, Default::default()).map_err(|e| e.to_string())?;
// the decoder undoes the sRGB encoding; the values are the source's own primaries
let mut img = d.image;
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
Ok((img, tone))
let info = stored_facts(&head, bytes).map(|(f, mattes)| SourceInfo {
raw: f.raw,
as_shot_temp: f.as_shot_temp,
as_shot_tint: f.as_shot_tint,
relative_wb: f.relative_wb,
legacy_relative_wb: f.legacy_relative_wb,
lens: f.lens,
camera_color: f.camera_color.map(Arc::new),
camera_tone: tone,
mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)),
local_tone: f.local_tone.map(Arc::new),
native_long: None,
});
Ok((img, tone, info))
}
/// Where a library keeps its smart previews.
@@ -276,15 +390,26 @@ pub fn is_valid(path: &Path) -> bool {
}
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
let size_ok = rest
.get(..nl)
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
.is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
let json = rest.get(..nl).and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok());
let size_ok = json.as_ref().is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) {
return false;
}
let tail = json.and_then(|h| h.get("tail").and_then(serde_json::Value::as_u64)).and_then(|t| usize::try_from(t).ok()).unwrap_or(0);
if tail == 0 {
// no tail: the file ends at the JPEG's end marker
let mut end = [0u8; 2];
return f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9];
}
// with a tail, the JPEG must end right before it and the tail must be there in full
let Ok(meta) = f.metadata() else { return false };
let Ok(file_len) = usize::try_from(meta.len()) else { return false };
let Some(jpeg_end) = file_len.checked_sub(tail) else { return false };
if jpeg_end <= MAGIC.len() + nl + 3 {
return false;
}
let mut end = [0u8; 2];
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
f.seek(SeekFrom::Start((jpeg_end - 2) as u64)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
}
/// The fit version a header records: a missing, negative, fractional or otherwise malformed value
@@ -299,12 +424,15 @@ fn header_encode_version(head: &serde_json::Value) -> u64 {
head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
/// Whether the complete proxy at `path` is out of date for this build: written by an older
/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
pub fn is_stale(path: &Path) -> bool {
/// Whether the complete proxy at `path` is out of date for a photo and must be rebuilt: written by
/// an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG quality) — this
/// reaches every proxy — or, when the photo takes the per-file camera look (`look`,
/// [`lightcraft_catalog::Photo::fitted_look`]), by an older camera-look fit than this build's (so
/// its stored curve, and the colour baked into its pixels, are out of date; the fit only reaches
/// those raws, so a JPEG's or DNG's proxy is never stale for it). A proxy from a newer build is
/// left alone (rebuilding it here would only undo that build's work), as is one that can't be
/// read: those are [`is_valid`]'s business.
pub fn is_stale_for(path: &Path, look: bool) -> bool {
use std::io::Read;
if !is_valid(path) {
return false;
@@ -316,9 +444,14 @@ pub fn is_stale(path: &Path) -> bool {
}
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
rest.get(..nl)
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
rest.get(..nl).and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok()).is_some_and(|h| {
header_encode_version(&h) < ENCODE_VERSION || (look && header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
})
}
/// [`is_stale_for`] for a photo that takes the per-file camera look: both conditions apply.
pub fn is_stale(path: &Path) -> bool {
is_stale_for(path, true)
}
/// The marker file in a smart previews folder: the camera-look fit version and encode version its
@@ -356,7 +489,14 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None })
decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource {
image: Arc::new(image),
info,
camera_tone,
denoised: None,
from_proxy: true,
reduced: false,
})
}
#[cfg(test)]
@@ -453,7 +593,8 @@ mod tests {
}))
.unwrap();
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let bytes = encode(&img, Some(&tone)).unwrap();
let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() };
let bytes = encode(&img, Some(&info)).unwrap();
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
let dir = temp("tone");
let path = dir.join("t.lcsp");
@@ -473,15 +614,50 @@ mod tests {
let mut hostile = MAGIC.to_vec();
hostile.extend_from_slice(head.to_string().as_bytes());
hostile.extend_from_slice(&bytes[nl..]);
let (pixels, tone) = decode(&hostile).unwrap();
let (pixels, tone, _) = decode(&hostile).unwrap();
assert_eq!((pixels.width, tone), (img.width, None));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn facts_and_mattes_travel_with_the_proxy() {
let img = lightcraft_scenes::demo_library()[0].render(32, 20);
let mut mattes = Mattes::default();
mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] });
let info = SourceInfo {
raw: true,
as_shot_temp: 4200.0,
as_shot_tint: -5.0,
legacy_relative_wb: true,
mattes: Some(std::sync::Arc::new(mattes)),
..Default::default()
};
let bytes = encode(&img, Some(&info)).unwrap();
let (_, _, back) = decode(&bytes).unwrap();
let back = back.expect("the facts are read back");
assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true));
assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160);
// a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts)
let dir = temp("facts");
let path = dir.join("f.lcsp");
std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap();
assert!(!is_valid(&path));
let _ = std::fs::remove_dir_all(&dir);
// an old proxy (no tail) has no facts, and neither has one with a malformed tail
assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none());
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
head["tail"] = serde_json::json!(bytes.len());
let mut lying = MAGIC.to_vec();
lying.extend_from_slice(head.to_string().as_bytes());
lying.extend_from_slice(&bytes[nl..]);
assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted");
}
#[test]
fn roundtrip_keeps_the_picture() {
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap();
assert_eq!((back.width, back.height), (img.width, img.height));
assert!(tone.is_none());
let mut err = 0.0f64;
+37 -6
View File
@@ -34,8 +34,8 @@
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
//!
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
//! not cancelled midway.
//! The walk for new files stops at the import's limits on depth and entries (logged), or when the
//! scan is cancelled.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -52,13 +52,19 @@ use crate::{EngineError, Result, Session};
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
/// whole scan) and the library's photos checked.
/// How far a scan is: the folders and files listed so far (the walk, which comes first and on a
/// network share can take minutes), the folder's files probed ([`ScanProgress`], whose `cancel`
/// stops the whole scan, walk included) and the library's photos checked.
#[derive(Default)]
pub struct SyncProgress {
pub files: ScanProgress,
pub checked: std::sync::atomic::AtomicUsize,
pub to_check: std::sync::atomic::AtomicUsize,
/// Folders and files (photos or not) the walk has listed.
pub folders_listed: std::sync::atomic::AtomicUsize,
pub files_listed: std::sync::atomic::AtomicUsize,
/// The walk is over (or there was nothing to walk).
pub listed: std::sync::atomic::AtomicBool,
}
impl SyncProgress {
@@ -68,6 +74,15 @@ impl SyncProgress {
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
}
/// While a scan lists the folder (before any file can be checked): the folders and files
/// listed so far. `None` once the listing is over.
pub fn listing(&self) -> Option<(usize, usize)> {
use std::sync::atomic::Ordering::{Acquire, Relaxed};
// (Acquire pairs with the Release store of `listed`: once the listing is seen to be
// over, so is what was stored before it, and the counts that follow are the final ones)
(!self.listed.load(Acquire)).then(|| (self.folders_listed.load(Relaxed), self.files_listed.load(Relaxed)))
}
}
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
@@ -196,6 +211,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let SyncInput { folder, scan, ids, naming, labels } = input;
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
progress.listed.store(true, std::sync::atomic::Ordering::Release);
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
}
// (how each photo's sidecar is named: a pass over the library, made here and not while the
@@ -207,15 +223,30 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
// network share that is one request for a folder's names where it was several for each
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
// (how many photos there are to check is known before the walk: the count shows while it runs)
// (how many photos there are to check is known before the walk; while the walk runs, the
// folders and files it listed tell how far it is)
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
let mut listing = Listing::default();
let (files, _) = crate::import::expand_noting(
std::slice::from_ref(&folder),
scan.skip(),
crate::walk::Limits::default(),
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
Some(&mut |dir, names, complete| {
use std::sync::atomic::Ordering::Relaxed;
progress.folders_listed.fetch_add(1, Relaxed);
let files = names.iter().filter(|(_, kind)| *kind == crate::walk::EntryKind::File).count();
progress.files_listed.fetch_add(files, Relaxed);
listing.note(dir, names, complete);
// ✕ stops the walk too (a cancelled scan's result is not used)
!progress.files.cancel.load(Relaxed)
}),
);
progress.listed.store(true, std::sync::atomic::Ordering::Release);
// ✕ during the listing: its result is not used, so nothing more is asked of the disk (the files' cached probes
// would each be checked against the file, a round trip per file on a network share)
if progress.files.cancel.load(std::sync::atomic::Ordering::Relaxed) {
return FolderChanges { path: folder, seen, ..Default::default() };
}
let listing = &listing;
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
+12 -4
View File
@@ -146,8 +146,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
.collect();
// (`dir.jpg` is gone, though a sidecar of its name is there)
let mut l = Listing::default();
let walked =
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
let walked = crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
l.note(d, names, complete);
true
});
assert_eq!(walked.files.len(), 12);
let nothing = Listing::default();
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
@@ -167,7 +169,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
l.asked.load(std::sync::atomic::Ordering::Relaxed)
};
let mut with = Listing::default();
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
with.note(d, names, complete);
true
});
let (with, without) = (asked(&with), asked(&Listing::default()));
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
// names are found in any case, 21 where they are not)
@@ -181,7 +186,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
// a folder of plain names only: the disk is asked nothing at all
let sub = dir.join("sub");
let mut only_plain = Listing::default();
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
only_plain.note(d, names, complete);
true
});
let mut c2 = Catalog::new();
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
let p = photo(&mut c2, &sub.join(name));
+190 -5
View File
@@ -519,7 +519,7 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
}
/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
/// A crop of no size (or a hair wide), as a sidecar can set it, made the render's
/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
/// neighbour past isize::MAX). Such a photo renders, quickly.
@@ -533,7 +533,7 @@ fn degenerate_crops_render_with_spots_and_masks() {
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
set_crop_unchecked(&mut s, rect, 30.0);
let t = std::time::Instant::now();
let r = s.render_now(id, 700, 450).unwrap();
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
@@ -541,13 +541,23 @@ fn degenerate_crops_render_with_spots_and_masks() {
}
}
/// Store a crop as a sidecar can, without `crop.set`'s minimum size, kept inside the turned photo.
fn set_crop_unchecked(s: &mut Session, [x0, y0, x1, y1]: [f64; 4], angle: f64) {
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
let mut d = active_dev(s);
d.crop.geometry = lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(x0, y0, x1, y1), angle }.constrained(w, h);
s.set_develop(id, d, "Crop").unwrap();
}
/// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop
/// rectangle; it now falls back to the image's own aspect, a finite crop inside the image.
#[test]
fn rotate_aspect_of_degenerate_crop_stays_finite() {
for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] {
let mut s = demo();
s.execute("crop.set", &json!({"rect": rect})).unwrap();
set_crop_unchecked(&mut s, rect, 0.0);
s.execute("crop.rotateAspect", &json!({})).unwrap();
let r = active_dev(&s).crop.geometry.rect;
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}");
@@ -556,17 +566,36 @@ fn rotate_aspect_of_degenerate_crop_stays_finite() {
}
}
/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop.
/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. Their
/// maths overflow, so they fall back to a full-size crop and pass the minimum size.
#[test]
fn crop_set_huge_rect_stays_finite() {
for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] {
let mut s = demo();
let _ = s.execute("crop.set", &json!({"rect": rect}));
s.execute("crop.set", &json!({"rect": rect})).unwrap();
let r = active_dev(&s).crop.geometry.rect;
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}");
}
}
/// `crop.set` took a crop of no size (or a sliver), and the next export wrote a 1×1 image (#1952).
/// It now refuses a crop smaller than a drag of the crop tool can make, and keeps the one it had.
/// A rect far wider than the photo is a sliver too once it is shrunk to fit inside it.
#[test]
fn crop_set_refuses_a_crop_smaller_than_the_tool_allows() {
let mut s = demo();
s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9]})).unwrap();
let before = active_dev(&s).crop.geometry;
for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.4, 0.8, 0.4], [0.0, 0.0, 1e-9, 1.0], [0.0, 0.0, 1e6, 1.0], [0.0, 0.0, 1.0, 1e6]]
{
let e = s.execute("crop.set", &json!({"rect": rect})).unwrap_err();
assert!(e.to_string().contains("smaller"), "{rect:?}: {e}");
assert_eq!(active_dev(&s).crop.geometry, before, "{rect:?}");
}
s.execute("crop.set", &json!({"rect": [0.45, 0.45, 0.55, 0.55]})).unwrap();
assert_eq!(active_dev(&s).crop.geometry.rect, lightcraft_geom::Rect::new(0.45, 0.45, 0.55, 0.55));
}
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
#[test]
@@ -917,6 +946,119 @@ fn auto_sync_carries_only_the_changed_settings() {
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7);
}
#[test]
fn auto_sync_camera_local_tone_off_preserves_other_settings() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, (profile, amount, contrast)) in ids.iter().zip([("lc.color", 100, 0), ("lc.vivid", 37, 25), ("lc.mono", 63, -20)]) {
s.execute("library.select", &json!({"ids": [id]})).unwrap();
let settings = json!({"profile": {"id": profile, "amount": amount}, "light": {"contrast": contrast}});
s.execute("develop.merge", &json!({"settings": settings})).unwrap();
}
let initial: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect();
assert!(initial.iter().all(|d| !d.profile.camera_local_tone));
s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap();
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
let on = json!({"settings": {"profile": {"camera_local_tone": true}}, "label": "Camera Local Tone Mapping"});
let off = json!({"settings": {"profile": {"camera_local_tone": false}}, "label": "Camera Local Tone Mapping"});
// Positive control: On reaches both selected photos, changing only this setting.
s.execute("develop.merge", &on).unwrap();
let mut synced = initial.clone();
for d in synced.iter_mut().take(2) {
d.profile.camera_local_tone = true;
}
for (id, expected) in ids.iter().zip(&synced) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "On: photo {id}");
}
let undo_before = s.undo.len();
let history_before: Vec<_> = ids.iter().map(|id| s.catalog.photo(PhotoId(*id)).unwrap().history.len()).collect();
s.execute("develop.merge", &off).unwrap();
assert!(!s.develop_of(PhotoId(ids[0])).unwrap().profile.camera_local_tone);
assert!(!s.develop_of(PhotoId(ids[1])).unwrap().profile.camera_local_tone, "Auto Sync must carry camera local tone Off");
assert_eq!(s.undo.len(), undo_before + 1, "Off is one undoable edit");
for (i, (id, expected)) in ids.iter().zip(&initial).enumerate() {
let photo = s.catalog.photo(PhotoId(*id)).unwrap();
assert_eq!(photo.develop.as_ref(), expected, "Off: photo {id}");
assert_eq!(photo.history.len(), history_before[i] + usize::from(i < 2), "Off history: photo {id}");
let stored = photo.develop.to_json();
assert!(stored["profile"].get("camera_local_tone").is_none(), "false stays omitted: photo {id}");
assert_eq!(&lightcraft_develop::DevelopSettings::from_json(&stored).unwrap(), expected);
}
// One Undo/Redo restores the complete settings of both selected photos, not the third.
s.execute("edit.undo", &json!({})).unwrap();
for (id, expected) in ids.iter().zip(&synced) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Undo: photo {id}");
}
s.execute("edit.redo", &json!({})).unwrap();
for (id, expected) in ids.iter().zip(&initial) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Redo: photo {id}");
}
}
#[test]
fn auto_sync_camera_local_tone_changes_only_when_edited() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
let settings = json!({"profile": {"id": "lc.vivid", "amount": 37, "camera_local_tone": true}, "light": {"contrast": 25}});
s.execute("develop.merge", &json!({"settings": settings})).unwrap();
let mut expected: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect();
assert!(!expected[0].profile.camera_local_tone);
assert!(expected[1].profile.camera_local_tone);
s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap();
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
// An unrelated edit must not copy the active photo's unchanged false over the peer's true.
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap();
for d in expected.iter_mut().take(2) {
d.light.exposure = 0.7;
}
for (id, settings) in ids.iter().zip(&expected) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unrelated edit: photo {id}");
}
let peer_history = s.catalog.photo(PhotoId(ids[1])).unwrap().history.len();
s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": false}}})).unwrap();
assert_eq!(s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(), peer_history, "unchanged toggle adds no peer history");
for (id, settings) in ids.iter().zip(&expected) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unchanged toggle: photo {id}");
}
// Direct unchanged edits may still add an active undo step. With Auto Sync off, real
// Boolean changes affect only the active photo; selected and unselected peers stay exact.
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
for on in [true, false] {
s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": on}}})).unwrap();
expected[0].profile.camera_local_tone = on;
for (id, settings) in ids.iter().zip(&expected) {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Auto Sync disabled: photo {id}");
}
}
}
#[test]
fn auto_sync_hdr_off_returns_selected_photos_to_sdr() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
assert_eq!(ids.len(), 2);
for id in &ids {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().hdr, lightcraft_develop::Hdr::default(), "default-HDR fixture");
}
s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap();
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
let enabled = |s: &Session| ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().hdr.enabled).collect::<Vec<_>>();
s.execute("develop.hdr", &json!({"enabled": true})).unwrap();
assert_eq!(enabled(&s), vec![true, true], "HDR On must reach both selected photos");
s.execute("develop.hdr", &json!({"enabled": false})).unwrap();
assert_eq!(enabled(&s), vec![false, false], "HDR Off must reach both selected photos");
}
#[test]
fn json_delta_keeps_only_changes() {
let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1});
@@ -1682,3 +1824,46 @@ fn malformed_photo_targets_are_refused_not_widened() {
let preset = s.presets.first().unwrap().id.clone();
s.execute("preset.apply", &json!({"id": preset})).unwrap();
}
#[test]
fn auto_sync_hdr_enabled_preserves_per_photo_tuning() {
use lightcraft_develop::{DevelopSettings, Hdr, ProcessVersion};
let mut s = demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
assert_eq!(ids.len(), 2);
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
// Keep distinct stored processes explicit: the default is now V2; no rendering claim is made.
let mut expected = [DevelopSettings::default(), DevelopSettings { process: ProcessVersion(2), ..DevelopSettings::default() }];
expected[0].process = ProcessVersion(1);
expected[0].light.exposure = 0.5;
expected[0].light.contrast = 12.0;
expected[1].light.exposure = -0.25;
expected[1].light.contrast = -22.0;
expected[1].hdr = Hdr {
enabled: false,
max_ev: 4.0,
sdr_brightness: 11.0,
sdr_contrast: -12.0,
sdr_highlights: 13.0,
sdr_shadows: -14.0,
sdr_whites: 15.0,
sdr_clarity: -16.0,
};
for (id, settings) in ids.iter().zip(&expected) {
s.set_develop(*id, settings.clone(), "HDR tuning fixture").unwrap();
}
s.execute("library.select", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "active": ids[0].0})).unwrap();
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
let selection = s.selection.clone();
for enabled in [true, false] {
s.execute("develop.hdr", &json!({"enabled": enabled})).unwrap();
for (id, settings) in ids.iter().zip(&mut expected) {
settings.hdr.enabled = enabled;
let actual = s.develop_of(*id).unwrap();
assert_eq!(actual.hdr, settings.hdr, "HDR tuning must survive enabled={enabled} for photo {id:?}");
assert_eq!(actual.as_ref(), &*settings, "other per-photo edits and process must survive enabled={enabled}");
}
assert_eq!(s.selection, selection, "selection and active photo must survive enabled={enabled}");
assert!(s.auto_sync, "Auto Sync must remain enabled");
}
}
+55
View File
@@ -259,3 +259,58 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() {
assert_eq!(s.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&root);
}
/// Opens `lib` with a probe that counts the originals it checks.
fn open_counting(lib: &Path, clock: &Arc<Mutex<String>>) -> (Session, Arc<std::sync::atomic::AtomicUsize>) {
let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut s = Session::new().with_fs();
let c = clock.clone();
s.clock = Box::new(move || c.lock().unwrap().clone());
let n = checks.clone();
s.media.availability.set_probe(Arc::new(move |p| {
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Path::new(p).exists()
}));
s.open_library(lib, false).unwrap();
(s, checks)
}
/// A library last shown on Missing Photos opens without checking its originals: that view holds
/// no Local records, so forgetting them needn't work it out, and no photo is picked from it (each
/// check can wait for a sleeping NAS; a big library on one froze the window for minutes).
#[test]
fn opening_on_missing_photos_checks_no_originals() {
let root = temp_dir("missing");
let (a, lib) = (root.join("a"), root.join("lib"));
for i in 0..5 {
png(&a.join(format!("m{i}.png")), i);
}
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
let mut s = open(&lib, &clock);
s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap();
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
// every original is there: the view is empty and nothing is selected
assert!(s.visible().is_empty());
assert!(s.selection.active.is_none());
s.close_library().unwrap();
drop(s);
let (s, checks) = open_counting(&lib, &clock);
assert_eq!(s.source, LibrarySource::Missing, "the view is restored");
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening an empty Missing view checked originals");
assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran");
drop(s);
// the originals go: the view lists them, one is active
std::fs::remove_dir_all(&a).unwrap();
let mut s = open(&lib, &clock);
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
assert_eq!(s.visible().len(), 5, "all five are missing");
assert!(s.selection.active.is_some());
s.close_library().unwrap();
drop(s);
let (s, checks) = open_counting(&lib, &clock);
assert_eq!(s.source, LibrarySource::Missing);
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening a full Missing view checked originals");
let _ = std::fs::remove_dir_all(&root);
}
+205
View File
@@ -0,0 +1,205 @@
//! `ids` that aren't photo ids are an error, never "the selection": a command that fell back to the
//! selection on `{"ids": 3}` acted on photos the call didn't name.
use lightcraft_catalog::{AlbumId, PhotoId};
use serde_json::{Value, json};
use crate::Session;
/// A demo session with the first two photos selected, the third active elsewhere in the grid, and
/// an album holding the selection.
fn session() -> (Session, Vec<PhotoId>, AlbumId) {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
s.execute("library.select", &json!({"ids": [vis[0].0, vis[1].0], "active": vis[0].0})).unwrap();
let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap();
let album = AlbumId(r["id"].as_u64().unwrap());
(s, vis.to_vec(), album)
}
const BAD_IDS: [fn() -> Value; 5] = [|| json!(3), || json!("3"), || json!(["3"]), || json!([3, -1]), || json!({"id": 3})];
#[test]
fn album_commands_refuse_ids_that_are_not_photo_ids() {
let (mut s, vis, album) = session();
let members = |s: &Session| s.catalog.album(album).unwrap().photos.clone();
let before = members(&s);
assert_eq!(before.len(), 2);
for bad in BAD_IDS.map(|f| f()) {
for cmd in ["album.removePhotos", "album.addPhotos"] {
let e = s.execute(cmd, &json!({"id": album.0, "ids": bad})).unwrap_err();
assert!(e.to_string().contains("ids"), "{cmd} {bad}: {e}");
assert_eq!(members(&s), before, "{cmd} {bad} changed the album");
}
assert!(s.execute("album.toggleTarget", &json!({"ids": bad})).is_err(), "album.toggleTarget {bad}");
}
// well-formed ids still work, and no ids still means the selection
s.execute("album.removePhotos", &json!({"id": album.0, "ids": [vis[1].0]})).unwrap();
assert_eq!(members(&s), vec![vis[0]]);
s.execute("album.addPhotos", &json!({"id": album.0, "ids": null})).unwrap();
assert_eq!(members(&s).len(), 2);
}
#[test]
fn develop_set_refuses_ids_that_are_not_photo_ids() {
let (mut s, vis, _) = session();
let exposure = |s: &Session, id: PhotoId| s.develop_of(id).unwrap().light.exposure;
let before: Vec<f64> = vis.iter().map(|id| exposure(&s, *id)).collect();
for bad in BAD_IDS.map(|f| f()) {
let e = s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": bad})).unwrap_err();
assert!(e.to_string().contains("ids"), "{bad}: {e}");
assert_eq!(vis.iter().map(|id| exposure(&s, *id)).collect::<Vec<_>>(), before, "{bad} edited a photo");
}
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": [vis[2].0]})).unwrap();
assert_eq!((exposure(&s, vis[0]), exposure(&s, vis[2])), (before[0], 2.5));
}
#[test]
fn library_select_refuses_ids_that_are_not_photo_ids() {
let (mut s, vis, _) = session();
let before = s.selection.ids.clone();
for bad in BAD_IDS.map(|f| f()) {
assert!(s.execute("library.select", &json!({"ids": bad})).is_err(), "{bad}");
assert_eq!(s.selection.ids, before, "{bad} changed the selection");
}
s.execute("library.select", &json!({"ids": [vis[2].0]})).unwrap();
assert_eq!(s.selection.ids, vec![vis[2]]);
}
/// The same for a single `id`: a value that isn't a photo id used to mean the active photo
/// (`photo.relink` then relinked the active photo to the file, `photo.removeRegion` removed its face
/// box), or, for `denoise.cancel`, every photo. (The stack commands share the helper; `in_stack`
/// keeps them disabled on a photo outside a stack, so they aren't called here.)
#[test]
fn single_photo_commands_refuse_an_id_that_is_not_a_photo_id() {
let (mut s, vis, _) = session();
let active = s.active().unwrap();
let mut meta = s.catalog.photo(active).unwrap().meta.clone();
meta.regions = vec![lightcraft_meta::Region {
rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.3, y1: 0.3 },
kind: lightcraft_meta::RegionKind::Face,
name: None,
description: None,
}];
s.commit("setup", lightcraft_catalog::Op::SetMeta { id: active, meta: Box::new(meta) }).unwrap();
let source = |s: &Session| format!("{:?}", s.catalog.photo(active).unwrap().source);
let before = source(&s);
let commits = s.commits();
for bad in [json!("5"), json!(-1), json!(1.5), json!([vis[2].0]), json!({"id": 1})] {
for (cmd, mut params) in [
("photo.relink", json!({"path": "/elsewhere/other.jpg"})),
("photo.removeRegion", json!({"index": 0})),
("photo.setRegion", json!({"index": 0, "rect": {"x0": 0.2, "y0": 0.2, "x1": 0.4, "y1": 0.4}})),
("faces.setName", json!({"index": 0, "name": "Ann"})),
("library.findSimilar", json!({})),
("denoise.cancel", json!({})),
("photo.inspect", json!({})),
] {
params["id"] = bad.clone();
let e = s.execute(cmd, &params).unwrap_err();
assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}");
}
}
assert_eq!(s.commits(), commits, "nothing was changed");
assert_eq!(source(&s), before);
assert_eq!(s.catalog.photo(active).unwrap().meta.regions.len(), 1);
}
/// Mask ids and spot indexes the same way: `mask.delete {"id": "1"}` deleted the selected mask, and an
/// id past `u32` wrapped (`4294967297 as u32` is 1) onto another mask.
#[test]
fn mask_and_spot_commands_refuse_ids_that_are_not_ids() {
let (mut s, _, _) = session();
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
s.execute("spot.add", &json!({"mode": "heal", "points": [[0.4, 0.5]], "size": 0.02, "source": [0.1, 0.0]})).unwrap();
let state = |s: &Session| {
let d = s.develop_of(s.active().unwrap()).unwrap();
(d.masks.iter().map(|m| (m.id, m.name.clone(), m.visible)).collect::<Vec<_>>(), d.spots.len())
};
let before = state(&s);
assert_eq!((before.0.len(), before.1), (2, 1));
let first = before.0[0].0;
let commits = s.commits();
for bad in [json!(u64::from(u32::MAX) + 1 + u64::from(first)), json!("1"), json!(-1), json!(1.5), json!([first])] {
for (cmd, params) in [
("mask.delete", json!({})),
("mask.rename", json!({"name": "renamed"})),
("mask.visible", json!({"visible": false})),
("mask.select", json!({})),
] {
let mut params = params;
params["id"] = bad.clone();
assert!(s.execute(cmd, &params).is_err(), "{cmd} {bad}");
}
for cmd in ["spot.delete", "spot.select"] {
assert!(s.execute(cmd, &json!({"index": bad})).is_err(), "{cmd} {bad}");
}
}
assert_eq!(s.commits(), commits, "nothing was changed");
assert_eq!(state(&s), before);
// well-formed ids and a null still work
s.execute("mask.delete", &json!({"id": first})).unwrap();
s.execute("spot.select", &json!({"index": null})).unwrap();
assert_eq!(state(&s).0.len(), 1);
}
/// `photo.label` without a label, or with one that isn't text, cleared the colour label of every
/// selected photo (a missing `label` read as `none`); now it is an error and only `none` or null clear.
#[test]
fn photo_label_refuses_a_missing_or_non_text_label() {
let (mut s, vis, _) = session();
s.execute("photo.label", &json!({"label": "red"})).unwrap();
let labels = |s: &Session| vis.iter().take(2).map(|id| s.catalog.photo(*id).unwrap().label).collect::<Vec<_>>();
let before = labels(&s);
assert!(before.iter().all(Option::is_some));
for params in [json!({}), json!({"label": 3}), json!({"label": true}), json!({"label": ["red"]}), json!({"label": "crimson"})] {
assert!(s.execute("photo.label", &params).is_err(), "{params}");
assert_eq!(labels(&s), before, "{params} changed the labels");
}
s.execute("photo.label", &json!({"label": null})).unwrap();
assert!(labels(&s).iter().all(Option::is_none));
s.execute("photo.label", &json!({"label": "green"})).unwrap();
s.execute("photo.label", &json!({"label": "none"})).unwrap();
assert!(labels(&s).iter().all(Option::is_none));
}
/// The mask-effect commands and a mask's `component` the same way: `mask.saveEffect {"id": "2"}`
/// saved the selected mask's sliders, and `mask.update {"component": "1"}` reshaped component 0.
#[test]
fn mask_effects_and_components_refuse_values_that_are_not_ids() {
let (mut s, _, _) = session();
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
s.execute("mask.saveEffect", &json!({"name": "Kept"})).unwrap();
let state = |s: &Session| (format!("{:?}", s.develop_of(s.active().unwrap()).unwrap().masks), s.mask_effects.len());
let before = state(&s);
let commits = s.commits();
let d = s.develop_of(s.active().unwrap()).unwrap();
let shape = serde_json::to_value(&d.masks.last().unwrap().components[0].shape).unwrap();
for bad in [json!("1"), json!(-1), json!(1.5), json!([1]), json!(u64::from(u32::MAX) + 2)] {
for (cmd, params) in [
("mask.applyEffect", json!({"name": "Kept"})),
("mask.saveEffect", json!({"name": "New"})),
("mask.updateEffect", json!({"name": "Kept"})),
] {
let mut params = params;
params["id"] = bad.clone();
let e = s.execute(cmd, &params).unwrap_err();
assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}");
}
if bad.is_u64() {
continue; // a well-formed index past the end is "no such component", not a type error
}
for (cmd, params) in
[("mask.update", json!({"shape": shape})), ("mask.component", json!({"action": "invert"})), ("mask.refineDetail", json!({}))]
{
let mut params = params;
params["component"] = bad.clone();
let e = s.execute(cmd, &params).unwrap_err();
assert!(e.to_string().contains("`component`"), "{cmd} {bad}: {e}");
}
}
assert_eq!(s.commits(), commits, "nothing was changed");
assert_eq!(state(&s), before);
}
+286 -4
View File
@@ -30,6 +30,25 @@ fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
#[test]
fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() {
let src = temp_dir("sony-discovery");
std::fs::create_dir_all(src.join("trip/day2")).unwrap();
let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"];
let mut expected: Vec<String> = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect();
for path in &expected {
std::fs::write(path, b"discovery only, not a decoded photo").unwrap();
}
std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap();
let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None);
found.sort();
expected.sort();
assert_eq!(found, expected);
assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree");
std::fs::remove_dir_all(src).unwrap();
}
#[test]
fn recursive_import_with_duplicates() {
let src = temp_dir("src");
@@ -479,6 +498,129 @@ fn smart_previews_stand_in_for_offline_originals() {
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #1534: the decoder facts a render interprets its settings against (a camera colour model,
/// the file's local tone map, its semantic mattes) must be stored with the smart preview, so an
/// edit made while the original is offline renders the same picture as the online source instead
/// of the header-facts fallback.
#[test]
fn smart_previews_keep_the_decoder_facts_offline_edits_need() {
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, WbMode};
use lightcraft_pipeline::SourceInfo;
use lightcraft_pipeline::masks::{MatteKind, Mattes};
use lightcraft_raster::Rgb32f;
// A smooth scene: the proxy's 8-bit sRGB round trip stays within a code step, so a mismatch
// left between the offline and the online render is metadata the proxy failed to keep.
let pixels = Rgb32f::from_fn(96, 64, |x, y| {
let t = ((x * 7 + y * 3) % 96) as f32 / 96.0;
[0.02 + t * 1.2, 0.02 + t * 0.6, 0.02 + t * 0.25]
});
let camera_color = lightcraft_pipeline::CameraColor {
tags: lightcraft_raw::ColorData {
illuminant: [21, 17],
color_matrix: [Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), None],
as_shot_neutral: Some([0.5, 1.0, 0.7]),
baseline_exposure: 0.3,
..Default::default()
},
developed_for: lightcraft_color::Xy { x: 0.32, y: 0.35 },
};
let mut mattes = Mattes::default();
mattes.push(
MatteKind::Sky,
lightcraft_raster::Image { width: 96, height: 64, data: (0..96 * 64usize).map(|i| if (i % 96) / 24 == 1 { 255 } else { 0 }).collect() },
);
let local_tone = lightcraft_pipeline::local_tone::LocalTone {
map: lightcraft_raw::gaintable::GainTableMap {
points_v: 1,
points_h: 2,
points_n: 1,
spacing_v: 1.0,
spacing_h: 1.0,
origin_v: 0.0,
origin_h: 0.0,
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
gamma: 1.0,
gains: vec![1.0, 4.0],
},
placement: lightcraft_raw::gaintable::SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal },
};
// the facts differ per case; the rest is the raw source's usual shape
let info = |camera_color: Option<lightcraft_pipeline::CameraColor>,
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
mattes: Option<Mattes>| SourceInfo {
raw: true,
as_shot_temp: 5200.0,
as_shot_tint: 8.0,
camera_color: camera_color.map(std::sync::Arc::new),
local_tone: local_tone.map(std::sync::Arc::new),
mattes: mattes.map(std::sync::Arc::new),
..Default::default()
};
/// Build the proxy of `pixels`+`info`, apply `edit`, then render the proxy's own pixels and
/// stored facts online, and the same proxy offline (the original renamed away): how many
/// pixels differ. Identical stored pixels and identical edits must render identically; what
/// the proxy failed to keep shows up as a difference.
fn offline_diff(tag: &str, pixels: &Rgb32f, info: &SourceInfo, edit: DevelopSettings) -> usize {
let src = temp_dir(&format!("smartmeta-src-{tag}"));
let lib = temp_dir(&format!("smartmeta-lib-{tag}"));
write_png(&src.join("a.png"), 3);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let (p, i) = (pixels.clone(), info.clone());
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!(r["built"], 1, "{r}");
s.set_develop(id, edit, "reproduction").unwrap();
let dir = s.media.smart_dir.clone().unwrap();
let stored = crate::smart::load(&crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap())).unwrap();
let mut online = s.render_job(id, 96, 96, false, true).unwrap();
// the proxy's own (post-JPEG) pixels, with the facts the original source had: the offline
// render must reach the same picture through the facts the proxy stored
online.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(stored.image, Some(info.clone()))));
let online = online.run().rendered.unwrap().image;
// the drive goes away
std::fs::rename(&src, src.with_extension("offline")).unwrap();
s.media.rendered.clear();
s.media.clear_sources();
s.media.forget(id);
let offline = s.render_now(id, 96, 96).expect("renders from the smart preview").image;
assert_eq!(online.data.len(), offline.data.len(), "{tag}: same grid");
let differing = online.data.iter().zip(&offline.data).filter(|(a, b)| a != b).count();
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
let _ = std::fs::remove_dir_all(&lib);
differing
}
// a Kelvin white balance is evaluated through the camera's colour model
let mut wb = DevelopSettings::default();
wb.wb.mode = WbMode::Custom;
wb.wb.temp = 3000.0;
wb.wb.tint = 20.0;
let differing = offline_diff("wb", &pixels, &info(Some(camera_color), None, None), wb);
assert_eq!(differing, 0, "the camera colour model is kept offline");
// an AI mask uses the mattes that came with the file
let mut sky = DevelopSettings::default();
sky.masks.push(Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }],
adjust: LocalAdjustments { exposure: 2.0, ..Default::default() },
..Default::default()
});
let differing = offline_diff("sky", &pixels, &info(None, None, Some(mattes)), sky);
assert_eq!(differing, 0, "the file's mattes are kept offline");
// Camera local tone mapping reads the file's gain table map
let mut tone = DevelopSettings::default();
tone.profile.camera_local_tone = true;
tone.light.exposure = -3.0;
let differing = offline_diff("tone", &pixels, &info(None, Some(local_tone), None), tone);
assert_eq!(differing, 0, "the local tone map is kept offline");
}
/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed
/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not
/// reported as present and is rebuilt.
@@ -609,6 +751,34 @@ fn cube_luts_become_profiles() {
let _ = std::fs::remove_dir_all(&lib);
}
/// Without a library on disk an imported LUT lives only in this session: no copy of it is
/// left in the temp folder (#1952).
#[test]
fn cube_import_without_a_library_leaves_no_temp_copy() {
let src = temp_dir("cubemem");
let mut cube = String::from("TITLE \"Temp Copy Check\"\nLUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
cube.push_str(&format!("{r} {g} {b}\n"));
}
}
}
std::fs::write(src.join("mem.cube"), &cube).unwrap();
let leak = std::env::temp_dir().join("lightcraft-imported-temp-copy-check.cube");
let _ = std::fs::remove_file(&leak);
let mut s = Session::new().with_fs();
let r = s.execute("profile.import", &json!({"paths": [src.join("mem.cube").to_string_lossy()]})).unwrap();
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "usable in this session");
let file = s.lut_profiles.iter().find(|p| p.id == id).unwrap().file.clone();
assert!(file.is_empty(), "no file kept without a library: {file}");
drop(s);
assert!(!leak.exists(), "{} left behind", leak.display());
lightcraft_pipeline::lut::unregister(&id);
let _ = std::fs::remove_dir_all(&src);
}
/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and
/// still render; clashes and moving into itself are refused.
#[test]
@@ -778,6 +948,18 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Assisted culling: a photo listed twice is analysed once and is not a burst with itself.
#[test]
fn assisted_culling_ignores_repeated_ids() {
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
let r = s.execute("photo.analyze", &json!({"ids": [id.0, id.0]})).unwrap();
assert_eq!(r["groups"], 0, "a photo listed twice is not a burst of two: {r}");
assert_eq!(r["photos"].as_array().map(Vec::len), Some(1), "{r}");
let an = s.catalog.photo(id).unwrap().analysis.unwrap();
assert!(an.group.is_none() && !an.best, "{an:?}");
}
/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
/// every month, including those with fewer than 31 days.
#[test]
@@ -1411,9 +1593,15 @@ fn look_loader(
/// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version.
#[test]
fn smart_previews_from_an_older_look_fit_are_refreshed() {
for format in ["ARW", "DNG"] {
smart_look_refresh_for(format);
}
}
fn smart_look_refresh_for(format: &str) {
use std::sync::atomic::{AtomicUsize, Ordering};
let src = temp_dir("smartlook-src");
let lib = temp_dir("smartlook-lib");
let src = temp_dir(&format!("smartlook-src-{format}"));
let lib = temp_dir(&format!("smartlook-lib-{format}"));
write_png(&src.join("a.png"), 3);
write_png(&src.join("b.png"), 9);
let mut s = Session::new().with_fs();
@@ -1423,11 +1611,12 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
let (id, png) = (find(&s, "a.png"), find(&s, "b.png"));
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
raw.format = "ARW".into();
raw.format = format.into();
raw.kind = lightcraft_catalog::MediaKind::Raw;
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb());
assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
assert!(!s.catalog.photo(png).unwrap().relative_wb());
s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap();
let dir = s.media.smart_dir.clone().unwrap();
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
@@ -1515,6 +1704,99 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file
/// camera look. A DNG keeps an absolute white balance, so it never takes that look; at head its
/// existing proxy was kept at an older encoding even with its original online, so the decoder
/// facts the newer encoding carries never reached it through a normal rebuild.
#[test]
fn a_proxy_with_an_older_encoding_is_refreshed_without_the_camera_look() {
let src = temp_dir("smartenc-src");
let lib = temp_dir("smartenc-lib");
write_png(&src.join("a.png"), 3);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// the test loader decodes the catalogue photo's "DNG" pixels
let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]);
let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() };
let (p, i) = (pixels.clone(), info.clone());
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
raw.format = "DNG".into();
raw.kind = lightcraft_catalog::MediaKind::Raw;
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
assert!(s.catalog.photo(id).unwrap().develops_raw() && !s.catalog.photo(id).unwrap().fitted_look(), "a DNG does not take the camera look");
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
let dir = s.media.smart_dir.clone().unwrap();
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION));
// an existing proxy from the previous encoding, its original online: the migration must reach
// it through a normal rebuild even though the photo never takes the camera look
edit_smart_header(&file, |h| h["enc"] = json!(crate::smart::ENCODE_VERSION - 1));
let stale_bytes = std::fs::read(&file).unwrap();
assert!(crate::smart::is_stale(&file), "the older encoding is stale even without the camera look");
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(1), Some(0)), "{r}");
assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION), "migrated to the current encoding");
assert!(!crate::smart::is_stale(&file));
assert_ne!(std::fs::read(&file).unwrap(), stale_bytes, "the proxy was rebuilt");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #1534 (review): a cached proxy source is not the original. After an offline edit rendered
/// from a stale proxy, Build Smart Previews read that cached source back and refreshed the proxy
/// from itself, stamping its older look as current. A stale refresh reads the original only, so an
/// offline original leaves the stored look — and the bytes — as they are.
#[test]
fn a_stale_refresh_never_rebuilds_a_proxy_from_itself() {
let src = temp_dir("smartself-src");
let lib = temp_dir("smartself-lib");
write_png(&src.join("a.png"), 3);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]);
let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() };
let (p, i) = (pixels.clone(), info.clone());
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
// a raw that takes the per-file camera look, so an older fit makes its proxy stale
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
raw.format = "ARW".into();
raw.kind = lightcraft_catalog::MediaKind::Raw;
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
assert!(s.catalog.photo(id).unwrap().fitted_look());
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
let dir = s.media.smart_dir.clone().unwrap();
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!(r["built"], 1, "{r}");
edit_smart_header(&file, |h| h["look_version"] = json!(0));
let stale_bytes = std::fs::read(&file).unwrap();
assert!(crate::smart::is_stale(&file), "the stored look is behind this build's fit");
// the original goes offline, then a preview-size render loads the proxy into the source cache
std::fs::rename(&src, src.with_extension("offline")).unwrap();
s.media.forget(id);
let _ = s.render_now(id, 600, 600).expect("renders from the smart preview");
// Build Smart Previews must not take that cached proxy for the original: it can't refresh a
// stale proxy from itself
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(0), Some(1)), "{r}");
assert_eq!(std::fs::read(&file).unwrap(), stale_bytes, "the offline proxy is preserved");
assert!(crate::smart::is_stale(&file), "still stale: the original was not read");
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
let _ = std::fs::remove_dir_all(&lib);
}
/// The look marker: missing, damaged or older than this build's fit version counts as unchecked.
#[test]
fn smart_preview_look_marker_is_read_safely() {
+24
View File
@@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() {
assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits");
assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB");
}
/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]`
/// merges two frames rather than holding four in memory.
#[test]
fn a_photo_named_twice_is_merged_once() {
let dir = temp_dir("dup-ids");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let (a, b) = (ids[0], ids[1]);
for cmd in ["merge.hdr", "merge.panorama"] {
let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err();
assert!(e.to_string().contains("at least 2"), "{cmd}: {e}");
}
let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap();
let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap();
assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::<Vec<_>>(), vec![a, b]);
let _ = std::fs::remove_dir_all(&dir);
}
+24
View File
@@ -356,3 +356,27 @@ fn rename_huge_start_is_a_bad_param() {
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
assert!(r.is_err(), "{r:?}");
}
#[test]
fn stack_toggle_by_ids_leaves_the_selection_alone() {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
let (loose, top, member) = (vis[0], vis[1], vis[2]);
s.execute("library.select", &json!({"ids": [top.0, member.0], "active": top.0})).unwrap();
s.execute("stack.group", &json!({})).unwrap();
// a loose photo is selected: the stack is named by `ids` (a badge click), not selected
s.execute("library.select", &json!({"ids": [loose.0]})).unwrap();
let r = s.execute("stack.toggle", &json!({"ids": [member.0]})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
assert!(!s.catalog.stack_of(top).unwrap().collapsed);
assert_eq!((s.selection.ids.clone(), s.selection.active), (vec![loose], Some(loose)), "the selection is untouched");
s.execute("stack.toggle", &json!({"ids": [top.0]})).unwrap();
assert!(s.catalog.stack_of(top).unwrap().collapsed);
// naming only loose photos is refused without a commit, as is an unknown id
let commits = s.commits();
assert!(s.execute("stack.toggle", &json!({"ids": [loose.0]})).is_err());
assert!(s.execute("stack.toggle", &json!({"ids": [999_999]})).is_err());
assert_eq!(s.commits(), commits);
// without `ids` the selection still decides (a loose photo selected: not available)
assert!(matches!(s.execute("stack.toggle", &json!({})), Err(crate::EngineError::Disabled(..))));
}
+54
View File
@@ -314,6 +314,41 @@ fn a_cancelled_scan_stops_checking_files() {
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
}
/// While the folder is listed, before a single photo can be checked, the scan says how far the
/// listing is: a folder tree on a network share takes minutes to list, and "0 of 64,085" all
/// that while looks like a scan that has stopped.
#[test]
fn a_scan_says_how_many_folders_and_files_it_has_listed() {
let dir = Scratch::new("listed");
let mut s = library(&dir);
write_png(&dir.path("trip/day2/c.png"), 3);
std::fs::write(dir.path("trip/day2/notes.txt"), "not a photo").unwrap();
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
let progress = crate::sync::SyncProgress::default();
assert_eq!(progress.listing(), Some((0, 0)), "listing is the first thing a scan does");
crate::sync::scan_with(input, &progress);
assert_eq!(progress.listing(), None, "the listing is done once the scan is");
let (folders, files) =
(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), progress.files_listed.load(std::sync::atomic::Ordering::Relaxed));
// trip, trip/day1, trip/day2; a.png, b.png, c.png and the note (every file is listed, photo or not)
assert_eq!((folders, files), (3, 4));
}
/// ✕ on a scan's row stops the listing too, not only the checks after it.
#[test]
fn a_cancelled_scan_stops_listing_the_folders() {
let dir = Scratch::new("cancel-listing");
let mut s = library(&dir);
for i in 0..20u8 {
write_png(&dir.path(&format!("trip/more/{i}/x.png")), i);
}
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
let progress = crate::sync::SyncProgress::default();
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
crate::sync::scan_with(input, &progress);
assert_eq!(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), 1, "the walk stopped after the folder it was in");
}
#[test]
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
let dir = Scratch::new("many");
@@ -685,3 +720,22 @@ fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
assert_eq!(s.sidecar_namings(&ids), one_by_one);
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
}
/// ✕ set after the listing, while the scan goes through the files: the probes a preceding scan
/// kept are not checked against the disk one by one (a round trip per file on a network share);
/// the files not reached come back as errors.
#[test]
fn a_cancelled_scan_does_not_check_the_cached_probes_against_the_disk() {
let dir = Scratch::new("cancel-cached");
let mut s = library(&dir);
write_png(&dir.path("trip/new.png"), 9);
let found = crate::import::scan(&mut s, &[dir.path("trip")]);
assert!(found.iter().any(|c| c.name == "new.png" && c.error.is_none()), "{found:?}");
assert!(!s.import_probes.is_empty(), "the scan kept the new file's probe");
let (input, paths) = crate::import::ScanInput::new(&mut s, &[dir.path("trip")]);
let progress = crate::import::ScanProgress::default();
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
let out = crate::import::scan_with(input, &paths, &progress);
let new = out.candidates.iter().find(|c| c.name == "new.png").expect("new.png is a candidate");
assert!(new.error.is_some(), "the cached probe was used after ✕: {new:?}");
}
+208
View File
@@ -621,6 +621,214 @@ fn dng_embedded_crs_settings_are_read_on_import() {
let _ = std::fs::remove_dir_all(&src);
}
fn jpeg_xmp_packet(rating: i8, title: &str) -> String {
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), title: Some(title.into()), ..Default::default() }, None)
}
fn write_jpeg_xmp(path: &Path, packet: &str) -> Vec<u8> {
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]);
let meta = lightcraft_codecs::EncodeMeta { xmp: Some(packet), ..Default::default() };
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
assert_eq!(lightcraft_meta::embedded(&bytes).xmp.as_deref(), Some(packet), "the procedural JPEG carries the requested packet");
std::fs::write(path, &bytes).unwrap();
bytes
}
#[test]
fn embedded_jpeg_xmp_reread_refreshes_metadata_and_undoes_once() {
let src = temp_dir("jpeg-xmp-refresh");
let file = src.join("photo.jpg");
let initial = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Initial embedded title"));
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
assert_eq!(imported["imported"].as_array().unwrap().len(), 1, "{imported}");
let id = only(&s);
assert_eq!(s.catalog.photo(id).unwrap().kind, lightcraft_catalog::MediaKind::Image);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
assert!(!s.xmp.auto_write);
// Change the packet after import: rereading must use the file, not the imported metadata.
let changed = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Changed embedded title"));
assert_ne!(initial, changed);
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([{"id": id.0, "from": file.to_string_lossy()}]), "embedded JPEG XMP must be reread: {read}");
assert_eq!(read["failed"], json!([]), "{read}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (4, "Changed embedded title"));
assert_eq!(photo.develop, before.develop);
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title), "one undo restores both fields");
assert_eq!(std::fs::read(&file).unwrap(), changed, "reading and undo never rewrite the original");
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_keeps_sidecar_precedence() {
let src = temp_dir("jpeg-xmp-sidecar");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
let sidecar = src.join("photo.xmp");
let packet = jpeg_xmp_packet(5, "Sidecar title");
std::fs::write(&sidecar, &packet).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([{"id": id.0, "from": sidecar.to_string_lossy()}]), "{read}");
assert_eq!(read["failed"], json!([]), "{read}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
// A malformed sidecar must report its failure, not fall back to the valid embedded packet.
let malformed = "<a>".repeat(1000);
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
std::fs::write(&sidecar, &malformed).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "{read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), malformed);
assert_eq!(std::fs::read(&file).unwrap(), original);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_malformed_input_preserves_state_and_undo() {
let src = temp_dir("jpeg-xmp-malformed");
let file = src.join("photo.jpg");
write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Original title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
let malformed = "<a>".repeat(1000);
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
let broken_packet = write_jpeg_xmp(&file, &malformed);
let truncated_container = vec![0xff, 0xd8, 0xff, 0xe1, 0, 40, b'x'];
for bytes in [broken_packet, truncated_container] {
std::fs::write(&file, &bytes).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "{read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), bytes);
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
}
// Failed reads do not add an undo entry: the last successful title edit is still next.
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title));
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_non_utf8_sidecar_preserves_state_and_undo() {
let src = temp_dir("jpeg-xmp-non-utf8-sidecar");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
assert!(!s.xmp.auto_write);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
let sidecar = src.join("photo.xmp");
let invalid = [0xff, 0xfe, 0x80];
std::fs::write(&sidecar, invalid).unwrap();
assert_eq!(std::fs::read_to_string(&sidecar).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
assert_eq!(crate::sidecar::find_sidecar(file.to_str().unwrap(), s.sidecar_naming(id)), Some(sidecar.clone()));
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "an existing non-UTF-8 sidecar must not fall back to embedded JPEG XMP: {read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title), "failed reads must not add an undo entry");
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), original);
assert_eq!(std::fs::read(&sidecar).unwrap(), invalid);
assert!(!src.join("photo.jpg.xmp").exists());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_all_metadata_uses_supplied_bytes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let src = temp_dir("jpeg-xmp-all-metadata");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "On-disk title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
assert!(!s.xmp.auto_write);
let before = s.catalog.photo(id).unwrap().clone();
let supplied = write_jpeg_xmp(&src.join("supplied.jpg"), &jpeg_xmp_packet(4, "Supplied title"));
let reads = Arc::new(AtomicUsize::new(0));
let counted = reads.clone();
let expected_path = file.to_string_lossy().into_owned();
s.media.file_bytes = Some(Arc::new(move |path| {
assert_eq!(path, expected_path);
counted.fetch_add(1, Ordering::SeqCst);
Ok(supplied.clone())
}));
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), 1);
let xmp = query["xmp"].as_array().unwrap();
assert!(
xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Supplied title"),
"All Metadata must use the bytes already supplied: {query}"
);
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "4"), "{query}");
assert!(!xmp.iter().any(|x| x["value"] == "On-disk title"), "{query}");
let sidecar = src.join("photo.xmp");
let packet = jpeg_xmp_packet(5, "Sidecar title");
std::fs::write(&sidecar, &packet).unwrap();
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), 2);
let xmp = query["xmp"].as_array().unwrap();
assert!(xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Sidecar title"), "{query}");
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "5"), "{query}");
assert!(!xmp.iter().any(|x| x["value"] == "Supplied title"), "{query}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), original);
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn demo_photos_have_no_sidecar() {
let mut s = Session::with_demo();
+10 -5
View File
@@ -58,8 +58,9 @@ pub enum EntryKind {
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
/// stopped inside the folder, or an entry could not be read).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
/// stopped inside the folder, or an entry could not be read). It answers whether to go on: `false`
/// ends the walk there (a scan cancelled; [`Walked::truncated`] is left alone: who stopped it knows).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool) -> bool;
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
@@ -154,8 +155,10 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&
};
children.push((p, dir));
}
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
note(&path, listed, complete);
if let (Some(note), Some(listed)) = (note.as_mut(), listed)
&& !note(&path, listed, complete)
{
break;
}
children.sort();
let below = depth.saturating_add(1);
@@ -193,6 +196,7 @@ mod tests {
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
noted.push((dir.to_path_buf(), entries, complete));
true
});
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
let named = |n: &str, k: EntryKind| (n.to_string(), k);
@@ -205,7 +209,8 @@ mod tests {
// stopped inside the first folder: its listing is not all of it
let mut cut = Vec::new();
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
cut.push((dir.to_path_buf(), entries.len(), complete))
cut.push((dir.to_path_buf(), entries.len(), complete));
true
});
assert!(walked.truncated);
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
+83 -3
View File
@@ -58,6 +58,43 @@ pub struct Url {
pub path: String,
}
/// Remove `.` and `..` segments from the path part of `path` (RFC 3986 section 5.2.4); the query is kept as is.
fn remove_dot_segments(path: &str) -> String {
let (p, query) = match path.find('?') {
Some(i) => (path.get(..i).unwrap_or_default(), path.get(i..).unwrap_or_default()),
None => (path, ""),
};
let mut out: Vec<&str> = Vec::new();
let mut segments = p.split('/').skip(1).peekable();
let mut trailing_slash = false;
while let Some(seg) = segments.next() {
let last = segments.peek().is_none();
match seg {
"." => trailing_slash = last,
".." => {
out.pop();
trailing_slash = last;
}
_ => {
out.push(seg);
trailing_slash = false;
}
}
}
let mut result = format!("/{}", out.join("/"));
if trailing_slash && !result.ends_with('/') {
result.push('/');
}
result.push_str(query);
result
}
/// `s` without the leading `prefix`, matched ASCII case-insensitively (RFC 3986 §3.1).
fn strip_scheme<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..).unwrap_or_default())
}
impl Url {
pub fn parse(s: &str) -> Result<Url, HttpError> {
let bad = |why: &str| HttpError::BadUrl(why.to_string());
@@ -68,9 +105,9 @@ impl Url {
if s.chars().any(|c| c.is_control() || c == ' ') {
return Err(bad("contains spaces or control characters"));
}
let (tls, rest) = if let Some(r) = s.strip_prefix("https://") {
let (tls, rest) = if let Some(r) = strip_scheme(s, "https://") {
(true, r)
} else if let Some(r) = s.strip_prefix("http://") {
} else if let Some(r) = strip_scheme(s, "http://") {
(false, r)
} else {
return Err(bad("only http:// and https:// are supported"));
@@ -81,6 +118,7 @@ impl Url {
None => (rest, ""),
};
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
let path = remove_dot_segments(&path);
if authority.contains('@') {
return Err(bad("user names and passwords in URLs are not supported"));
}
@@ -112,7 +150,7 @@ impl Url {
/// `Location` of a redirect, resolved against this URL.
pub fn join(&self, location: &str) -> Result<Url, HttpError> {
let l = location.trim();
if l.starts_with("http://") || l.starts_with("https://") {
if strip_scheme(l, "http://").is_some() || strip_scheme(l, "https://").is_some() {
let url = Url::parse(l)?;
if self.tls && !url.tls {
return Err(HttpError::BadUrl("redirect from https to http refused".into()));
@@ -135,8 +173,16 @@ impl Url {
if l.starts_with('/') {
return Url::parse(&format!("{base}{l}"));
}
// empty or fragment-only: the same document
if l.is_empty() || l.starts_with('#') {
return Ok(self.clone());
}
// relative to the current directory
let dir = self.path.split('?').next().unwrap_or("/");
// a query-only reference keeps the whole path, file name included (RFC 3986 §5.2.2)
if l.starts_with('?') {
return Url::parse(&format!("{base}{dir}{l}"));
}
let dir = dir.rsplit_once('/').map_or("", |(d, _)| d);
Url::parse(&format!("{base}{dir}/{l}"))
}
@@ -612,12 +658,46 @@ mod tests_connect {
mod tests {
use super::*;
#[test]
fn fragment_only_or_empty_location_keeps_file_name_and_query() {
let base = Url::parse("https://a.example/x/y/file?q").unwrap();
for l in ["#top", "", " "] {
let u = base.join(l).unwrap();
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/x/y/file?q"), "{l:?}");
}
}
#[test]
fn rejects_oversized_and_control_character_urls() {
assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err());
assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err());
}
#[test]
fn join_query_only_keeps_file_name() {
let u = Url::parse("https://a.example/x/y/file?q=1").unwrap();
assert_eq!(u.join("?page=2").unwrap().path, "/x/y/file?page=2");
}
#[test]
fn join_removes_dot_segments() {
let base = Url::parse("https://a.example/x/y/file").unwrap();
for (loc, want) in
[("../z", "/x/z"), ("./z", "/x/y/z"), ("../../../z", "/z"), ("/a/./b/../c", "/a/c"), ("../z?q=1", "/x/z?q=1"), ("..", "/x/")]
{
assert_eq!(base.join(loc).unwrap().path, want, "{loc}");
}
}
#[test]
fn url_scheme_is_matched_case_insensitively() {
let u = Url::parse("HTTPS://A.example/z").unwrap();
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/z"));
assert!(Url::parse("Http://a.example/").is_ok());
let j = Url::parse("https://a.example/start").unwrap().join("HTTPS://cdn.example/file").unwrap();
assert_eq!((j.tls, j.host.as_str(), j.path.as_str()), (true, "cdn.example", "/file"));
}
#[test]
fn parses_urls() {
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();
+162 -5
View File
@@ -6,7 +6,7 @@
//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app,
//! CLI, control channel and MCP agree.
use crate::{CropGeometry, Point, Rect};
use crate::{Affine, CropGeometry, Point, Rect, Vec2};
/// Smallest crop side, as a fraction of the image's shorter side.
pub const MIN_CROP_FRACTION: f64 = 0.02;
@@ -109,11 +109,21 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer:
let out = match (handle, aspect) {
(CropHandle::Move, _) => {
let slide = |cur: Rect, ax: f64, ay: f64| {
let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t))));
cur.translate(crate::Vec2::new(ax * t, ay * t))
let t = bisect(0.0, 1.0, |t| feasible(cur.translate(Vec2::new(ax * t, ay * t))));
cur.translate(Vec2::new(ax * t, ay * t))
};
let cur = slide(sr, dx, 0.0);
slide(cur, 0.0, dy)
// straightened, go to the nearest place that fits, so the frame slides along tilted edges (#2299)
let nearest = if base.angle != 0.0 {
let from = |r: Rect| nearest_move(r, base.angle, w, h, Vec2::new(dx, dy)).map(|v| r.translate(v)).filter(|r| feasible(*r));
// a frame at the tolerance's edge on opposite sides moves only once it is inside it
from(sr).or_else(|| shrunk_inside(sr, base.angle, w, h).and_then(from))
} else {
None
};
match nearest {
Some(r) => r,
None => slide(slide(sr, dx, 0.0), 0.0, dy),
}
}
(hd, None) => {
let (mx0, mx1, my0, my1) = sides(hd);
@@ -202,6 +212,70 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer:
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start }
}
/// The translation (pixels) nearest `want` that keeps the frame `r` inside a `w × h` image turned by `angle` degrees.
fn nearest_move(r: Rect, angle: f64, w: f64, h: f64, want: Vec2) -> Option<Vec2> {
let back = Affine::rotate_about(-angle.to_radians(), Point::new(w / 2.0, h / 2.0));
let (ex, ey) = (back.apply_vec(Vec2::new(1.0, 0.0)), back.apply_vec(Vec2::new(0.0, 1.0)));
let (nx, ny) = (Vec2::new(ex.x, ey.x), Vec2::new(ex.y, ey.y));
// never tighter than where the frame is, plus a slack so a zero-width strip survives rounding
let (sx, sy) = (1e-9 * w, 1e-9 * h);
let mut planes = Vec::with_capacity(16);
for c in r.corners() {
let q = back.apply(c);
for (n, b, s) in [(nx, w - q.x, sx), (-nx, q.x, sx), (ny, h - q.y, sy), (-ny, q.y, sy)] {
planes.push((n, b.max(0.0) + s));
}
}
if planes.iter().all(|(n, b)| n.dot(want) <= *b) {
return Some(want);
}
let lim = 4.0 * w.max(h);
let mut poly = vec![Vec2::new(-lim, -lim), Vec2::new(lim, -lim), Vec2::new(lim, lim), Vec2::new(-lim, lim)];
for (n, b) in &planes {
poly = clip_half_plane(&poly, *n, *b);
}
poly.iter()
.zip(poly.iter().cycle().skip(1))
.map(|(a, b)| {
let ab = *b - *a;
let l2 = ab.dot(ab);
let t = if l2 > 0.0 { ((want - *a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
*a + ab * t
})
.filter(|v| v.x.is_finite() && v.y.is_finite())
.min_by(|a, b| (*a - want).len().total_cmp(&(*b - want).len()))
}
/// `r` scaled about its centre by the largest factor down to `1 - 1e-5` that puts it exactly inside a `w × h` image
/// turned by `angle` degrees.
fn shrunk_inside(r: Rect, angle: f64, w: f64, h: f64) -> Option<Rect> {
let back = Affine::rotate_about(-angle.to_radians(), Point::new(w / 2.0, h / 2.0));
let scaled = |k: f64| Rect::from_center(r.center(), r.width() * k, r.height() * k);
let inside = |k: f64| {
scaled(k).corners().iter().all(|c| {
let q = back.apply(*c);
q.x >= 0.0 && q.y >= 0.0 && q.x <= w && q.y <= h
})
};
let lo = 1.0 - 1e-5;
inside(lo).then(|| scaled(bisect_range(lo, 1.0, inside)))
}
/// The part of the convex polygon `poly` where `n · v <= b`.
fn clip_half_plane(poly: &[Vec2], n: Vec2, b: f64) -> Vec<Vec2> {
let mut out = Vec::with_capacity(poly.len() + 1);
for (p, q) in poly.iter().zip(poly.iter().cycle().skip(1)) {
let (dp, dq) = (n.dot(*p) - b, n.dot(*q) - b);
if dp <= 0.0 {
out.push(*p);
}
if (dp <= 0.0) != (dq <= 0.0) {
out.push(*p + (*q - *p) * (dp / (dp - dq)));
}
}
out
}
/// Which sides a handle moves: (left, right, top, bottom).
fn sides(h: CropHandle) -> (bool, bool, bool, bool) {
use CropHandle::*;
@@ -480,4 +554,87 @@ mod tests {
}
}
}
// Scenario: moving a frame that fills a straightened photo's height (issue #2299)
#[test]
fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() {
for angle in [3.0, -10.0] {
let a = 2.0 / 3.0;
let start = crate::crop_fit_angle(W, H, angle, Some(a));
let c = start.rect.center();
let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a));
assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect);
assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size");
assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect);
let gc = g.rect.center();
let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a));
assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect);
let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a));
assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect);
}
}
// Scenario: a straightened move that fits goes exactly where the pointer says
#[test]
fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() {
let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 };
let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None);
assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect);
}
// Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299)
#[test]
fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() {
let a = 2.0 / 3.0;
let fit = crate::crop_fit_angle(W, H, 3.0, Some(a));
let k = 1.0 + 5e-7 / fit.rect.height();
let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 };
assert!(start.is_within_image(W, H));
let c = start.rect.center();
let z = drag(start, Move, c, c, Some(a)).rect;
let s = start.rect;
assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}");
let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a));
assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect);
assert!(g.is_within_image(W, H));
}
// Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299)
#[test]
fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() {
let a = 2.0 / 3.0;
for angle in [3.0, 6.0, -10.0, 12.0] {
let fit = crate::crop_fit_angle(W, H, angle, Some(a));
let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle };
// the second fills the photo's height, at the tolerance's edge top and bottom
let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a));
for start in [small, shrunk] {
let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a));
let c = big.rect.center();
let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a));
assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect);
assert!(
g.is_within_image(W, H)
&& (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5
&& (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5,
"{angle}: {:?}",
g.rect
);
}
}
}
// Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299)
#[test]
fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() {
let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 };
let c = start.rect.center();
let g = drag(start, Move, c, p(c.x - 0.2, c.y), None);
assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect);
let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None));
for g in [a, b] {
assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect);
}
assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect);
}
}
+184 -6
View File
@@ -1,7 +1,7 @@
//! The wgpu device, the compiled kernels, buffers, dispatch and readback.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use wgpu::util::DeviceExt;
@@ -191,6 +191,12 @@ impl Drop for Tracked {
}
}
#[cfg(test)]
thread_local! {
/// Polls on this thread panic as wgpu's do once a Vulkan or DX12 device is lost (#1858).
static SIMULATED_LOST_POLL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
thread_local! {
static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) };
}
@@ -199,6 +205,9 @@ thread_local! {
static ALLOCATED: AtomicU64 = AtomicU64::new(0);
/// Bytes waiting in per-thread retired lists.
static RETIRED_BYTES: AtomicU64 = AtomicU64::new(0);
/// The render device is lost: wgpu's `poll` panics from then on, so [`Gpu::poll`] stops calling it.
/// Other failures that retire the GPU leave the device usable for releasing its buffers.
static DEVICE_LOST: AtomicBool = AtomicBool::new(false);
/// Bytes in the free pool.
static POOLED: AtomicU64 = AtomicU64::new(0);
/// Most bytes kept in the free pool (see [`crate::set_pool_limit`]).
@@ -324,6 +333,7 @@ impl Gpu {
// wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an
// error — so without this callback a lost device went unnoticed.
device.set_device_lost_callback(|reason, msg| {
DEVICE_LOST.store(true, Ordering::Relaxed);
log::error!("gpu: device lost ({reason:?}): {msg}");
crate::mark_broken(&format!("device lost ({reason:?}): {msg}"));
});
@@ -446,7 +456,33 @@ impl Gpu {
if !dropped.is_empty() {
drop(dropped);
// let wgpu release the memory now rather than at its next maintenance
let _ = self.device.poll(wgpu::PollType::Poll);
let _ = self.poll(wgpu::PollType::Poll);
}
}
/// Process finished GPU work: `device.poll`, with its error as text. Never panics (#1858): once a
/// device is lost, wgpu 30 panics in `poll` rather than returning an error (a device error is
/// not a `PollError`), and the buffer pool polls from destructors ([`Gpu::trim`]), where a
/// second panic while a failed render unwinds aborts the process. A lost device is not polled
/// again; a poll that panics counts as one and stops the GPU.
pub(crate) fn poll(&self, t: wgpu::PollType) -> Result<wgpu::PollStatus, String> {
if DEVICE_LOST.load(Ordering::Relaxed) {
return Err("the device is lost".into());
}
let polled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
#[cfg(test)]
if SIMULATED_LOST_POLL.with(std::cell::Cell::get) {
panic!("Error in Device::poll: Validation Error\n\nCaused by:\n Parent device is lost (simulated)");
}
self.device.poll(t)
}));
match polled {
Ok(r) => r.map_err(|e| e.to_string()),
Err(_) => {
DEVICE_LOST.store(true, Ordering::Relaxed);
crate::mark_broken("device lost: Device::poll failed");
Err("the device is lost (Device::poll failed)".into())
}
}
}
@@ -558,10 +594,11 @@ impl Gpu {
fail(FailKind::Fatal, format!("readback failed: {why}"));
zeros()
};
// A lost device makes the wait fail and the map callback never run: checking both keeps
// this from blocking forever (or handing back an unwritten buffer as the image).
if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) {
return failed(format!("{e}"));
// A lost device makes the wait fail ([`Gpu::poll`] turns wgpu's panic into the error) and
// the map callback never run: checking both keeps this from blocking forever (or handing
// back an unwritten buffer as the image).
if let Err(e) = self.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) {
return failed(e);
}
// another thread's poll may be the one that runs the callback: allow it a moment
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
@@ -734,6 +771,147 @@ mod tests {
assert_eq!(super::parse_limits("lots"), None);
}
/// Run `child` (an ignored test in this module) in its own process: it loses the device, which
/// no other test running beside it may see. Returns its stdout, or `None` without a GPU.
fn run_child(child: &str) -> Option<String> {
let exe = std::env::current_exe().unwrap();
// output goes to files: a pipe nobody reads while we wait could fill and stall the child
let log = |stream: &str| std::env::temp_dir().join(format!("lc-gpu-{child}-{}.{stream}", std::process::id()));
let (out_path, err_path) = (log("out"), log("err"));
let mut proc = std::process::Command::new(exe)
.args(["--exact", &format!("ctx::tests::{child}"), "--ignored", "--nocapture", "--test-threads=1"])
.stdout(std::fs::File::create(&out_path).unwrap())
.stderr(std::fs::File::create(&err_path).unwrap())
.spawn()
.unwrap();
// a driver that stalls must fail the test, not hang CI
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let status = loop {
if let Some(status) = proc.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = proc.kill();
let _ = proc.wait();
panic!("{child}: still running after 120 s");
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let stdout = std::fs::read_to_string(&out_path).unwrap_or_default();
let stderr = std::fs::read_to_string(&err_path).unwrap_or_default();
let _ = (std::fs::remove_file(&out_path), std::fs::remove_file(&err_path));
if stdout.contains("CHILD: no GPU") {
return None;
}
// an abort (SIGABRT, "panic in a destructor during cleanup") has no exit code
assert!(status.success(), "{child}: {status:?}\n{stdout}\n{stderr}");
assert!(stdout.contains("CHILD: survived"), "{child}: {stdout}");
Some(stdout)
}
/// Issue #1858: once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of
/// returning an error. A render's readback panicked; while it unwound, a buffer's way back to
/// the pool polls again from a destructor, and that second panic aborted the app.
#[test]
fn a_poll_on_a_lost_device_neither_panics_nor_aborts() {
if run_child("lost_poll_child").is_none() {
lightcraft_testkit::skip!(Gpu, "no GPU device");
}
}
/// A Metal device really lost (its command buffers exhausted) is never polled again.
#[test]
fn a_really_lost_device_is_not_polled_again() {
if run_child("really_lost_child").is_none() {
lightcraft_testkit::skip!(Gpu, "no GPU device, or one that can't be lost on purpose here");
}
}
/// A GPU retired by another failure (a validation error) keeps a live device: its buffers are
/// still released, which takes a poll.
#[test]
fn a_retired_but_live_device_is_still_polled() {
if run_child("retired_child").is_none() {
lightcraft_testkit::skip!(Gpu, "no GPU device");
}
}
#[test]
#[ignore = "run in its own process by a_retired_but_live_device_is_still_polled"]
fn retired_child() {
let Some(g) = crate::device() else {
println!("CHILD: no GPU");
return;
};
crate::mark_broken("device error: simulated validation error");
assert!(!crate::available());
drop(g.upload(&[1u32; 1024]));
g.poll(wgpu::PollType::Poll).unwrap();
println!("CHILD: survived");
}
/// Empties the buffer pool when dropped, as the UI does (`trim_pool(0)`) and as returning a
/// buffer to a full pool does.
struct TrimOnDrop(&'static super::Gpu);
impl Drop for TrimOnDrop {
fn drop(&mut self) {
self.0.trim(0);
}
}
#[test]
#[ignore = "run in its own process by a_poll_on_a_lost_device_neither_panics_nor_aborts"]
fn lost_poll_child() {
let Some(g) = crate::device() else {
println!("CHILD: no GPU");
return;
};
let src = g.upload(&[1u32; 1024]);
drop(g.upload(&[2u32; 1024])); // pooled: trimming it polls
super::SIMULATED_LOST_POLL.with(|c| c.set(true));
// a render (caught as `try_render` catches it) whose readback finds the device lost
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _trim = TrimOnDrop(g);
let v: Vec<u32> = g.finish_and_read(g.encoder(), &src, 1024);
assert!(v.iter().all(|x| *x == 0), "a failed readback hands back zeros");
}));
assert!(r.is_ok(), "the lost device fails the render without a panic");
assert!(super::failed(), "the render failed, so it is redone on the CPU");
assert!(!crate::available(), "the GPU is not used again");
// a render that panics for another reason still unwinds through a destructor that polls
drop(g.upload(&[3u32; 1024]));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _trim = TrimOnDrop(g);
panic!("a render panics (expected in this test)");
}));
assert!(r.is_err());
println!("CHILD: survived");
}
#[test]
#[ignore = "run in its own process by a_really_lost_device_is_not_polled_again"]
fn really_lost_child() {
let Some(g) = crate::device().filter(|g| g.info.backend == wgpu::Backend::Metal) else {
println!("CHILD: no GPU");
return;
};
// wgpu-hal's Metal backend treats a command buffer past its 4096 unsubmitted as a lost device
let b = g.upload(&[1u32; 16]);
let held: Vec<wgpu::CommandBuffer> = (0..4200)
.map(|_| {
let mut e = g.encoder();
e.clear_buffer(b.raw(), 0, None);
e.finish()
})
.collect();
assert!(crate::unavailable_reason().is_some_and(|r| r.contains("device lost")), "{:?}", crate::unavailable_reason());
let e = g.poll(wgpu::PollType::Poll).unwrap_err();
assert!(e.contains("lost"), "{e}");
drop(held);
println!("CHILD: survived");
}
#[test]
fn dispatches_stay_within_the_workgroup_limit() {
// 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis
+42 -3
View File
@@ -161,7 +161,8 @@ struct ConvParams {
cout: u32,
leaky: u32,
alpha: f32,
pad: u32,
/// Always 0: the kernel steps its output stores by `1 + zero`, so FXC cannot merge them (`nn_conv.wgsl`).
zero: u32,
}
#[repr(C)]
@@ -349,7 +350,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>)
cout: c.cout as u32,
leaky: u32::from(c.leaky.is_some()),
alpha: c.leaky.unwrap_or(0.0),
pad: 0,
zero: 0,
};
let src = get(&place, c.src)?;
let src2 = c.src2.map(|t| get(&place, t)).transpose()?;
@@ -416,7 +417,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>)
cout: c.cout as u32,
leaky: u32::from(c.leaky.is_some()),
alpha: c.leaky.unwrap_or(0.0),
pad: 0,
zero: 0,
};
let src = get(&place, c.src)?;
let slot = alloc((4 * m * c.cout * 4) as u64, &mut slots, &mut free);
@@ -920,6 +921,44 @@ mod tests {
a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale
}
/// A 1 × 1 convolution from 4 channels to `cout` that copies input channel `co % 4` to each output channel `co`.
fn copy(src: usize, cout: usize) -> Op {
let mut weight = vec![0f32; cout * 4];
for co in 0..cout {
weight[co * 4 + co % 4] = 1.0;
}
Op::Conv(lightcraft_denoise::net::Conv { src, src2: None, cin: 4, cout, k: 1, weight, bias: vec![0.0; cout], leaky: None })
}
/// Two 1 × 1 layers that only copy channels, so the right answer is exact: the first layer's stores (mode 0) went
/// wrong on DX12 with FXC on an NVIDIA GTX 1070, where every second pixel came out with its channels shifted.
#[test]
fn a_layer_stores_every_pixels_channels_where_the_next_reads_them() {
let n = Net::new(4, vec![copy(0, 4), copy(1, 12), Op::DepthToSpace2 { src: 2 }]).unwrap();
let Some(g) = gpu(&n, 3) else { return };
let x: Vec<f32> = (1..=36).map(|i| i as f32).collect();
assert_eq!(g.run(&x).unwrap(), reference::run(&n, 3, &x).unwrap());
}
/// The same for a transposed convolution's stores (mode 1): a max-pool halves the tile, and the 2 × 2 transposed
/// convolution copies each pooled pixel's channels to the four pixels it becomes.
#[test]
fn a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them() {
use lightcraft_denoise::net::ConvT2;
// [cin][cout][2][2]: channel c to channel c at all four places
let mut weight = vec![0f32; 4 * 4 * 4];
for c in 0..4 {
for q in 0..4 {
weight[(c * 4 + c) * 4 + q] = 1.0;
}
}
let up = Op::ConvT2(ConvT2 { src: 1, cin: 4, cout: 4, weight, bias: vec![0.0; 4], leaky: None });
let n = Net::new(4, vec![Op::MaxPool2 { src: 0 }, up, copy(2, 12), Op::DepthToSpace2 { src: 3 }]).unwrap();
let Some(g) = gpu(&n, 6) else { return };
let x: Vec<f32> = (1..=144).map(|i| i as f32).collect();
assert_eq!(g.run(&x).unwrap(), reference::run(&n, 6, &x).unwrap());
}
#[test]
fn the_gpu_gives_what_the_reference_gives() {
// (tile, channels, depth): a tile that is not a multiple of the block size, odd channel counts, a deeper net
+1 -1
View File
@@ -180,7 +180,7 @@ impl<'a> Cx<'a> {
if let Some(enc) = self.enc.take() {
self.gpu.submit([enc.finish()]);
}
let _ = self.gpu.device.poll(wgpu::PollType::wait_indefinitely());
let _ = self.gpu.poll(wgpu::PollType::wait_indefinitely());
}
/// A zero-filled buffer (the clear is recorded).
+13 -7
View File
@@ -27,7 +27,8 @@ struct P {
cout: u32,
leaky: u32,
alpha: f32,
pad: u32,
// always 0: the output stores step by 1u + zero, see the end of main
zero: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@@ -122,6 +123,11 @@ fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t
return;
}
let bv = bias[n / 4u];
// Four channels that follow each other are stored one by one, `one` apart. With a literal 1u, FXC
// (DX12) merges them into one 16-byte store, and NVIDIA's DX12 driver ran mode 0's wrong on a
// GTX 1070: every second pixel's channels came out shifted. Mode 1's merged store ran right
// there, but it is the same instruction, so it steps the same way. FXC cannot see that `one` is 1.
let one = 1u + p.zero;
for (var i = 0u; i < 4u; i++) {
let m = m0 + ty * 4u + i;
if (m >= hw) {
@@ -135,9 +141,9 @@ fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t
if (p.mode == 0u) {
let o = m * p.n + n;
dst[o] = v.x;
dst[o + 1u] = v.y;
dst[o + 2u] = v.z;
dst[o + 3u] = v.w;
dst[o + one] = v.y;
dst[o + 2u * one] = v.z;
dst[o + 3u * one] = v.w;
} else if (p.mode == 1u) {
let q = n / p.cout;
let co = n % p.cout;
@@ -146,9 +152,9 @@ fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t
let opix = (2u * y + q / 2u) * (2u * p.w) + 2u * x + q % 2u;
let o = opix * p.cout + co;
dst[o] = v.x;
dst[o + 1u] = v.y;
dst[o + 2u] = v.z;
dst[o + 3u] = v.w;
dst[o + one] = v.y;
dst[o + 2u * one] = v.z;
dst[o + 3u * one] = v.w;
} else {
let c = n / 4u;
let y = m / p.w;
+35 -1
View File
@@ -169,13 +169,24 @@ impl LensDb {
/// The best profile for an EXIF lens model (and lens make, when the file has one), preferring lenses for the
/// camera's mount. `None` when no name is a convincing match.
pub fn find_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>) -> Option<&Profile> {
self.best_lens(lens_make, lens_model, camera, |_| true)
}
/// [`find_lens`](Self::find_lens) among the profiles one of whose names is exactly `name`
/// ([`Profile::names_exactly`]), for names that carry no maker, such as [`spec_name`]s: another maker's lens with
/// the same specification scores as high, and must neither win nor, by coming first, hide the profile named so.
pub fn find_lens_named(&self, lens_make: Option<&str>, name: &str, camera: Option<&Camera>) -> Option<&Profile> {
self.best_lens(lens_make, name, camera, |l| l.names_exactly(name))
}
fn best_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>, eligible: impl Fn(&Profile) -> bool) -> Option<&Profile> {
let exif = tokens(lens_model);
if exif.is_empty() {
return None;
}
let make = lens_make.map(tokens).unwrap_or_default();
let mut best: Option<(f64, &Profile)> = None;
for lens in &self.lenses {
for lens in self.lenses.iter().filter(|l| eligible(l)) {
let maker = tokens(&lens.maker);
if !make.is_empty() && !maker.is_empty() && !maker_matches(&maker, &make) {
continue;
@@ -372,7 +383,30 @@ fn by_focal<T: Copy>(points: &[(f64, T)], focal: f64, eval: impl Fn(&T) -> f64)
Some(a + (b - a) * t)
}
/// A lens name built from a lens specification (min / max focal length, f-number at each), for files that name
/// no lens model, only its specification (Nikon DSLRs' maker note): "24-70mm f/2.8", "18-55mm f/3.5-5.6",
/// "85mm f/1.8". Such a name says nothing about the maker, so [`Profile::names_exactly`] must confirm a match.
pub fn spec_name(spec: [f64; 4]) -> String {
fn num(v: f64) -> String {
let t = format!("{v:.1}");
t.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn range(a: f64, b: f64) -> String {
if (a - b).abs() < 0.05 { num(a) } else { format!("{}-{}", num(a), num(b)) }
}
format!("{}mm f/{}", range(spec[0], spec[1]), range(spec[2], spec[3]))
}
impl Profile {
/// Whether one of the profile's names is exactly `name` (same words and numbers, ignoring case and
/// punctuation): the check for names that carry no maker, such as [`spec_name`]s, where the best partial match
/// could be another maker's lens with the same focal range. The maker's words count too, so "Samyang 85mm f/1.4"
/// is not "85mm f/1.4"; only a name given for the purpose (a user's alias) matches.
pub fn names_exactly(&self, name: &str) -> bool {
let want = tokens(name);
!want.is_empty() && self.models.iter().any(|m| tokens(m) == want)
}
/// Distortion ratio `r_d / r_u` at lensfun radius `r` for `focal` (1 when the profile has none).
pub fn distortion_ratio(&self, focal: f64, r: f64) -> f64 {
if !self.rectilinear {
+44
View File
@@ -243,3 +243,47 @@ fn tokens_split_names_like_exif_writes_them() {
assert_eq!(tokens("FE 24-105mm F4.0 G OSS"), ["fe", "24", "105", "mm", "f", "4", "g", "oss"]);
assert_eq!(tokens("f/2.8"), ["f", "2.8"]);
}
#[test]
fn spec_names_read_like_lens_names() {
assert_eq!(spec_name([24.0, 70.0, 2.8, 2.8]), "24-70mm f/2.8");
assert_eq!(spec_name([18.0, 55.0, 3.5, 5.6]), "18-55mm f/3.5-5.6");
assert_eq!(spec_name([85.0, 85.0, 1.8, 1.8]), "85mm f/1.8");
assert_eq!(spec_name([10.5, 10.5, 2.8, 2.8]), "10.5mm f/2.8");
}
#[test]
fn spec_names_need_an_exact_profile_name() {
let db = db();
let lens = db.find_lens(None, "24-70mm f/2.8", None).expect("a partial match");
// the profile's names carry more words than the specification, so the maker-less name is not confirmed
assert!(!lens.names_exactly("24-70mm f/2.8"));
assert!(lens.names_exactly("testcam 24-70MM F/2.8 zoom"), "case and punctuation don't matter");
assert!(!lens.names_exactly("24-70mm f/2.8 Zoom"), "the maker's words count: another maker's lens isn't this one");
let mut aliased = lens.clone();
aliased.models.push("24-70mm f/2.8".into());
assert!(aliased.names_exactly(&spec_name([24.0, 70.0, 2.8, 2.8])));
assert!(!aliased.names_exactly("24-105mm f/4"));
}
/// Another maker's lens with the same specification scores as high as the aliased one; coming first in the
/// database it must not hide the profile named for the specification.
#[test]
fn a_named_profile_is_found_behind_a_tie() {
let lens = |maker: &str, models: &str| {
format!(
r#"<lens><maker>{maker}</maker>{models}<mount>Test Mount</mount><cropfactor>1</cropfactor>
<calibration><distortion model="poly3" focal="18" k1="-0.01" /></calibration></lens>"#
)
};
let xml = format!(
r#"<lensdatabase version="2">{}{}</lensdatabase>"#,
lens("Otherco", "<model>Otherco 18-55mm f/3.5-5.6</model>"),
lens("Testcam", "<model>Testcam 18-55mm f/3.5-5.6 VR</model><model>18-55mm f/3.5-5.6</model>"),
);
let db = LensDb::parse(&xml).unwrap();
let name = spec_name([18.0, 55.0, 3.5, 5.6]);
assert_eq!(db.find_lens(None, &name, None).unwrap().maker, "Otherco", "a plain lookup keeps the first of a tie");
assert_eq!(db.find_lens_named(None, &name, None).unwrap().maker, "Testcam");
assert!(db.find_lens_named(None, "70-200mm f/4", None).is_none());
}
+47
View File
@@ -380,6 +380,53 @@ mod tests {
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
#[test]
fn idle_close_reconnects_without_replaying_a_command() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (closed, expired) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let mut ids = Vec::new();
for _ in 0..2 {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("accept: {e}"),
}
};
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
ids.push(request["id"].as_u64().unwrap());
writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ids.len()})).unwrap();
writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
line.clear();
let error = reader.read_line(&mut line).unwrap_err();
assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(line.is_empty(), "no command is resent while idle");
writeln!(writer, "{}", json!({"ok": false, "error": "control connection idle read timed out; closing the connection"})).unwrap();
drop(reader);
drop(writer);
closed.send(()).unwrap();
}
ids
});
let mut remote = Remote::lazy(&addr);
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(1));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(2));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(server.join().unwrap(), [1, 2], "each command ran once on a separate connection");
}
#[test]
fn unreachable_app_is_reported() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+23 -1
View File
@@ -132,7 +132,7 @@ impl Backend for Headless {
"engine.commands" => {
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"enabled": self.session.active().is_some()}));
Ok(Value::Array(v))
}
@@ -203,6 +203,28 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String>
mod tests {
use super::*;
#[test]
fn folder_discovery_matches_engine_extensions() {
let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let nested = dir.join("trip/day2");
std::fs::create_dir_all(&nested).unwrap();
let mut expected = Vec::new();
for ext in lightcraft_engine::import::EXTENSIONS {
for (folder, name) in [(&dir, format!("lower.{ext}")), (&nested, format!("upper.{}", ext.to_ascii_uppercase()))] {
let path = folder.join(name);
std::fs::write(&path, b"discovery only, not a decoded photo").unwrap();
expected.push(path.to_string_lossy().into_owned());
}
}
std::fs::write(nested.join("notes.txt"), b"not a photo").unwrap();
let mut found = expand_paths(&[dir.to_string_lossy().into_owned()]);
expected.sort();
found.sort();
assert_eq!(found, expected);
std::fs::remove_dir_all(dir).unwrap();
}
/// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619).
#[test]
fn headless_sessions_get_the_sam3_model_folder() {
+3 -1
View File
@@ -243,8 +243,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation, unless `fit` says otherwise)"},
"height": {"type": "integer", "description": "Output height in px"},
"fit": {"type": "string", "enum": ["dimensions", "widthHeight"], "description": "With width + height: dimensions (default) fits either orientation (the larger number goes to the long edge); widthHeight limits the width to `width` and the height to `height` as the photo stands"},
"megapixels": {"type": "number", "description": "Output size in megapixels"},
"percent": {"type": "number", "description": "Output size as a percentage of full size"},
"dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"},
@@ -730,6 +731,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"height",
"megapixels",
"percent",
"fit",
"dontEnlarge",
"ppi",
"quality",
+43 -19
View File
@@ -13,13 +13,49 @@ use crate::ransac::{self, Model};
/// Long edge of the images features are detected on.
pub const WORK_EDGE: usize = 1200;
/// The luminance merges align and weigh by (camera or linear RGB; not a colorimetric one).
#[inline]
pub(crate) fn luma(c: [f32; 3]) -> f32 {
0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]
}
/// The motion models a fit chooses among, simplest first: (model, name, fewest inliers).
pub type Models = [(Model, &'static str, usize)];
/// Of `models` fitted by RANSAC to `a` → `b` (`thresh` px; at least `share` of the points and each
/// model's minimum as inliers; plausible for a `w × h` frame), the simplest that explains (almost) as
/// many points as the one explaining the most: extra degrees of freedom fitted to clustered points
/// extrapolate badly.
pub(crate) fn choose_model(
models: &Models,
a: &[Point],
b: &[Point],
thresh: f64,
share: f64,
(w, h): (f64, f64),
seed: u64,
) -> Option<(&'static str, ransac::Fit)> {
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
for &(model, name, min) in models {
let need = ((a.len() as f64 * share).ceil() as usize).max(min);
if let Some(f) = ransac::ransac(model, a, b, thresh, need, seed)
&& plausible(&f.h, w, h, 0.12)
{
fits.push((name, f));
}
}
let most = fits.iter().map(|(_, f)| f.inliers.len()).max()?;
// the fit with `most` inliers always qualifies
fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97)
}
/// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and
/// the scale from full-resolution pixels to work pixels.
pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() };
let scale = small.width as f64 / img.width as f64;
let p = small.map(|c| {
let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain;
let y = luma(c) * gain;
y.clamp(0.0, 1.0).powf(1.0 / 2.2)
});
(p, scale)
@@ -29,8 +65,7 @@ pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
/// without EXIF.
pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 {
let step = (img.data.len() / 100_000).max(1);
let mut v: Vec<f32> =
img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect();
let mut v: Vec<f32> = img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| luma(*c)).collect();
if v.is_empty() {
return clip;
}
@@ -86,24 +121,13 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
let a: Vec<Point> = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect();
let b: Vec<Point> = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect();
let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale);
// Fit every model; keep the simplest one that explains (almost) as many matches as the most
// general one — extra degrees of freedom fitted to clustered points extrapolate badly.
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] {
if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed)
&& plausible(&f.h, ww, wh, 0.12)
{
fits.push((name, f));
}
}
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
// the fit with `most` inliers always qualifies
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
let models = [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)];
let Some((name, f)) = choose_model(&models, &a, &b, 1.5, 0.0, (ww, wh), seed) else { return Alignment::identity() };
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
}
/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and
/// a validity mask (false where `h(p)` falls outside `img`).
/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped
/// image and a validity mask (false where `h(p)` falls outside `img`).
pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
if *h == Homography::IDENTITY {
@@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let (sx, sy) = (p.x as f32, p.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_bilinear(sx, sy);
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f,
let src = lo[i] + t * (hi[i] - lo[i]);
let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src;
let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) };
row[x] = img.sample_bilinear(sx, sy);
row[x] = img.sample_lanczos3(sx, sy);
arow[x] = crate::features::bilinear(alpha, sx, sy);
}
});
+1 -1
View File
@@ -298,7 +298,7 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
let wx = 1.0 - (2.0 * px / fw - 1.0).abs();
let wy = 1.0 - (2.0 * py / fh - 1.0).abs();
let w = (wx * wy).max(1e-6) as f32;
row[tx] = fr.image.sample_bilinear(px as f32, py as f32);
row[tx] = fr.image.sample_lanczos3(px as f32, py as f32);
wrow[tx] = w;
}
});
+1
View File
@@ -13,6 +13,7 @@ lightcraft-tiff = { workspace = true }
lightcraft-geom = { workspace = true }
serde = { workspace = true }
quick-xml = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+9
View File
@@ -7,6 +7,7 @@
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! IPTC (33723) from IFD0.
//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block.
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
//! XMP `uuid` box.
@@ -36,6 +37,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
_ => Embedded::default(),
};
}
if let Some(m) = crate::mrw::parse(bytes) {
// Minolta MRW: the `TTW` block is a complete TIFF with the Exif data
return m
.block(b"TTW")
.filter(|t| Tiff::parse(t).is_ok())
.map(|t| Embedded { exif: Some(t.to_vec()), ..Default::default() })
.unwrap_or_default();
}
if let Some(c) = crate::cr3::parse_cr3(bytes) {
return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() };
}
+10 -22
View File
@@ -28,21 +28,10 @@ impl DateTime {
// A timestamp must represent a real Gregorian calendar day, not just
// numbers in the range 1..=31. Invalid dates would otherwise be
// accepted from EXIF/XMP and silently normalized during time arithmetic.
let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400));
let max_day = match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => 0,
};
let ok = self.day >= 1
&& self.day <= max_day
&& self.hour < 24
&& self.minute < 60
&& self.second < 61
&& self.year > 0
&& self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60);
// chrono rejects impossible days (leap years and all); the time is checked
// by hand so a leap second (`second == 60`) stays acceptable.
let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some();
let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60);
ok.then_some(self)
}
@@ -153,13 +142,12 @@ impl DateTime {
/// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds).
pub fn unix_seconds(&self) -> i64 {
// days from civil (proleptic Gregorian), Howard Hinnant's public-domain algorithm
let (y, m, d) = (self.year as i64 - if self.month <= 2 { 1 } else { 0 }, self.month as i64, self.day as i64);
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
// Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers);
// an out-of-range one (built by hand) reads as the epoch rather than panicking.
let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day))
.zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1))
.map(|(d, epoch)| (d - epoch).num_days())
.unwrap_or(0);
days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60
}
}
+39 -5
View File
@@ -127,20 +127,32 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata {
m
}
/// Degrees/minutes/seconds triplet as decimal degrees. A component that is present but not a number (a rational
/// with a zero denominator, as in the placeholder GPS block phones write without a fix) makes the whole value unusable.
fn dms(ifd: &Ifd, tag: u16) -> Option<f64> {
let v = ifd.f64s(tag)?;
let d = *v.first()?;
let deg = d + v.get(1).copied().unwrap_or(0.0) / 60.0 + v.get(2).copied().unwrap_or(0.0) / 3600.0;
let v = ifd.value(tag)?;
let part = |i: usize| if i < v.count() { v.get_f64(i) } else { Some(0.0) };
let d = v.get_f64(0)?;
let deg = d + part(1)? / 60.0 + part(2)? / 3600.0;
deg.is_finite().then_some(deg)
}
/// `Some(true)` for the negative hemisphere (`neg`), `Some(false)` for `pos`, `None` when the ref is missing or invalid.
fn hemisphere(g: &Ifd, tag: u16, pos: char, neg: char) -> Option<bool> {
match g.string(tag)?.trim_start().chars().next()?.to_ascii_uppercase() {
c if c == neg => Some(true),
c if c == pos => Some(false),
_ => None,
}
}
fn read_gps(g: &Ifd) -> Option<Gps> {
let mut lat = dms(g, t::GPS_LATITUDE)?;
let mut lon = dms(g, t::GPS_LONGITUDE)?;
if g.string(t::GPS_LATITUDE_REF).is_some_and(|s| s.starts_with('S')) {
if hemisphere(g, t::GPS_LATITUDE_REF, 'N', 'S')? {
lat = -lat;
}
if g.string(t::GPS_LONGITUDE_REF).is_some_and(|s| s.starts_with('W')) {
if hemisphere(g, t::GPS_LONGITUDE_REF, 'E', 'W')? {
lon = -lon;
}
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
@@ -382,6 +394,28 @@ pub(crate) mod tests {
}
}
#[test]
fn placeholder_gps_block_is_no_location() {
// Android phones without a fix write NUL refs and 0/0 rationals (#1617).
let read = |lat_ref: &str, lat: Vec<(u32, u32)>| {
let mut gps = IfdBuilder::new();
gps.set(t::GPS_LATITUDE_REF, Value::Ascii(lat_ref.into()));
gps.set(t::GPS_LATITUDE, Value::Rational(lat));
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into()));
gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)]));
let mut ifd0 = IfdBuilder::new();
ifd0.set_child(t::GPS_IFD, gps);
read_exif(&TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()).gps
};
let valid = vec![(33, 1), (51, 1), (3564, 100)];
assert!(read("N", valid.clone()).is_some());
assert_eq!(read("N", vec![(0, 0), (0, 0), (0, 0)]), None, "0/0 rationals");
assert_eq!(read("N", vec![(33, 1), (0, 0), (0, 0)]), None, "0/0 minutes");
assert_eq!(read("", valid.clone()), None, "NUL ref");
assert_eq!(read("", valid.clone()), None, "empty ref");
assert_eq!(read("X", valid), None, "invalid ref");
}
#[test]
fn exif_prefix_and_garbage() {
let mut b = b"Exif\0\0".to_vec();
+13 -1
View File
@@ -147,7 +147,7 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
match name.as_slice() {
b"ele" if parent == Some(b"trkpt") => {
if let Some(p) = pt.as_mut() {
p.ele = text.trim().parse().ok();
p.ele = text.trim().parse().ok().filter(|v: &f64| v.is_finite());
}
}
b"time" if parent == Some(b"trkpt") => {
@@ -351,6 +351,18 @@ mod tests {
assert_eq!(log.points[1].time - log.points[0].time, 60.0);
}
#[test]
fn non_finite_elevation_is_dropped() {
for ele in ["inf", "-inf", "NaN", "infinity"] {
let s = format!(
r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><ele>{ele}</ele><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>"#
);
let log = parse_gpx(&s).unwrap();
assert_eq!(log.points.len(), 1, "{ele}");
assert_eq!(log.points[0].elevation, None, "{ele}");
}
}
#[test]
fn interpolation_across_the_antimeridian() {
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
+1
View File
@@ -19,6 +19,7 @@ pub mod focus;
mod focus_read;
mod gpx;
mod iptc;
pub mod mrw;
pub mod tags;
mod xmp;
mod xmp_merge;
+93
View File
@@ -0,0 +1,93 @@
//! Minolta / Konica Minolta MRW block container.
//!
//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box
//! analysis of the CC0 samples on raw.pixls.us. The file starts with `\0MRM` and a big-endian `u32` length; the
//! sensor data begins right after that many bytes (at `8 + length`). In between is a sequence of blocks, each a
//! four-byte name (`\0PRD`, `\0TTW`, `\0WBG`, `\0RIF`, `\0PAD`), a big-endian `u32` length and that many bytes.
//! `\0TTW` holds a complete big-endian TIFF (IFD0, Exif, maker note) whose offsets are relative to the block.
/// The blocks and the offset of the sensor data in an MRW file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mrw<'a> {
/// `(name without the leading NUL, contents)`, in file order.
pub blocks: Vec<([u8; 3], &'a [u8])>,
/// Absolute offset of the sensor data.
pub data_offset: usize,
}
impl<'a> Mrw<'a> {
/// The first block called `name` (e.g. `b"PRD"`).
pub fn block(&self, name: &[u8; 3]) -> Option<&'a [u8]> {
self.blocks.iter().find(|(n, _)| n == name).map(|(_, d)| *d)
}
}
/// At most this many blocks are walked (real files have four or five).
const MAX_BLOCKS: usize = 64;
fn be32(b: &[u8], at: usize) -> Option<usize> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize)
}
/// Parse the block list of an MRW file. `None` when the magic is missing or the header length points past the
/// file; a block that runs past the header is dropped together with everything after it.
pub fn parse(bytes: &[u8]) -> Option<Mrw<'_>> {
if !bytes.starts_with(b"\0MRM") {
return None;
}
let data_offset = be32(bytes, 4)?.checked_add(8).filter(|&o| o <= bytes.len())?;
let mut blocks = Vec::new();
let mut at = 8usize;
while blocks.len() < MAX_BLOCKS && at.saturating_add(8) <= data_offset {
let (Some(name), Some(len)) = (bytes.get(at..at + 4), be32(bytes, at + 4)) else { break };
let start = at + 8;
let Some(end) = start.checked_add(len).filter(|&e| e <= data_offset) else { break };
let Some(data) = bytes.get(start..end) else { break };
if name[0] == 0 {
blocks.push(([name[1], name[2], name[3]], data));
}
at = end;
}
Some(Mrw { blocks, data_offset })
}
#[cfg(test)]
mod tests {
use super::*;
fn block(name: &[u8; 3], data: &[u8]) -> Vec<u8> {
let mut v = vec![0];
v.extend_from_slice(name);
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
v.extend_from_slice(data);
v
}
#[test]
fn walks_blocks_and_finds_the_data() {
let mut body = block(b"PRD", &[1, 2, 3]);
body.extend(block(b"WBG", &[4; 12]));
let mut f = b"\0MRM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
f.extend_from_slice(&[9, 9]);
let m = parse(&f).unwrap();
assert_eq!(m.block(b"PRD"), Some(&[1u8, 2, 3][..]));
assert_eq!(m.block(b"WBG").map(<[u8]>::len), Some(12));
assert_eq!(m.block(b"TTW"), None);
assert_eq!(m.data_offset, f.len() - 2);
}
#[test]
fn hostile_lengths_never_panic() {
assert_eq!(parse(b"\0MRM"), None);
assert_eq!(parse(b"\0MRM\xff\xff\xff\xff"), None);
// a block claiming more than the header holds is dropped
let f = b"\0MRM\0\0\0\x0c\0PRD\xff\xff\xff\xff\0\0\0\0";
assert_eq!(parse(f).map(|m| m.blocks.len()), Some(0));
for n in 0..f.len() {
let _ = parse(&f[..n]);
}
}
}
+18 -1
View File
@@ -145,7 +145,14 @@ fn rational(n: f64, d: f64) -> String {
return "—".into();
}
let v = n / d;
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() }
if (v - v.round()).abs() < 1e-9 {
let r = v.round();
if r == 0.0 { "0".to_string() } else { format!("{}", r) }
} else {
let s = format!("{:.4}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
}
}
fn list<T: ToString>(v: &[T]) -> String {
@@ -370,6 +377,16 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
mod tests {
use super::*;
#[test]
fn rational_has_no_trailing_dot() {
assert_eq!(rational(1.0, 100000.0), "0");
assert_eq!(rational(-1.0, 100000.0), "0");
assert_eq!(rational(-1.0, 1e10), "0");
assert_eq!(rational(1000001.0, 100000.0), "10");
assert_eq!(rational(1.0, 3.0), "0.3333");
assert_eq!(rational(5.0, 2.0), "2.5");
}
#[test]
fn rows_from_written_exif() {
let m = crate::Metadata {
+8 -2
View File
@@ -457,8 +457,12 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option<Regi
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
let a = v.abs();
let d = a.trunc();
let m = (a - d) * 60.0;
let mut d = a.trunc();
let mut m = ((a - d) * 60.0 * 1e6).round() / 1e6;
if m >= 60.0 {
d += 1.0;
m = 0.0;
}
format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos })
}
@@ -866,6 +870,8 @@ mod tests {
assert_eq!(parse_gps_coord(""), None);
assert_eq!(parse_gps_coord("é"), None);
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N");
assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W");
}
/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°.
+1 -1
View File
@@ -59,7 +59,7 @@ pub use tone::ToneMap;
/// A raw source's own colour model, for white balance: like Lightroom, a white balance
/// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG
/// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]).
#[derive(Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CameraColor {
/// The file's colour tags (its profile look left out: it is applied at load).
pub tags: lightcraft_raw::ColorData,
+35 -5
View File
@@ -490,9 +490,13 @@ pub struct PlaneSigmas {
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
let ppl = px_per_long as f32;
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0);
let tone_active =
s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0);
// A hidden mask, or one with no components, does not change the picture. Its sliders must
// not pick the tone base Clarity reads (#1214).
let live = |m: &&lightcraft_develop::Mask| m.visible && !m.components.is_empty();
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().filter(live).any(|m| f(&m.adjust) != 0.0);
let tone_active = s.light.highlights != 0.0
|| s.light.shadows != 0.0
|| s.masks.iter().filter(live).any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0);
// Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved)
// From V2 it is taken of the median at 0.15%: lines and texture finer than that are detail,
// not tone (#632).
@@ -507,10 +511,10 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|| local_any(|a| a.texture)
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
|| s.masks.iter().filter(live).any(|m| m.adjust.defringe > 0.0))
.then(|| (0.0018 * ppl).max(0.6));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
let chroma = (local_any(|a| a.moire) || s.masks.iter().filter(live).any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 };
PlaneSigmas { base, base_detail, clarity, texture, dark, chroma }
}
@@ -689,6 +693,32 @@ mod tests {
assert_eq!(frame_airlight(&hazy, &info, &s, &cropped, 48, 32).to_bits(), fresh(&hazy, &cropped).to_bits());
}
#[test]
fn a_hidden_or_empty_mask_does_not_request_the_tone_base() {
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let shape = MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) };
let component = MaskComponent { name: None, op: MaskOp::Add, invert: false, shape };
let mut s = DevelopSettings::default();
s.masks.push(Mask {
visible: false,
components: vec![component],
adjust: lightcraft_develop::LocalAdjustments { highlights: 40.0, ..Default::default() },
..Default::default()
});
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none());
s.masks[0].visible = true;
s.masks[0].components.clear();
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none());
s.masks[0].components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) },
});
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_some());
}
#[test]
fn exposure_doubles() {
let mut s = DevelopSettings::default();
+1 -1
View File
@@ -19,7 +19,7 @@ use crate::{Plan, SourceInfo};
use lightcraft_develop::DevelopSettings;
/// A raw's gain table map and where its developed source sits in the active area.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LocalTone {
pub map: GainTableMap,
pub placement: SourcePlacement,
+33
View File
@@ -25,6 +25,33 @@ pub enum MatteKind {
Glasses,
}
impl MatteKind {
/// A stable code for serialization (e.g. a smart preview's stored mattes); never renumber.
pub fn code(self) -> u8 {
match self {
MatteKind::Sky => 0,
MatteKind::Person => 1,
MatteKind::Skin => 2,
MatteKind::Hair => 3,
MatteKind::Teeth => 4,
MatteKind::Glasses => 5,
}
}
/// [`MatteKind::code`]'s inverse (`None` for a code this build doesn't know).
pub fn from_code(code: u8) -> Option<Self> {
match code {
0 => Some(MatteKind::Sky),
1 => Some(MatteKind::Person),
2 => Some(MatteKind::Skin),
3 => Some(MatteKind::Hair),
4 => Some(MatteKind::Teeth),
5 => Some(MatteKind::Glasses),
_ => None,
}
}
}
/// Segmentation mattes that came with the source: 0..255 alpha planes, each covering the whole
/// (EXIF-oriented) source image at its own resolution. Several mattes of one kind (e.g. one per
/// person) are combined with max.
@@ -56,6 +83,12 @@ impl Mattes {
self.mattes.iter().map(|(_, m)| m.data.len()).sum()
}
/// The stored mattes, in insertion order (kind and plane), e.g. for storing them with a
/// photo's proxy source.
pub fn iter(&self) -> impl Iterator<Item = (MatteKind, &Image<u8>)> {
self.mattes.iter().map(|(k, m)| (*k, m))
}
fn of(&self, kinds: &[MatteKind]) -> Vec<&Image<u8>> {
self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect()
}
+20 -2
View File
@@ -137,7 +137,8 @@ fn perf_report() {
#[test]
fn unedited_rendered_source_is_passthrough() {
// An sRGB ramp decoded to linear Rec.2020 must render back to (almost) the same 8-bit values.
// An sRGB ramp decoded to linear Rec.2020 must render back to the same 8-bit values, white
// included (issue #1945: neutral 255, 254 and 253 came back one level darker).
let src8 = lightcraft_raster::Rgba8::from_fn(256, 4, |x, y| [x as u8, (x as u8).wrapping_add(y as u8 * 40), 255 - x as u8, 255]);
let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020);
let src = src8.to_linear().map(|c| m.apply_f32(c));
@@ -148,7 +149,24 @@ fn unedited_rendered_source_is_passthrough() {
worst = worst.max((a[c] as i32 - b[c] as i32).abs());
}
}
assert!(worst <= 3, "max error {worst}");
assert_eq!(worst, 0, "max error {worst}");
}
#[test]
fn unedited_neutral_near_whites_are_passthrough() {
// Issue #1945: neutral 255, 254 and 253 rendered one level darker (the display tone map's
// shoulder ran at neutral settings), so a white background never exported as 255.
let vals = [255u8, 254, 253, 252, 250, 240, 200, 128, 0];
let src8 = lightcraft_raster::Rgba8::from_fn(vals.len(), 1, |x, _| {
let v = vals[x];
[v, v, v, 255]
});
let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020);
let src = src8.to_linear().map(|c| m.apply_f32(c));
let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(vals.len(), 1)).image;
let got: Vec<[u8; 3]> = r.data.iter().map(|p| [p[0], p[1], p[2]]).collect();
let want: Vec<[u8; 3]> = vals.iter().map(|&v| [v, v, v]).collect();
assert_eq!(got, want);
}
fn typical_edits() -> DevelopSettings {
+21 -4
View File
@@ -186,6 +186,9 @@ impl ToneMap {
let w = (whites / 100.0) as f32;
let b = (blacks / 100.0) as f32;
let m = GREY.powf(1.0 / 2.2);
// Neutral sliders are the identity up to white (issue #1945): the SDR shoulder below would
// pull 1.0 down to 0.9875 (8-bit 255 to 254), so it only shapes an edited curve.
let neutral = c == 0.0 && w == 0.0 && b == 0.0;
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
@@ -203,12 +206,12 @@ impl ToneMap {
p += w * 0.12 * 0.5;
}
let mut o = p.max(0.0).powf(2.2);
// short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05
// short shoulder (SDR, edited curves): slope 1 at 0.95, reaching 1.0 at 1.05
if peak > 1.0 && o > 0.95 {
// exponential shoulder: slope 1 at 0.95, asymptote at `peak`
let r = peak - 0.95;
o = 0.95 + r * (1.0 - (-(o - 0.95) / r).exp());
} else if o > 0.95 {
} else if o > 0.95 && !neutral {
let d = (o - 0.95).min(0.1);
o = 0.95 + d - d * d / 0.2;
}
@@ -411,10 +414,13 @@ mod tests {
#[test]
fn display_identity_and_monotone() {
let t = ToneMap::display(0.0, 0.0, 0.0);
for i in 1..=95 {
// up to and including white (issue #1945: 1.0 came out as 0.9875, 8-bit 255 as 254); the
// tolerance is the LUT's interpolation next to the clip at 1.0, far below an 8-bit level
for i in 1..=100 {
let y = i as f32 / 100.0;
assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y));
assert!((t.apply(y) - y).abs() < 1e-3, "{y} -> {}", t.apply(y));
}
assert_eq!(t.apply(4.0), 1.0, "neutral values pushed above white clip");
for (c, w, b) in [(100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (60.0, -40.0, 30.0)] {
let t = ToneMap::display(c, w, b);
let mut prev = -1.0;
@@ -428,6 +434,17 @@ mod tests {
assert!(c.apply(0.05) < 0.05 && c.apply(0.7) > 0.7);
}
#[test]
fn display_shoulder_stays_on_edited_curves() {
// The short shoulder is part of the V1 look of edited curves (docs/process-versions.md);
// only neutral settings are the identity.
for (c, w, b) in [(20.0, 0.0, 0.0), (0.0, 10.0, 0.0), (0.0, 0.0, -5.0)] {
let t = ToneMap::display(c, w, b);
let o = t.apply(1.0);
assert!(o < 0.999 && o > 0.95, "{c} {w} {b}: {o}");
}
}
#[test]
fn grey_stays_near_grey_and_highlights_roll_off() {
let t = ToneMap::new(0.0, 0.0, 0.0);
+149
View File
@@ -0,0 +1,149 @@
//! Preview/save approximation checks, not CPU/GPU equivalence or pixel parity.
//!
//! All inputs are procedural, scene-linear Rec.2020. The save reference is the
//! full-size U8 sRGB output, decoded to linear light, Box reduced, then encoded
//! back to U8 sRGB. This models one explicit display reduction, not every export
//! codec/viewer's resampler. Averaging before nonlinear tone mapping cannot in
//! general equal averaging afterwards, especially on unresolved bright texture.
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, SourceInfo, local, render};
use lightcraft_raster::{
Rgb32f, Rgba8,
resample::{Filter, resize},
};
fn scene(w: usize, h: usize) -> Rgb32f {
Rgb32f::from_fn(w, h, |x, y| {
let u = x as f32 / w as f32;
// Smooth shadows through scene highlights, hard boundaries, and fine
// twigs/checkers that straddle the shoulder after a strong exposure push.
let ramp = 0.012 * 2.0_f32.powf(7.0 * u);
let texture = if (x + 3 * y) % 7 < 2 { 0.35 } else { 1.15 };
let v = ramp * texture * if y < h / 2 { 1.0 } else { 0.3 };
[v * 1.05, v, v * 0.85]
})
}
fn edits() -> DevelopSettings {
let mut s = DevelopSettings::default();
s.light.exposure = 1.5;
s.light.highlights = -90.0;
s.light.shadows = 75.0;
s.light.contrast = 35.0;
s.light.whites = 20.0;
s.light.blacks = -25.0;
s.effects.clarity = 40.0;
s
}
#[derive(Debug)]
struct Error {
mean: f64,
p99: u8,
}
impl Error {
fn acceptable(&self) -> bool {
// Observed maxima on these fixtures: mean 6.057, pixel-max p99 37. Leave
// headroom for numerical variation, while bounding both widespread
// drift and localized tails. These are fixture bounds, not a promise
// for arbitrary photographs, settings, resamplers, or output depths.
self.mean < 8.0 && self.p99 <= 40
}
}
fn error(a: &Rgba8, b: &Rgba8) -> Error {
assert_eq!((a.width, a.height), (b.width, b.height));
let mut sum = 0_u64;
let mut pixel_errors: Vec<u8> = a
.data
.iter()
.zip(&b.data)
.map(|(a, b)| {
let deltas = std::array::from_fn::<_, 3, _>(|c| a[c].abs_diff(b[c]));
sum += deltas.iter().map(|&v| u64::from(v)).sum::<u64>();
deltas.into_iter().max().unwrap_or(0)
})
.collect();
// Mean remains over RGB samples; the tail uses each pixel's worst channel
// so a thin artifact in just one channel is not diluted by the other two.
let mean = sum as f64 / (pixel_errors.len() * 3) as f64;
pixel_errors.sort_unstable();
Error { mean, p99: pixel_errors[(pixel_errors.len() - 1) * 99 / 100] }
}
fn reduced_save(save: &Rgba8, w: usize, h: usize) -> Rgba8 {
resize(&save.to_linear(), w, h, Filter::Box).to_srgb8()
}
fn measure(w: usize, h: usize, edges: &[usize], inject: bool) {
let src = scene(w, h);
let s = edits();
let info = SourceInfo::default();
let save = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
for &edge in edges {
let req = RenderRequest::fit(edge, edge);
let preview = render(&src, &info, &s, &req).image;
let reference = reduced_save(&save, preview.width, preview.height);
let actual = error(&preview, &reference);
eprintln!("{w}x{h} -> {edge}: direct {actual:?}");
assert!(actual.acceptable(), "{w}x{h} -> {edge}: {actual:?}");
// A separately reduced source models a proxy decode. Preserve native
// size metadata and use the geometry stage's Mitchell prefilter here.
let proxy = resize(&src, preview.width, preview.height, Filter::Mitchell);
let proxy_info = SourceInfo { native_long: Some(w.max(h)), ..info.clone() };
let from_proxy = render(&proxy, &proxy_info, &s, &req).image;
let proxy_error = error(&from_proxy, &reference);
eprintln!("{w}x{h} -> {edge}: proxy {proxy_error:?}");
assert!(proxy_error.acceptable(), "{w}x{h} -> {edge} proxy: {proxy_error:?}");
if inject {
for control in ["exposure", "highlights"] {
let mut broken = s.clone();
match control {
"exposure" => broken.light.exposure = 0.0,
_ => broken.light.highlights = 0.0,
}
let omitted = render(&src, &info, &broken, &req).image;
let omitted_error = error(&omitted, &reference);
eprintln!("{w}x{h} -> {edge}: omitted {control}: {omitted_error:?}");
// Exercise the very same acceptance predicate as the real
// preview. A test-only omitted control must be rejected; this
// guards against silently loosening the bounds into a no-op.
assert!(!omitted_error.acceptable(), "omitted {control} escaped: {omitted_error:?}");
}
}
}
}
#[test]
fn tail_bound_rejects_a_thin_single_channel_artifact() {
let reference = Rgba8::filled(800, 48, [0, 0, 0, 255]);
let mut corrupted = reference.clone();
for pixel in &mut corrupted.data[..800] {
pixel[0] = 255;
}
let actual = error(&corrupted, &reference);
// One red row is only 0.69% of RGB samples (and mean < 2), but is 2.08%
// of pixels. The tail must reject it using the preview's own predicate.
assert!(actual.mean < 2.0);
assert_eq!(actual.p99, 255);
assert!(!actual.acceptable(), "single-channel row escaped: {actual:?}");
}
#[test]
fn preview_tracks_reduced_save_with_strong_edits() {
// 3200 reaches the guided-filter subsample cap (sigma = 1.5% long edge),
// unlike 800/1440. A narrow strip keeps this regression affordable in CI.
assert_eq!(local::guided_fast_step(0.015 * 3200.0), 16);
assert!(local::guided_fast_step(0.015 * 1440.0) < 16);
measure(3200, 192, &[800, 1440], true);
}
#[test]
#[ignore = "larger preview/save stress measurement; run with --ignored --nocapture"]
fn large_preview_save_stress() {
measure(6400, 768, &[800, 1440], true);
}
+3 -1
View File
@@ -2,7 +2,8 @@
//!
//! - [`Image<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`,
//! `Plane = Image<f32>`).
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel.
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps
//! ([`sample`]: Lanczos-3 held to its neighbours' range).
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius).
//! - [`Histogram`] of display-encoded RGB + luminance.
@@ -13,6 +14,7 @@ pub mod blur;
pub mod histogram;
pub mod morph;
pub mod resample;
pub mod sample;
pub use histogram::{HdrBins, Histogram};
+2 -1
View File
@@ -19,7 +19,8 @@ impl Filter {
Filter::Lanczos3 => 3.0,
}
}
fn eval(self, x: f32) -> f32 {
/// The filter's weight at distance `x` (pixels of the source).
pub(crate) fn eval(self, x: f32) -> f32 {
let x = x.abs();
match self {
Filter::Box => {
+154
View File
@@ -0,0 +1,154 @@
//! Point sampling at continuous coordinates for geometric warps (alignment, projections).
//!
//! [`Rgb32f::sample_lanczos3`] interpolates with a 6 × 6 Lanczos-3 kernel. Bilinear sampling (the
//! [`Rgb32f::sample_bilinear`] next to it) averages two pixels at a half-pixel offset: a [½ ½]
//! filter that passes nothing at the Nyquist frequency, so a frame warped by a fraction of a pixel
//! loses its finest detail. Lanczos-3 keeps most of it. Its negative lobes would overshoot next to
//! a hard edge (a clipped highlight, a star), so each sample is held to the range of the 4 × 4
//! pixels around it, channel by channel: in smooth areas and fine texture that range holds the
//! interpolated value anyway (a 2 × 2 range would cut the peaks of texture near Nyquist that fall
//! between pixels), and at an edge the overshoot goes.
use std::sync::OnceLock;
use crate::Rgb32f;
use crate::resample::Filter;
/// Phases of a pixel the kernel's taps are tabulated for.
const PHASES: usize = 1024;
/// The six Lanczos-3 weights (summing to 1) for a sample `t` (0..1) past the third of the six
/// pixels it reads: pixel `k` (0..6) is at distance `t + 2 − k`.
pub fn lanczos3_taps(t: f32) -> [f32; 6] {
static TABLE: OnceLock<Vec<[f32; 6]>> = OnceLock::new();
let table = TABLE.get_or_init(|| {
(0..=PHASES)
.map(|i| {
let t = i as f32 / PHASES as f32;
let w: [f32; 6] = std::array::from_fn(|k| Filter::Lanczos3.eval(t + 2.0 - k as f32));
let s: f32 = w.iter().sum();
w.map(|v| v / s)
})
.collect()
});
let i = if t.is_finite() { (t.clamp(0.0, 1.0) * PHASES as f32).round() as usize } else { 0 };
table.get(i).copied().unwrap_or([0.0, 0.0, 1.0, 0.0, 0.0, 0.0])
}
impl Rgb32f {
/// Lanczos-3 sample at continuous pixel coordinates (pixel centres at +0.5), held to the range
/// of the 4 × 4 pixels around it (see the module docs). Past the edges the edge pixels repeat;
/// a coordinate that isn't finite reads the nearest edge, as [`Rgb32f::sample_bilinear`] does.
pub fn sample_lanczos3(&self, x: f32, y: f32) -> [f32; 3] {
let (w, h) = (self.width, self.height);
if w == 0 || h == 0 {
return [0.0; 3];
}
let fx = x.clamp(-1.0, w as f32 + 1.0) - 0.5;
let fy = y.clamp(-1.0, h as f32 + 1.0) - 0.5;
let (fx, fy) = (if fx.is_finite() { fx } else { 0.0 }, if fy.is_finite() { fy } else { 0.0 });
let (x0, y0) = (fx.floor(), fy.floor());
let (wx, wy) = (lanczos3_taps(fx - x0), lanczos3_taps(fy - y0));
let (x0, y0) = (x0 as isize, y0 as isize);
let col: [usize; 6] = std::array::from_fn(|k| (x0 + k as isize - 2).clamp(0, w as isize - 1) as usize);
let row: [usize; 6] = std::array::from_fn(|k| (y0 + k as isize - 2).clamp(0, h as isize - 1) as usize * w);
let px = |r: usize, c: usize| self.data.get(r + c).copied().unwrap_or_default();
let mut acc = [0f32; 3];
// and the range of the 4 × 4 pixels around the sample (taps 1 to 4 on each axis)
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for (j, (r, &wr)) in row.iter().zip(&wy).enumerate() {
let mut line = [0f32; 3];
for (i, (&c, &wc)) in col.iter().zip(&wx).enumerate() {
let p = px(*r, c);
for ch in 0..3 {
line[ch] += wc * p[ch];
}
if (1..5).contains(&i) && (1..5).contains(&j) {
for ch in 0..3 {
lo[ch] = lo[ch].min(p[ch]);
hi[ch] = hi[ch].max(p[ch]);
}
}
}
for ch in 0..3 {
acc[ch] += wr * line[ch];
}
}
// no finite pixel in the 4 × 4 range (NaN all around): nothing to hold the sample to
std::array::from_fn(|ch| if lo[ch] <= hi[ch] { acc[ch].clamp(lo[ch], hi[ch]) } else { acc[ch] })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// High-pass energy of a row-wise pattern: the mean squared second difference.
fn hp(img: &Rgb32f) -> f64 {
let w = img.width;
let mut s = 0.0;
let mut n = 0;
for y in 4..img.height - 4 {
for x in 4..w - 4 {
let v = |dx: isize| img.get((x as isize + dx) as usize, y)[1] as f64;
s += (2.0 * v(0) - v(-1) - v(1)).powi(2);
n += 1;
}
}
s / n as f64
}
#[test]
fn taps_sum_to_one_and_hit_pixels_exactly() {
for t in [0.0, 0.1, 0.5, 0.77, 1.0] {
let w = lanczos3_taps(t);
assert!((w.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
let w = lanczos3_taps(0.0);
assert!((w[2] - 1.0).abs() < 1e-5 && w.iter().enumerate().all(|(k, v)| k == 2 || v.abs() < 1e-5));
assert_eq!(lanczos3_taps(f32::NAN), lanczos3_taps(0.0));
}
/// A fine pattern moved by half a pixel: bilinear keeps cos²(π f) of its high-pass energy (a
/// third at 0.3 cycles per pixel), Lanczos-3 twice that, and nearly all of it at 0.2.
#[test]
fn a_half_pixel_shift_keeps_fine_detail() {
for (f, lanczos_min, over_bilinear) in [(0.2f32, 0.9, 1.3), (0.3, 0.65, 1.9)] {
let img = Rgb32f::from_fn(64, 16, |x, _| [0.5 + 0.4 * (x as f32 * f * std::f32::consts::TAU).sin(); 3]);
let shifted = |s: &dyn Fn(f32, f32) -> [f32; 3]| Rgb32f::from_fn(64, 16, |x, y| s(x as f32 + 1.0, y as f32 + 0.5));
let bil = shifted(&|x, y| img.sample_bilinear(x, y));
let lan = shifted(&|x, y| img.sample_lanczos3(x, y));
let (e0, eb, el) = (hp(&img), hp(&bil), hp(&lan));
assert!(el > lanczos_min * e0, "{f} c/px: lanczos {el} vs {e0}");
assert!(el > over_bilinear * eb, "{f} c/px: lanczos {el} vs bilinear {eb}");
}
}
/// Next to a hard edge Lanczos-3 would overshoot; the sample stays within its neighbours.
#[test]
fn no_overshoot_at_a_hard_edge() {
let img = Rgb32f::from_fn(16, 4, |x, _| if x < 8 { [0.0; 3] } else { [1.0; 3] });
for i in 0..160 {
let v = img.sample_lanczos3(i as f32 * 0.1, 2.0);
assert!((0.0..=1.0).contains(&v[0]), "{v:?} at {}", i as f32 * 0.1);
}
// pixel centres come back exactly
assert_eq!(img.sample_lanczos3(8.5, 1.5), [1.0; 3]);
assert_eq!(img.sample_lanczos3(7.5, 1.5), [0.0; 3]);
}
#[test]
fn edges_and_odd_coordinates_never_panic() {
let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
for (x, y) in [(f32::INFINITY, f32::INFINITY), (f32::NEG_INFINITY, 0.0), (f32::NAN, f32::NAN), (f32::MAX, -1e30), (-5.0, 9.0)] {
let v = img.sample_lanczos3(x, y);
assert!(v.iter().all(|c| c.is_finite()), "{v:?}");
}
assert_eq!(Rgb32f::new(0, 0).sample_lanczos3(1.0, 1.0), [0.0; 3]);
// pixels that are all NaN (a damaged float file) give a NaN sample, never a panic
let nan = Rgb32f::from_fn(8, 8, |_, _| [f32::NAN; 3]);
assert!(nan.sample_lanczos3(4.3, 4.6).iter().all(|v| v.is_nan()));
assert_eq!(img.sample_lanczos3(0.5, 0.5), [0.0, 0.0, 1.0]);
}
}
+95 -13
View File
@@ -194,18 +194,74 @@ pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool {
/// anchored at the sensor origin.
pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<RawImage> {
let tiff = Tiff::parse(bytes)?;
let ifd0 = &tiff.ifds[0];
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?;
decode_ifd(bytes, &tiff, raw, raw, mode, format)
}
/// The smallest reduced-resolution copy of a DNG's LinearRaw image whose default crop still has a long edge of
/// `min_long` px: Lightroom keeps 256, 512 and 2048 px copies beside the full image of its HDR and panorama
/// merges, so a thumbnail decodes a 512 px copy instead of a 24k × 7k image (about 19 s). Only a copy that the main
/// image's tags describe as well: same samples per pixel and aspect ratio (within 1 %), and a main image without
/// opcode lists, a linearization table or black-level deltas (all in full-size pixel coordinates).
fn reduced_ifd<'a>(tiff: &'a Tiff, main: &Ifd, min_long: usize) -> Option<&'a Ifd> {
if main.u16(t::PHOTOMETRIC) != Some(photometric::LINEAR_RAW) {
return None;
}
let full_size_tags =
[t::OPCODE_LIST_1, t::OPCODE_LIST_2, t::OPCODE_LIST_3, t::LINEARIZATION_TABLE, t::BLACK_LEVEL_DELTA_H, t::BLACK_LEVEL_DELTA_V];
if full_size_tags.iter().any(|&tag| main.contains(tag)) {
return None;
}
let (mw, mh) = (main.u64(t::IMAGE_WIDTH)?, main.u64(t::IMAGE_LENGTH)?);
if mw == 0 || mh == 0 {
return None;
}
let crop_long = match main.f64s(t::DEFAULT_CROP_SIZE).as_deref() {
Some([w, h]) if w.is_finite() && h.is_finite() && *w >= 1.0 && *h >= 1.0 => w.max(*h).min(mw.max(mh) as f64),
_ => mw.max(mh) as f64,
};
let cpp = main.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1);
tiff.all_ifds()
.into_iter()
.filter(|i| i.u32(t::NEW_SUBFILE_TYPE) == Some(1) && i.u16(t::PHOTOMETRIC) == Some(photometric::LINEAR_RAW))
.filter(|i| i.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1) == cpp)
.filter_map(|i| {
let (w, h) = (i.u64(t::IMAGE_WIDTH)?, i.u64(t::IMAGE_LENGTH)?);
let (a, b) = (w.checked_mul(mh)?, h.checked_mul(mw)?);
let same_aspect = w > 0 && h > 0 && a.abs_diff(b).saturating_mul(100) <= a;
let covers = crop_long * w as f64 / mw as f64 >= min_long as f64;
(same_aspect && covers && w < mw && h < mh).then_some((i, w))
})
.min_by_key(|&(_, w)| w)
.map(|(i, _)| i)
}
/// [`decode`] from the smallest reduced-resolution copy of the raw image that covers `min_long` ([`reduced_ifd`]):
/// the main image's levels, colour and metadata, its active area and default crop scaled to the copy. `None` when the
/// file keeps no such copy.
pub(crate) fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
let tiff = Tiff::parse(bytes)?;
let Some(main) = raw_ifd(&tiff) else { return Ok(None) };
let Some(small) = reduced_ifd(&tiff, main, min_long) else { return Ok(None) };
decode_ifd(bytes, &tiff, small, main, Mode::Full, RawFormat::Dng).map(Some)
}
/// [`decode_as`] of the samples in `pixels`, described by the raw IFD `main`: the same IFD, or (see
/// [`decode_reduced`]) a reduced-resolution copy of it, whose own level tags win and whose geometry is `main`'s
/// scaled to its size.
fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, format: RawFormat) -> Result<RawImage> {
let ifd0 = &tiff.ifds[0];
let raw = main;
// GoPro's GPR: a DNG whose raw data is VC-5 coded, which is not decoded (yet). Named here, before
// the generic "TIFF compression 9" of the chunk decoder. Header mode still describes the file
// (a GPR has no embedded preview, and a library keeps the photo with this reason shown, as it
// does for any file whose pixels fail to decode, rather than refusing to import it).
if mode == Mode::Full
&& let Some(c) = raw.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
&& let Some(c) = pixels.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
{
return Err(RawError::Unsupported(format!("DNG compression {c} (GoPro VC-5) is not decoded yet")));
}
let info = raw.image()?;
let info = pixels.image()?;
let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize);
// one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511)
if !matches!(cpp, 1 | 3 | 4) {
@@ -217,22 +273,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
// DNG 1.7 interleaved storage: the raster is a permutation of the mosaic, so undo it before
// anything reads a sample as a pixel.
if mode == Mode::Full {
let factor = |tag| raw.u16(tag).map(usize::from).unwrap_or(1);
let factor = |tag| pixels.u16(tag).map(usize::from).unwrap_or(1);
deinterleave(&mut data, w, h, cpp, factor(t::ROW_INTERLEAVE_FACTOR), factor(t::COLUMN_INTERLEAVE_FACTOR))?;
}
// linearization table (integer data only)
let mut linearized = false;
if let (RawData::U16(v), Some(table)) = (&mut data, raw.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
if let (RawData::U16(v), Some(table)) = (&mut data, pixels.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
let table: Vec<u16> = table.iter().map(|&x| x.min(65535) as u16).collect();
let last = table.len() - 1;
v.iter_mut().for_each(|s| *s = table[(*s as usize).min(last)]);
linearized = true;
}
// a reduced copy's own levels, where it states them
let levels = |tag| if pixels.contains(tag) { pixels } else { raw };
let float = matches!(data, RawData::F32(_));
let default_white = if float { 1.0 } else { ((1u64 << bits.min(16)) - 1) as f32 };
let mut white: Vec<f32> = raw.f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
let mut white: Vec<f32> =
levels(t::WHITE_LEVEL).f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
if white.is_empty() {
white = vec![default_white];
}
@@ -247,14 +306,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
}
}
let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
let dim = levels(t::BLACK_LEVEL).u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
let (br, bc) = match dim.as_slice() {
[r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize),
_ => (1, 1),
};
// (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place)
let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0);
let mut values: Vec<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
let mut values: Vec<f32> = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
if values.len() != br * bc * cpp {
values = vec![values.first().copied().unwrap_or(0.0)];
}
@@ -263,11 +322,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
let black =
BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) };
// geometry in the main image's pixels (a reduced copy's is scaled below)
let (full_w, full_h) = if std::ptr::eq(pixels, raw) {
(w, h)
} else {
(raw.u64(t::IMAGE_WIDTH).unwrap_or(0) as usize, raw.u64(t::IMAGE_LENGTH).unwrap_or(0) as usize)
};
if full_w == 0 || full_h == 0 {
return Err(RawError::Corrupt("raw image IFD without a size".into()));
}
let active_area = match raw.u64s(t::ACTIVE_AREA).as_deref() {
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= h && (*right as usize) <= w => {
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= full_h && (*right as usize) <= full_w => {
Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize)
}
_ => Rect::new(0, 0, w, h),
_ => Rect::new(0, 0, full_w, full_h),
};
let crop = match (raw.f64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.f64s(t::DEFAULT_CROP_SIZE).as_deref()) {
(Some([x, y]), Some([cw, ch])) if *cw >= 1.0 && *ch >= 1.0 && *x >= 0.0 && *y >= 0.0 => {
@@ -275,6 +343,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
}
_ => Rect::new(0, 0, active_area.width, active_area.height),
};
// the developed size, which the metadata reports whatever copy the samples come from
let developed = (crop.width, crop.height);
let (active_area, crop) = if (full_w, full_h) == (w, h) {
(active_area, crop)
} else {
let (sx, sy) = (w as f64 / full_w as f64, h as f64 / full_h as f64);
let scale = |r: Rect| {
let (x, y) = ((r.x as f64 * sx).round() as usize, (r.y as f64 * sy).round() as usize);
let (x1, y1) = (((r.x + r.width) as f64 * sx).round() as usize, ((r.y + r.height) as f64 * sy).round() as usize);
Rect::new(x, y, x1.saturating_sub(x).max(1), y1.saturating_sub(y).max(1))
};
let area = scale(active_area).clipped(w, h);
(area, scale(crop).clipped(area.width, area.height))
};
let cfa = if info.photometric == photometric::CFA {
if raw.u16(t::CFA_LAYOUT).unwrap_or(1) != 1 {
@@ -319,9 +401,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
let opcodes =
crate::OpcodeLists { list1: opcode_list(t::OPCODE_LIST_1), list2: opcode_list(t::OPCODE_LIST_2), list3: opcode_list(t::OPCODE_LIST_3) };
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
let mut metadata = lightcraft_meta::from_tiff(tiff);
metadata.width = Some(developed.0 as u32);
metadata.height = Some(developed.1 as u32);
let img = RawImage {
format,
width: w,
+92
View File
@@ -0,0 +1,92 @@
//! Built-in white-balance presets for cameras whose raws carry neither colour matrices nor a preset table, feeding
//! the Kelvin scale of [`crate::kelvin_presets`].
//!
//! Nikon NEFs store only the as-shot multipliers (`WB_RBLevels`) and, in the maker note, the colour temperature the
//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x004f`: tag name from the
//! ExifTool Nikon tag documentation, read with exiftool run as a black-box tool on the files). A body's points are
//! the as-shot multipliers of its own photos shot at one preset each, placed at the temperature the camera itself
//! wrote for that preset. Presets off the daylight locus (fluorescent, flash) and shots with a fine-tune shift are
//! left out.
//!
//! Sources: own photos of each body listed (a reporter's samples, never committed), the camera's own stated
//! temperatures, and the ExifTool Nikon tag-name documentation (prose). No matrices or third-party data.
use crate::kelvin_presets::KelvinPresets;
/// One body: its make's first word, its Exif model without the make, and five (Kelvin, R/G, B/G) points.
struct Builtin {
make: &'static str,
model: &'static str,
points: [(f32, f32, f32); 5],
}
const BUILTINS: &[Builtin] = &[
// Nikon Z5II (issue emmertex/lightcraft-classic#157): Incandescent 3000 K, Direct sunlight 5210 K, the Kelvin
// setting at 5500 K, Cloudy 5990 K, Shade 8000 K (the temperatures the camera writes in `ColorTemperatureAuto`).
// Natural Auto (the camera wrote 5030 K) reads back at 5036 K; Flash (5410 K nominal) at about 6500 K.
Builtin {
make: "NIKON",
model: "Z5_2",
points: [
(3000.0, 1.138_671_9, 2.083_984_4),
(5210.0, 1.726_562_5, 1.335_937_5),
(5500.0, 1.771_484_4, 1.281_25),
(5990.0, 1.839_843_8, 1.212_890_6),
(8000.0, 2.097_656_3, 1.089_843_8),
],
},
];
fn find_builtin(make: Option<&str>, model: &str) -> Option<&'static Builtin> {
let model = model.trim();
BUILTINS.iter().find(|b| {
let n = b.make.len();
let bare = match (model.get(..n), model.get(n..)) {
(Some(prefix), Some(rest)) if prefix.eq_ignore_ascii_case(b.make) => rest.trim_start(),
_ => model,
};
make.is_none_or(|m| m.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case(b.make))) && bare.eq_ignore_ascii_case(b.model)
})
}
/// Whether this Exif make and model has built-in presets.
pub fn covers(make: Option<&str>, model: &str) -> bool {
find_builtin(make, model).is_some()
}
/// The built-in presets of this Exif make and model, if any.
pub fn find(make: Option<&str>, model: &str) -> Option<KelvinPresets> {
let b = find_builtin(make, model)?;
KelvinPresets::new(b.points.map(|(k, r, b)| (k, [r, 1.0, b])))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_builtin_makes_a_curve() {
for b in BUILTINS {
assert!(find(Some(b.make), b.model).is_some(), "{} {}", b.make, b.model);
}
}
#[test]
fn z5ii_reads_its_own_shots_near_the_camera_temperatures() {
let p = find(Some("NIKON CORPORATION"), "NIKON Z5_2").unwrap();
// Natural Auto, not on the curve: the camera wrote 5030 K for it
let (t, tint) = p.temp_tint([1.695_312_5, 1.0, 1.373_046_9]).unwrap();
assert!((1e6 / t - 1e6 / 5030.0).abs() < 10.0 && tint.abs() < 5.0, "{t} {tint}");
// Flash (nominally 5410 K) sits near the curve on the daylight side
let (t, tint) = p.temp_tint([1.916_015_6, 1.0, 1.164_062_5]).unwrap();
assert!((5000.0..7500.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}");
// the 5990 K Kelvin shot with a green-magenta fine-tune shift: daylight, clearly tinted
let (t, tint) = p.temp_tint([1.531_25, 1.0, 1.074_218_8]).unwrap();
assert!((4000.0..7500.0).contains(&t) && tint.abs() > 20.0, "{t} {tint}");
}
#[test]
fn matching() {
assert!(covers(Some("NIKON CORPORATION"), "NIKON Z5_2") && covers(None, "Z5_2") && covers(Some("Nikon"), "nikon z5_2"));
assert!(!covers(Some("NIKON CORPORATION"), "NIKON Z5") && !covers(Some("SONY"), "Z5_2") && !covers(None, "") && !covers(None, "NIK"));
}
}
+121 -28
View File
@@ -8,8 +8,13 @@
//! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the
//! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730).
//!
//! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see
//! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way,
//! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the
//! curve reads the as-shot levels within 5 % of the camera's own `ColorTempAsShot`.
//!
//! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]):
//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments
//! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments
//! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to
//! 8500 K within ±3 %), so the segments add no visible kinks.
//! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled
@@ -35,32 +40,54 @@ const MIN_K: f64 = 2000.0;
const MAX_K: f64 = 50000.0;
const MAX_TINT: f64 = 150.0;
/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own
/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A
/// camera with measured matrices (`crate::spectral`) is read through those instead.
const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"];
/// Models whose white balance is edited in Kelvin through their presets, as (brand, Exif model without the brand),
/// each checked to carry them: the Sony bodies on 80–150 own photos each, or the public corpus samples for the
/// ILCE-3500 and ILCE-7S; the Canon PowerShot G1 X Mark III on a reporter's nine photos, one per white-balance
/// setting (issue #157 of a downstream fork). A camera with measured matrices (`crate::spectral`) is read through
/// those instead.
const MODELS: &[(&str, &str)] = &[
("SONY", "DSC-RX10"),
("SONY", "ILCA-77M2"),
("SONY", "ILCA-99M2"),
("SONY", "ILCE-3500"),
("SONY", "ILCE-7S"),
("SONY", "SLT-A33"),
("CANON", "PowerShot G1 X Mark III"),
];
/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first).
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or
/// built-in ones ([`crate::kelvin_builtin`]).
pub fn covers(make: Option<&str>, model: &str) -> bool {
if crate::kelvin_builtin::covers(make, model) {
return true;
}
let model = model.trim();
let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model);
make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY")))
&& MODELS.iter().any(|m| m.eq_ignore_ascii_case(model))
let brand = make.and_then(|m| m.split_whitespace().next());
MODELS.iter().any(|(b, m)| {
let bare = model
.get(..b.len())
.zip(model.get(b.len()..))
.filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' '))
.map_or(model, |(_, rest)| rest.trim_start());
brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m)
})
}
/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`).
pub fn covers_camera(camera: &str) -> bool {
match camera.split_once(char::is_whitespace) {
Some((make, model)) => covers(Some(make), model),
None => covers(None, camera),
}
let words: Vec<&str> = camera.split_whitespace().collect();
// the make can be several words ("NIKON CORPORATION"), so try every split, as `spectral::find_camera` does
(1..words.len()).any(|i| covers(Some(&words[..i].join(" ")), &words[i..].join(" "))) || covers(None, camera)
}
/// A camera's multipliers at five stated temperatures, ascending, green = 1.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
/// Most points a curve keeps (Canon writes about seven at a stated temperature, Sony five).
const MAX_POINTS: usize = 32;
/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KelvinPresets {
points: [(f32, [f32; 3]); 5],
points: Vec<(f32, [f32; 3])>,
}
fn usable(m: [f32; 3]) -> bool {
@@ -76,15 +103,36 @@ fn locus_uv(t: f64) -> (f64, f64) {
}
impl KelvinPresets {
/// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive
/// multipliers, red rising and blue falling from one preset to the next.
pub fn new(points: [(f32, [f32; 3]); 5]) -> Option<KelvinPresets> {
let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m))
/// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to
/// [`MAX_POINTS`] of them, positive multipliers, red rising and blue falling from one preset to the next.
pub fn new(points: impl IntoIterator<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect();
let ok = (3..=MAX_POINTS).contains(&points.len())
&& points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m))
&& points.windows(2).all(|w| {
let (a, b) = (log_point(w[0].1), log_point(w[1].1));
w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1]
});
ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) })
ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() })
}
/// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …):
/// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back
/// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`].
pub fn from_unordered(points: impl IntoIterator<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len());
for p in pts {
let fits = kept.last().is_none_or(|q| {
let (a, b) = (log_point(q.1), log_point(p.1));
q.0 < p.0 && b[0] > a[0] && b[1] < a[1]
});
if fits {
kept.push(p);
}
}
KelvinPresets::new(kept)
}
/// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable
@@ -94,13 +142,17 @@ impl KelvinPresets {
return None;
}
let q = log_point(wb_multipliers);
let last = self.points.len() - 2;
let last = self.points.len().saturating_sub(2);
let mut best: Option<(f64, f64, f64)> = None;
for (i, w) in self.points.windows(2).enumerate() {
let ((k0, m0), (k1, m1)) = (w[0], w[1]);
let (p0, p1) = (log_point(m0), log_point(m1));
let d = [p1[0] - p0[0], p1[1] - p0[1]];
let len = d[0].hypot(d[1]);
// only a deserialized curve can repeat a point
if !(len > 0.0 && len.is_finite()) {
continue;
}
let t = [d[0] / len, d[1] / len];
// the normal towards higher red and blue: a green white
let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] };
@@ -158,7 +210,7 @@ mod tests {
#[test]
fn a_preset_reads_as_its_own_temperature_with_no_tint() {
let p = a99m2();
for (k, m) in p.points {
for &(k, m) in &p.points {
let (t, tint) = p.temp_tint(m).unwrap();
assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}");
// the scale of the multipliers doesn't matter, only their ratios to green
@@ -200,26 +252,67 @@ mod tests {
assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None);
assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None);
assert_eq!(p.as_shot_white(None), None);
let mut pts = p.points;
let mut pts = p.points.clone();
pts.swap(1, 2);
assert_eq!(KelvinPresets::new(pts), None, "out of order");
let mut pts = p.points;
let mut pts = p.points.clone();
pts[4].1 = pts[3].1;
assert_eq!(KelvinPresets::new(pts), None, "no step");
let mut pts = p.points;
let mut pts = p.points.clone();
pts[0].1[0] = 0.0;
assert_eq!(KelvinPresets::new(pts), None);
assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None);
assert_eq!(KelvinPresets::new(p.points[..2].to_vec()), None, "too few");
let with = ColorData { kelvin_presets: Some(p), ..Default::default() };
assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some());
}
/// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the
/// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back.
#[test]
fn a_curve_from_presets_in_any_order() {
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
let p = KelvinPresets::from_unordered([
(5651.0, g(1418.0, 1357.0)),
(7505.0, g(1653.0, 1171.0)),
(6594.0, g(1533.0, 1259.0)),
(3174.0, g(963.0, 2016.0)),
(5651.0, g(1418.0, 1357.0)),
(8000.0, g(1400.0, 1400.0)),
(8529.0, g(1807.0, 1080.0)),
(f32::NAN, g(1000.0, 1000.0)),
])
.unwrap();
assert_eq!(p.points.len(), 5);
assert!(p.points.windows(2).all(|w| w[0].0 < w[1].0));
let (t, tint) = p.temp_tint(g(1653.0, 1171.0)).unwrap();
assert!((t - 7505.0).abs() < 1.0 && tint.abs() < 0.1, "{t} {tint}");
assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None);
assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None);
// a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero
let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] };
assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None);
assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None);
}
#[test]
fn covered_models() {
for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] {
assert!(covers_camera("NIKON CORPORATION NIKON Z5_2") && covers_camera("NIKON Z5_2") && !covers_camera("NIKON CORPORATION NIKON Z6"));
for c in [
"SONY ILCA-99M2",
"SONY DSC-RX10",
"SONY ILCA-77M2",
"SONY SLT-A33",
"ILCA-99M2",
"Sony ilca-99m2",
"Canon Canon PowerShot G1 X Mark III",
"Canon PowerShot G1 X Mark III",
] {
assert!(covers_camera(c), "{c}");
}
for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] {
for c in
["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY", "Canon Canon PowerShot G1 X Mark II", "SONY PowerShot G1 X Mark III"]
{
assert!(!covers_camera(c), "{c}");
}
assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2"));
+3 -1
View File
@@ -137,7 +137,7 @@ pub enum SonySettingMeaning {
/// Whether a component is applied, and if not, why.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonyLensStatus {
/// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing).
/// Converted into the image's `OpcodeList3` (ILCE-7RM4A, ILCE-7RM6 or eligible full-frame ILCE-7CR Bayer distortion).
Applied,
/// No usable table.
NoTable,
@@ -149,6 +149,8 @@ pub enum SonyLensStatus {
/// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a
/// table found only in the `SR2SubIFD`.
NotValidatedMode,
/// A7CR distortion is Off, unavailable, missing or unknown. Its uncorrected JPEG and render keep the same geometry.
CameraCorrectionDisabled,
/// Validated scope, but the table is all zeros: a valid no-op, nothing to apply.
Neutral,
/// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate
+81 -4
View File
@@ -34,6 +34,7 @@ pub mod gaintable;
pub mod highlight;
#[cfg(feature = "jxl")]
mod jxl;
pub mod kelvin_builtin;
pub mod kelvin_presets;
pub mod lens_metadata;
pub mod ljpeg;
@@ -113,7 +114,7 @@ pub enum RawFormat {
Srw,
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
Crw,
/// Minolta MRW (`\0MRM` block container).
/// Minolta MRW (`\0MRM` block container, `vendor::mrw`).
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
@@ -148,6 +149,7 @@ impl RawFormat {
| RawFormat::CfaTiff
| RawFormat::Srw
| RawFormat::KodakDcs
| RawFormat::Mrw
| RawFormat::Erf
| RawFormat::Mef
)
@@ -368,6 +370,17 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage> {
decode_with(bytes, Mode::Full)
}
/// [`decode`] at a smaller size, for a picture whose default crop needs a long edge of only `min_long` px: a DNG
/// that keeps reduced-resolution copies of its LinearRaw image (Lightroom's HDR and panorama merges keep 256 to 2048
/// px ones) decodes the smallest that covers it. The image has the full one's levels, colour and metadata; its
/// active area and crop are scaled to its size. `Ok(None)` when the file keeps no such copy: decode it whole.
pub fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
if probe(bytes) != Some(RawFormat::Dng) {
return Ok(None);
}
Ok(dng::decode_reduced(bytes, min_long)?.map(lift_clipped))
}
/// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA,
/// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read
/// from the headers without decompressing the pixel data, for imports. Equal to
@@ -409,6 +422,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped),
RawFormat::Srw => vendor::srw::decode(bytes, mode),
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
RawFormat::Mrw => vendor::mrw::decode(bytes, mode),
RawFormat::Erf => vendor::erf::decode(bytes, mode),
RawFormat::Mef => vendor::mef::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
@@ -656,7 +670,7 @@ pub struct ColorData {
/// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`).
#[serde(default)]
pub profile: profile::ProfileLook,
/// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour
/// The camera's white-balance presets at stated temperatures (Sony ARW, Canon CR2), which give a raw without colour
/// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it.
#[serde(default)]
pub kelvin_presets: Option<kelvin_presets::KelvinPresets>,
@@ -1141,10 +1155,10 @@ mod tests {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
for f in [RawFormat::Crw, RawFormat::X3f] {
assert!(!f.is_supported());
}
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
}
@@ -1197,6 +1211,69 @@ mod tests {
assert_eq!((info.width, info.height), (32, 16));
}
/// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default
/// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it.
fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec<u8> {
let linear = |subfile: u32, w: u32, h: u32, sample: u16| {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768]));
let samples: Vec<u8> = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] });
ifd
};
let mut main = linear(0, w, h, 1000);
main.set(t::BLACK_LEVEL, Value::Short(vec![100]));
main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2]));
main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4]));
for (tag, v) in main_tags {
main.set(*tag, v.clone());
}
let mut ifd0 = rgb_ifd(4, 2);
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0]));
ifd0.set(t::MAKE, Value::Ascii("DJI".into()));
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
ifd0.add_sub_ifd(main);
for &(cw, ch) in copies {
ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16));
}
write(&[ifd0])
}
/// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the
/// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`.
#[test]
fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() {
let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]);
let full = decode(&file).unwrap();
assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28)));
// a 12 px picture: the 16 px copy (its crop is 14 px long)
let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px");
assert_eq!((small.width, small.height), (16, 8));
assert_eq!(small.crop, Rect::new(1, 1, 14, 7));
let RawData::U16(v) = &small.data else { panic!("16-bit samples") };
assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples");
assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black");
assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's");
// 20 px: the 32 px copy (28 px); 40 px: none covers it
assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32));
assert!(decode_reduced(&file, 40).unwrap().is_none());
// a different aspect ratio is not a copy of the image
assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none());
// opcodes are in full-size pixels
let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]);
assert!(decode_reduced(&opcodes, 8).unwrap().is_none());
// not a DNG
assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none());
}
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
#[test]
fn private_compression_99_is_a_raw() {
+2 -1
View File
@@ -713,7 +713,8 @@ mod tests {
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
// MRW decodes; its preview is still found by the scan
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
+241 -9
View File
@@ -1,6 +1,6 @@
//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of
//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one
//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline.
//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated
//! ILCE-7RM4A and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline.
//!
//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and
//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags
@@ -34,13 +34,18 @@ const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x798
/// TIFF field types.
const SHORT: u16 = 3;
const SSHORT: u16 = 8;
/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A
/// are distinct models, not interchangeable aliases.
/// Models using the validated A7R IVA conversion. The A7CR uses a separate conversion.
/// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.
const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"];
/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one.
const MAX_ENTRIES: usize = 1 << 16;
const SAMPLES: usize = 1024;
const MAX_ERROR: f64 = 0.0005;
/// Empirical A7CR unit, fixed on four FE 24-50mm files before checking the other files and lenses.
/// The camera-JPEG measurements and remaining coverage limits are in docs/sony-lens-corrections.md.
const A7CR_UNIT: f64 = 15559.300457825264;
const A7CR_FRAME: (usize, usize) = (9504, 6336);
const A7CR_APPROXIMATION_PX: f64 = 0.25;
#[derive(Clone, Copy)]
struct Tags {
@@ -307,11 +312,17 @@ pub(super) fn corrections(
// Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer
// image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags
// select an aspect crop).
let validated_model = DISTORTION_MODELS.contains(&model);
let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd);
let a7cr = model == "ILCE-7CR";
let validated_model = DISTORTION_MODELS.contains(&model) || a7cr;
let in_mode = !geometry.linear_ycbcr
&& geometry.native_3_2
&& distortion_table.source == Some(SonySource::RawIfd)
&& (!a7cr || ((geometry.reference.width, geometry.reference.height) == A7CR_FRAME && geometry.image_crop == geometry.reference));
let a7cr_auto = distortion_setting.value.is_some_and(|s| matches!(s.meaning, SonySettingMeaning::Auto | SonySettingMeaning::ForcedAuto));
let (w, h) = (geometry.storage_width, geometry.storage_height);
let warp = match &distortion_table.value {
Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference),
Some(table) if in_mode && a7cr && a7cr_auto => a7cr_distortion(table, Rect::new(0, 0, w, h), geometry.reference),
Some(table) if in_mode && !a7cr => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference),
_ => None,
};
let distortion_status = match &distortion_table.value {
@@ -319,6 +330,7 @@ pub(super) fn corrections(
Some(_) if warp.is_some() => SonyLensStatus::Applied,
Some(_) if !validated_model => SonyLensStatus::NotValidatedModel,
Some(_) if !in_mode => SonyLensStatus::NotValidatedMode,
Some(_) if a7cr && !a7cr_auto => SonyLensStatus::CameraCorrectionDisabled,
Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral,
Some(_) => SonyLensStatus::Rejected,
};
@@ -334,6 +346,94 @@ pub(super) fn corrections(
(Some(record), warp)
}
/// A7CR camera-JPEG convention, measured independently of the ILCE-7RM4A conversion below.
/// For output radius rho, the corrected radius is rho / zoom. The table gives source radius at that
/// corrected radius. Its knots use the full-frame diagonal, not storage padding. Off/unknown settings
/// and unvalidated capture modes are excluded by the caller.
fn a7cr_distortion(table: &[i16; 16], active: Rect, crop: Rect) -> Option<Opcode> {
if (crop.width, crop.height) != A7CR_FRAME
|| crop != crop.clipped(active.width, active.height)
|| table.iter().all(|v| *v == 0)
|| table.iter().any(|v| !(-4096..=4096).contains(v))
{
return None;
}
let (cw, ch) = (crop.width as f64, crop.height as f64);
let spacing = cw.hypot(ch) / 30.0;
let source = |r: f64| {
let at = r / spacing;
let i = (at.floor() as usize).min(14);
let (a, b) = (f64::from(table[i]), f64::from(table[i + 1]));
r * (1.0 + (a + (b - a) * (at - i as f64)) / A7CR_UNIT)
};
let border_min = cw.min(ch) / 2.0;
let border_max = cw.hypot(ch) / 2.0;
let border_ratio = |zoom: f64| {
(0..=4096)
.map(|j| {
let r = border_min + (border_max - border_min) * j as f64 / 4096.0;
source(r / zoom) / r
})
.fold(0.0, f64::max)
};
let (mut lo, mut hi) = (0.5, 2.0);
if border_ratio(lo) <= 1.0 || border_ratio(hi) > 1.0 {
return None;
}
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if border_ratio(mid) > 1.0 {
lo = mid;
} else {
hi = mid;
}
}
let zoom = (lo + hi) / 2.0;
let radius = (cw - 1.0).hypot(ch - 1.0) / 2.0;
let basis = |r: f64| {
let r2 = r * r;
[r, r * r2, r * r2 * r2, r * r2 * r2 * r2]
};
let mut matrix = [[0.0; 4]; 4];
let mut rhs = [0.0; 4];
let mut previous = -1.0;
for j in 0..=SAMPLES {
let r = j as f64 / SAMPLES as f64;
let mapped = source(r * radius / zoom) / radius;
if !mapped.is_finite() || mapped <= previous {
return None;
}
previous = mapped;
let terms = basis(r);
for (i, &a) in terms.iter().enumerate() {
rhs[i] += a * mapped;
for (k, &b) in terms.iter().enumerate() {
matrix[i][k] += a * b;
}
}
}
let mut k = solve4(matrix, rhs)?;
// Check every displayed radius, separately from the empirical camera-JPEG validation.
for j in 0..=32768 {
let r = j as f64 / 32768.0;
let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::<f64>();
let r2 = r * r;
let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3]));
let error_px = (fit * radius - source(r * radius / zoom)).abs() * 1440.0 / cw;
if !error_px.is_finite() || error_px > A7CR_APPROXIMATION_PX || derivative <= 0.0 {
return None;
}
}
let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0);
let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0);
let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy));
let ratio2 = (farthest / radius).powi(2);
for (i, coefficient) in k.iter_mut().enumerate() {
*coefficient *= ratio2.powi(i as i32);
}
Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] })
}
/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated
/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected.
/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it
@@ -454,6 +554,8 @@ mod tests {
/// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8).
const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328];
const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44];
/// Public issue #1300, FE 16-25mm F2.8 G at 16 mm, raw-IFD samples.
const A7CR_WIDE: [i16; 16] = [0, -14, -59, -130, -226, -341, -472, -615, -767, -924, -1084, -1242, -1397, -1549, -1696, -1839];
/// A CA table with negative and positive samples and two different curves.
fn ca() -> [i16; 32] {
@@ -630,7 +732,7 @@ mod tests {
for order in [Little, Big] {
for bigtiff in [false, true] {
for (model, warps, distortion_status) in
[("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)]
[("ILCE-7CR", 0, SonyLensStatus::NotValidatedMode), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)]
{
let case = format!("{model} {order:?} bigtiff={bigtiff}");
let mut arw = Arw::new(model, order);
@@ -1030,7 +1132,7 @@ mod tests {
("ILCE-7RM4A", [i16::MIN; 16], Rejected),
("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected),
("ILCE-7RM4", TELE, NotValidatedModel),
("ILCE-7CR", [0; 16], NotValidatedModel),
("ILCE-7CR", [0; 16], NotValidatedMode),
("", TELE, NotValidatedModel),
] {
let mut arw = Arw::new(model, order);
@@ -1073,6 +1175,136 @@ mod tests {
}
}
fn a7cr_geometry(storage: (usize, usize)) -> SonyLensGeometry {
let crop = Rect::new(32, 20, 9504, 6336);
SonyLensGeometry {
storage_width: storage.0,
storage_height: storage.1,
sony_crop: Some(crop),
default_crop: Some(crop),
image_crop: crop,
reference: crop,
compression: 7,
linear_ycbcr: false,
native_3_2: true,
}
}
#[test]
fn a7cr_requires_an_enabled_setting_and_validated_full_frame_mode() {
use SonyLensStatus::*;
for order in [Little, Big] {
for (setting, status) in
[(0, CameraCorrectionDisabled), (1, Applied), (17, Applied), (255, CameraCorrectionDisabled), (2, CameraCorrectionDisabled)]
{
let mut arw = Arw::new("ILCE-7CR", order);
arw.plain = plain(&A7CR_WIDE, setting);
arw.sr2 = None;
let bytes = arw.build();
let tiff = Tiff::parse(&bytes).unwrap();
let run = |model: &str, geometry: SonyLensGeometry| {
let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, model, geometry);
(record.unwrap().distortion.status, warp.is_some())
};
let geometry = a7cr_geometry((9728, 6656));
assert_eq!(run("ILCE-7CR", geometry.clone()), (status, status == Applied));
for model in ["ILCE-7CR ", "ILCE-7CR unknown", "ILCE-7C", "ILCE-7CM2", "ILCE-7RM4", "ILCE-7RM6"] {
assert_eq!(run(model, geometry.clone()), (NotValidatedModel, false), "{model}");
}
let mut linear = geometry.clone();
linear.linear_ycbcr = true;
let mut apsc = geometry.clone();
apsc.reference = Rect::new(32, 20, 6240, 4160);
apsc.image_crop = apsc.reference;
let mut aspect = geometry.clone();
aspect.image_crop.height = 5346;
aspect.native_3_2 = false;
let mut offset = geometry.clone();
offset.image_crop.x = 0;
for mode in [linear, apsc, aspect, offset] {
assert_eq!(run("ILCE-7CR", mode), (NotValidatedMode, false));
}
// Missing or malformed settings cannot enable the correction.
for value in [None, Some(Value::SShort(vec![-1])), Some(Value::Long(vec![17]))] {
arw.plain.retain(|(tag, _)| *tag != DISTORTION.setting.0);
if let Some(value) = value {
arw.plain.push((DISTORTION.setting.0, value));
}
let bytes = arw.build();
let tiff = Tiff::parse(&bytes).unwrap();
let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, "ILCE-7CR", geometry.clone());
assert_eq!(record.unwrap().distortion.status, CameraCorrectionDisabled);
assert!(warp.is_none());
}
}
}
}
#[test]
fn a7cr_storage_padding_does_not_change_the_optical_mapping() {
let crop = Rect::new(32, 20, 9504, 6336);
let mut maps = Vec::new();
for (w, h) in [(9600, 6376), (9728, 6656)] {
let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&A7CR_WIDE, Rect::new(0, 0, w, h), crop).unwrap() else {
panic!("warp")
};
let (cx, cy) = (center[0] * (w - 1) as f64, center[1] * (h - 1) as f64);
assert!((cx - 4783.5).abs() < 1e-10 && (cy - 3187.5).abs() < 1e-10);
let radius = cx.max((w - 1) as f64 - cx).hypot(cy.max((h - 1) as f64 - cy));
let k = planes[0];
maps.push(
(0..=100)
.map(|j| {
let r = j as f64 / 100.0 * 5700.0;
let r2 = (r / radius).powi(2);
r * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3])))
})
.collect::<Vec<_>>(),
);
}
for (a, b) in maps[0].iter().zip(&maps[1]) {
assert!((a - b).abs() < 1e-9);
}
for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] {
assert!(a7cr_distortion(&bad, Rect::new(0, 0, 9728, 6656), crop).is_none());
}
assert!(a7cr_distortion(&A7CR_WIDE, AREA, crop).is_none(), "crop outside storage");
assert!(a7cr_distortion(&A7CR_WIDE, AREA, Rect::new(0, 0, 6240, 4160)).is_none(), "APS-C");
}
#[test]
fn a7cr_matches_independently_measured_public_jpeg_geometry() {
// Green-channel feature fits, independent of the table, on the public FE 16-25mm (issue #1300)
// and FE 50mm files. Check only the supported radii, not extrapolated corners.
let prime = [-4, -1, 3, 6, 12, 17, 23, 30, 37, 43, 50, 58, 65, 71, 76, 84];
let cases = [
(A7CR_WIDE, [1.0606515126386147, -0.24282593993968085, 0.1667903749262344, -0.059882313562606454], 0.93),
(prime, [0.9951622479344596, 0.00741813858793482, -0.002474290073462703, -0.000235147000260114], 0.96),
];
let crop = Rect::new(32, 20, 9504, 6336);
let radius = 9503f64.hypot(6335.0) / 2.0;
for (table, reference, supported) in cases {
let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&table, Rect::new(0, 0, 9728, 6656), crop).unwrap() else {
panic!("warp")
};
let (cx, cy) = (center[0] * 9727.0, center[1] * 6655.0);
let normalization = cx.max(9727.0 - cx).hypot(cy.max(6655.0 - cy));
let k = planes[0];
let mut worst = 0.0f64;
for j in 0..=1000 {
let r = supported * j as f64 / 1000.0;
let rho = r * radius;
let r2 = (rho / normalization).powi(2);
let mapped = rho * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3])));
let expected = rho * (reference[0] + r * r * (reference[1] + r * r * (reference[2] + r * r * reference[3])));
worst = worst.max((mapped - expected).abs() * 1440.0 / 9504.0);
}
// This smooth feature fit has localization noise. The combined budget allows the
// 0.75 px camera-table residual plus the separately bounded 0.25 px polynomial error.
assert!(worst < 1.0, "camera-JPEG shape residual {worst} px at 1440");
}
}
#[test]
fn linear_ycbcr_files_are_decoded_but_not_corrected() {
// a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags
+113 -6
View File
@@ -19,6 +19,8 @@
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED
//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first.
//! - The same array lists the levels of the camera's white-balance presets, each with the colour temperature Canon
//! gives it ([`kelvin_presets_from_color_data`]): a Kelvin scale for a body without colour matrices.
//! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`].
mod sraw;
@@ -90,6 +92,41 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
None
}
/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word
/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and
/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the
/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at
/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool
/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool
/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after
/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II
/// (`ColorData8`: 5-word records). Other versions give no presets.
fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> {
match version {
-4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]),
13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]),
_ => None,
}
}
/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve;
/// `None` for an unknown version, a short array or presets that don't make a curve.
fn kelvin_presets_from_color_data(v: &[u64]) -> Option<crate::kelvin_presets::KelvinPresets> {
let version = *v.first()? as u16 as i16;
let rows = preset_rows(version)?;
let mut points = Vec::with_capacity(rows.len());
for (at, temp_at) in rows {
let q = v.get(at..at + 4)?;
let temp = *v.get(temp_at)? as f32;
if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32)
&& (1000.0..=20000.0).contains(&temp)
{
points.push((temp, wb));
}
}
crate::kelvin_presets::KelvinPresets::from_unordered(points)
}
/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that
/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in
/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy,
@@ -229,11 +266,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
}
}
let wb = mn
.as_ref()
.and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)))
.and_then(|v| wb_from_color_balance(&v))
.or_else(|| wb_from_wb_info(mn.as_ref()?));
let color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)));
let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data);
let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?));
let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB"));
let white = white_from_data(&active_rows(&data, width, active), prec as u32);
let black = match masked_columns(&data, width, active, white) {
@@ -256,7 +291,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active_area: active,
crop: Rect::new(0, 0, active.width, active.height),
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
color: ColorData::default(),
color: ColorData { kelvin_presets, ..Default::default() },
wb_multipliers: wb,
linearized: false,
pixel_aspect: 1.0,
@@ -507,6 +542,78 @@ pub(crate) mod tests {
}
}
/// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its
/// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63.
fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec<u64> {
let mut v = vec![0u64; 400];
v[0] = u64::from(version as u16);
for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) {
v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]);
v[temp_at] = k.into();
}
v
}
/// Issue #157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its
/// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which
/// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise.
#[test]
fn kelvin_presets_from_color_data_presets() {
let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)];
let v = color_data_with_presets(-4, &g1x3);
let p = kelvin_presets_from_color_data(&v).unwrap();
for (r, b, k) in g1x3 {
let (t, tint) = p.temp_tint([r as f32 / 1024.0, 1.0, b as f32 / 1024.0]).unwrap();
assert!((t - k as f64).abs() < 1.0 && tint.abs() < 0.1, "{k}: {t} {tint}");
}
// levels between Tungsten and Daylight: a temperature between them
let (t, _) = p.temp_tint([1264.0 / 1024.0, 1.0, 1537.0 / 1024.0]).unwrap();
assert!((3174.0..5651.0).contains(&t), "{t}");
let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)];
for version in [13, 14, 15] {
assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}");
}
// a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal
let mut odd = g1x3;
odd[4] = (1500, 1300, 9000);
assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some());
}
/// Unknown versions, short or garbled arrays give no presets and never panic.
#[test]
fn kelvin_presets_from_hostile_color_data() {
let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)];
let good = color_data_with_presets(-4, &g1x3);
let mut other = good.clone();
other[0] = u64::from(-3i16 as u16);
assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version");
assert!(kelvin_presets_from_color_data(&[]).is_none());
for len in [1, 96, 120, 157, 158] {
assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}");
}
let mut zeros = good.clone();
for at in [102, 118, 126] {
zeros[at] = 0;
}
assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left");
let mut seed = 0x2545_f491_4f6c_dd1du64;
for _ in 0..2000 {
let v: Vec<u64> = (0..200)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 }
})
.collect();
let _ = kelvin_presets_from_color_data(&v);
}
// through a whole decode, ColorData as UNDEFINED bytes
let bytes: Vec<u8> = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect();
assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some());
assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none());
}
/// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's
/// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0.
#[test]
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more