Compare commits

..
Author SHA1 Message Date
Brandon Thomas d43dd1fc8f Merge PR #1778: Model downloads: Time out and cancel the name lookup (#1532) 2026-10-18 10:05:47 -04:00
Brandon Thomas 6f5d662df3 Merge PR #2235: Georgian: a symbol Noto Sans Georgian cannot paint 2026-10-18 09:53:40 -04:00
Brandon ThomasandClaude Opus 5.5 70265ed17a Batch 42: #1893's heap test passes the Copy DecodeOptions without clone (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:53:39 -04:00
Michael Anderson 19008bdee9 Test cancelling a name lookup while it waits 2026-10-18 08:36:24 -05:00
Brandon Thomas 56c9b49634 Merge PR #1919: Hasselblad FFF: colour matrix from the embedded 3FR header
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
2026-10-18 09:22:57 -04:00
Brandon Thomas 2c60d2ab27 Merge PR #1778: Model downloads: Time out and cancel the name lookup (#1532) 2026-10-18 09:22:41 -04:00
Brandon Thomas 4b6db59ade Merge PR #1903: LRC-PRINT-PAGE: Page Options print page numbers, page info and crop marks (gap 15)
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-18 09:22:40 -04:00
Brandon ThomasandClaude Opus 5.5 cbdbd50f84 Merge main into #1919 (main already dropped the drop(write) line)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:21:51 -04:00
Brandon ThomasandClaude Opus 5.5 fe7edd7b26 Model downloads: the What's New note gets its own heading
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:21:34 -04:00
Brandon ThomasandClaude Opus 5.5 1e4aa93d58 LRC-PRINT-PAGE: the preview draws no Page Options for a package, as the print doesn't
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:21:12 -04:00
Brandon Thomas 7614fb14f1 Merge PR #1902: docs: refresh README features, counts and setup requirements 2026-10-18 09:20:46 -04:00
Brandon Thomas 2983a40385 Merge PR #1960: Merged develop settings are kept in range, as XMP import keeps them 2026-10-18 09:20:45 -04:00
Brandon Thomas 583e3ceefc Merge PR #2091: Reject trailing text after a bracketed IPv6 host in Url::parse 2026-10-18 09:20:43 -04:00
Brandon Thomas 5b66a4825e Merge PR #2150: Keep face index records aligned after a torn or failed append 2026-10-18 09:20:42 -04:00
Brandon Thomas dee8358c38 Merge PR #1838: Drop out-of-range GPS coordinates read from XMP 2026-10-18 09:20:40 -04:00
Brandon Thomas 8d41bf69f7 Merge PR #1894: HEIF: max_pixels on uncropped SPS sizes and grid totals (#1518) 2026-10-18 09:20:39 -04:00
Brandon Thomas f4e7d07e9b Merge PR #1989: TIFF: read strips and tiles larger than 128 MiB 2026-10-18 09:20:37 -04:00
Brandon Thomas 4e77935e6e Merge PR #2070: Show the applied profile when hovering it in the profile browser (#2057) 2026-10-18 09:20:36 -04:00
Brandon Thomas 7134099101 Merge PR #1691: LR-EXP-TYPE: omit redundant alpha from opaque 8-bit exports (#1670) 2026-10-18 09:20:34 -04:00
Brandon Thomas ad22d059db Merge PR #1844: fix(import): keep Lightroom luminance ranges open at white, report unmapped local sliders
# Conflicts:
#	contributors/people.toml
2026-10-18 09:20:33 -04:00
Brandon Thomas 29384a0404 Merge PR #1898: LR-MERGE-HEADLESS: merge dialogs remember the last settings per kind 2026-10-18 09:20:21 -04:00
Brandon Thomas 70100a34c5 Merge PR #2144: Strip XML 1.0-forbidden characters from XMP sidecar output 2026-10-18 09:20:19 -04:00
Brandon Thomas 363dbbdd9f Merge PR #2224: X-Trans CMOS 4 bodies take the X-T3's spectral matrices 2026-10-18 09:20:17 -04:00
Brandon Thomas 80e2d0e64a Merge PR #1884: AI denoise: support 102 MP Bayer raws (#1880) 2026-10-18 09:20:16 -04:00
Brandon Thomas d3b6266d07 Merge PR #1964: Contributors: add Marco Arnulfo
# Conflicts:
#	contributors/people.toml
2026-10-18 09:20:09 -04:00
Brandon Thomas c9f383b368 Merge PR #2040: Ignore zero or negative ISO when importing XMP 2026-10-18 09:19:45 -04:00
Brandon Thomas 389be0f6a9 Merge PR #2105: l10n: unify zh-hant library term and contact sheet wording 2026-10-18 09:19:43 -04:00
Brandon Thomas f819c8439b Merge PR #2151: Make TIFF chunk-length inference and header-plan merging fast 2026-10-18 09:19:42 -04:00
Brandon Thomas e6a7501adc Merge PR #1416: Localize Info Overlay mode-change notices 2026-10-18 09:19:29 -04:00
Brandon Thomas 0a0cb19011 Merge PR #1893: JPEG XL: check dimensions during loading & bound tracked allocations (#1517) 2026-10-18 09:19:27 -04:00
Brandon Thomas 7b65a6a59f Merge PR #1911: Contributors: credit nledford by real name 2026-10-18 09:19:26 -04:00
Brandon Thomas d43889145b Merge PR #2046: Start crop drags in the zone the cursor showed at the press 2026-10-18 09:19:24 -04:00
Brandon ThomasandClaude Opus 5.5 6985cfa54c Drop the README notes: toast language is not README material
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:18:48 -04:00
Brandon Thomas 592cb335cb Merge PR #1935: raw: alias Canon EOS R6 Mark II to EOS R6m2 2026-10-18 09:18:21 -04:00
Brandon Thomas d6a9a2e8f9 Merge PR #1845: Match f:/aperture:/focal: search tokens at f32 precision 2026-10-18 09:18:19 -04:00
Brandon Thomas 11308a5c8a Merge PR #2035: Reject out-of-range start in photo.rename and photo.renamePreview 2026-10-18 09:18:17 -04:00
Brandon Thomas 9bf08f6ce6 Merge PR #2083: Reject signed hex digits in #rrggbb colour parsing 2026-10-18 09:18:16 -04:00
Brandon Thomas 5c85bc3ee4 Merge PR #2149: Refuse catalog snapshots with an out-of-range seq 2026-10-18 09:18:14 -04:00
Hamza 0c3c500ea4 Georgian: a symbol Noto Sans Georgian cannot paint
`cargo xtask ci` fails with the craft-fonts input supplied, which CLAUDE.md says
releases always build with:

    ka in Proportional (faces: ["Noto Sans Georgian"]) lack glyphs: ✕

Two strings in ka.json use ✕ (U+2715). Noto Sans Georgian has no glyph for it
and egui's bundled fonts do not either, so a Georgian reader gets a blank where
the text describes the activity stack's stop control. Without the fonts input
those twenty tests skip, so a plain `cargo xtask ci` is green and this stayed
invisible.

The two Georgian values use × (U+00D7) instead: it reads the same at text size
and every face has it. The English keys are untouched — they are the source text
from docs/whats-new.md, and the test checks values, which is the right scope. An
English reader's font covers ✕; the problem is a translation carrying it into a
script whose face does not.

`translated_catalogs_do_not_use_missing_menu_triangles` already forbids ▸ and ▾
in locale JSON for the same reason, so ✕ joins them in one list. That matters
because ✕ is in the English source of those two strings: every language that
translates them next would hit the same failure, and a cheap test now catches it
instead of the font-dependent one.
2026-10-18 18:12:18 +05:00
NetterSilverandClaude Haiku 5.5 601ddce6ef Match f:/aperture:/focal: search tokens at f32 precision
Found while testing crates/catalog/src/query.rs:130

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:49:17 +02:00
Marco Arnulfo b9159b0fce TIFF: keep the default 128 MiB intermediate buffer for small pixel budgets 2026-10-18 13:18:49 +02:00
Yorgos S. 863adcac6f X-Trans CMOS 4 bodies take the X-T3's spectral matrices
An X-T30 RAF shot in Acros embeds a black-and-white camera JPEG, so the
per-file look fits only the tone curve, and the colour came from the
neutral "camera RGB ~ sRGB" fallback: a grey sky and muted colours. The
raw decode (lossless compressed) was correct.

Add a hand-kept SAME_SENSOR alias list next to the generated
rawtoaces-data table: the 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II,
X-Pro3, X100V, X-S10, X-E4) use the X-T3's measured matrices, and their
white balance reads in Kelvin through them.
2026-10-18 11:46:07 +02:00
adrdsouzaandCodex a8bc882092 Correct JPEG XL test target guard & tracked-budget claims (#1517)
Pitch-Impact: none

Co-authored-by: Codex <[email protected]>
2026-10-17 21:13:49 +05:30
adrdsouzaandCodex 9c4fce176f Record CI-generated codecs test dependency lockfile
Co-authored-by: Codex <[email protected]>
2026-10-17 20:50:59 +05:30
adrdsouzaandCodex 16beb4db40 Bound JPEG XL input feeds through the final frame (#1517)
Retain the pre-frame size check and allocation budget while avoiding untracked input-sized buffers. Add the reported heap regression and container/progressive compatibility checks.

Co-authored-by: Codex <[email protected]>
2026-10-17 20:38:18 +05:30
NetterSilverandClaude Haiku 5.5 f6d465c72c Make TIFF chunk-length inference and header-plan merging fast
Fixes #2128

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:50:59 +02:00
NetterSilverandClaude Haiku 5.5 fafa70724b Keep face index records aligned after a torn or failed append
Fixes #2129

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:33:33 +02:00
NetterSilverandClaude Haiku 5.5 035d572ac8 Refuse catalog snapshots with an out-of-range seq
Fixes #2130

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:57:37 +02:00
NetterSilverandClaude Haiku 5.5 e0c02d5e89 Strip XML 1.0-forbidden characters from XMP sidecar output
Fixes #2131

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:12:52 +02:00
wly 8e268431e0 l10n: unify zh-hant library term and contact sheet wording
Use 照片庫 for "library" everywhere (it was split between 照片庫 and
圖庫), and 縮圖目錄 for the contact sheet progress label to match the
File menu item. Only translations change; English keys and format
placeholders are untouched.
2026-10-17 15:08:07 +08:00
NetterSilverandClaude Haiku 5.5 edc369565e Reject trailing text after a bracketed IPv6 host in Url::parse
Found while testing crates/fetch/src/http.rs:87-102

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 04:17:46 +02:00
NetterSilverandClaude Haiku 5.5 80e439f6dc Reject signed hex digits in #rrggbb colour parsing
Found while testing crates/slideshow/src/settings.rs:296-300 (parse_color); same pattern in crates/engine/src/slideshow.rs:117-122 (fadeColor)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 00:45:25 +00:00
MarekSpodBiedry 5be931e48a Show the applied profile when hovering it in the profile browser (#2057) 2026-10-17 00:23:38 +02:00
NetterSilverandClaude Haiku 5.5 3accb56196 Start crop drags in the zone the cursor showed at the press
Fixes #1930

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:32:19 +00:00
NetterSilverandClaude Haiku 5.5 0a9675c6d9 Ignore zero or negative ISO when importing XMP
Found while testing crates/meta/src/xmp.rs:509-512

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 14:54:06 +00:00
NetterSilverandClaude Haiku 5.5 312dd04830 Reject out-of-range start in photo.rename and photo.renamePreview
Found while testing crates/engine/src/rename.rs:919 (start parsed at crates/engine/src/cmd/manage.rs:12)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 16:21:24 +02:00
Marco Arnulfo b67bb01952 TIFF: read strips and tiles larger than 128 MiB (fixes #1983) 2026-10-16 07:04:45 +02:00
Marco Arnulfo c8f741fd0b Contributors: add my name for the About window 2026-10-16 02:20:02 +02:00
Marco Arnulfo c5deea05e4 Merged develop settings are kept in range, as XMP import keeps them (fixes #1946) 2026-10-16 00:23:44 +02:00
FredApps 9bec9db541 LR-1670: refresh opaque RGB export against main 2026-10-15 22:24:56 +02:00
skybluu fa715c8c94 Localize Info Overlay mode notices (#911) 2026-10-15 15:10:36 -05:00
Claude 6938be39e4 Engine tests: drop the drop() of a closure (clippy drop_non_drop)
Clippy 1.97 rejects `drop(write)` in the same-folder export test: the
closure has no Drop, so its borrow of `written` already ends at its last
use and the call did nothing. Removed, with a note on why the following
read of `written` compiles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b
2026-10-15 19:17:57 +00:00
naryzach dde403946b raw: alias Canon EOS R6 Mark II to EOS R6m2
Canon EOS R6 Mark II writes its Exif model as 'Canon EOS R6 Mark II', which failed the spectral camera table lookup because the table registers the model as 'EOS R6m2'. Add the alias mapping and regression unit tests in lightcraft_raw and lightcraft_catalog.
2026-10-15 12:17:25 -07:00
Claude b5dc500c54 Hasselblad 3FR / FFF: uncompressed RGB embedded previews
These files carry no JPEG: their preview is an uncompressed 8-bit RGB
IFD (1217 x 913 in the H4D-40 FFF, 218 x 218 in the CFV 3FR). The
largest such image is handed on by embedded_preview as a small baseline
TIFF, which lightcraft_codecs::decode reads, so 3FR / FFF show a preview
in the grid and loupe before they are developed. No Orientation tag is
written: the raw file's applies, as for JPEG previews without one. The
FFF's 16-bit copy (with a TransferFunction of unknown meaning) is not
used.

decode_raw_preview applied a container-declared colour space by forcing
the JPEG decoder on any owned preview; it now checks for JPEG bytes, so
a wrapped TIFF (or a JPEG XL preview) goes to its own decoder.

Checked on the CC0 H4D-40 FFF and CFV 3FR through
embedded_preview_srgb: right colours, the 3FR upright through its
Rotate90. Tests cover the wrap, the choice among RGB images, a short
strip and truncated files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b
2026-10-15 18:58:05 +00:00
Claude 0f67225e83 Hasselblad FFF: colour matrix from the embedded 3FR header
An FFF (a 3FR converted on the desktop) has no colour matrix of its own:
the camera's stays in the original 3FR header the FFF carries as a
complete TIFF in private IFD0 tag 0xC51B. The new vendor::hasselblad
reader decodes 3FR / FFF through dng::decode_as and, when the file has
no matrix, takes the matrices from that header, so an FFF no longer
develops with the "camera RGB = sRGB" fallback (H4D-40 as-shot white
moves from xy 0.279, 0.356 to 0.325, 0.336).

RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV
3FR (raw.pixls.us 931 and 709). Synthetic tests cover the embedded
matrix, an own matrix winning, a missing or damaged header, and
truncated files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b
2026-10-15 18:19:15 +00:00
Nathaniel LedfordandClaude d020c55062 Contributors: credit nledford by real name
Co-Authored-By: Claude <[email protected]>
2026-10-15 11:39:17 -04:00
Michael Anderson 25d466fd69 Merge remote-tracking branch 'upstream/main' into fix/1532-dns-deadline
# Conflicts:
#	docs/whats-new.md
2026-10-15 09:14:06 -05:00
Lucas LepskiandGPT-6.1 Sol 7ac6dd81fe DOC-README: refresh current features and setup requirements
Align feature descriptions, counts, exports and language options with the
current code. Distinguish model-dependent features and implemented Print
and slideshow workflows from their remaining gaps.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-10-15 15:40:53 +02:00
Marco Arnulfo 79d726f856 LRC-PRINT-PAGE: Page Options print page numbers, page info and crop marks (gap 15)
The Print dialog's Page section gets Lightroom's Page Options for Single Image
and Contact Sheet: the page number at the bottom right, the print settings line
(sharpening, profile, resolution) at the bottom left, and crop marks at the
corners of each photo. They are `print.set {page: {pageNumbers, pageInfo,
cropMarks}}`, saved with templates, drawn in the preview and composed into the
PDF; marks and text turn white on a dark page.
2026-10-15 15:38:03 +02:00
Matthew James SivakandClaude Opus 5.5 a43cb68593 LR-MERGE-HEADLESS: merge dialogs remember the last settings per kind
The HDR / Panorama / HDR Panorama dialogs now open with the options of the
last merge of that kind instead of the defaults, and those options are kept
in the library's prefs.json (Session::last_merge) so they survive a restart.
"With Last Settings" and HDR for Each Bracket Set read the same store.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:04:14 +02:00
Adrian D'souzaandClaude Opus 5.5 8a4d6f2ebf HEIF: hold uncropped SPS sizes and a grid's tiles together to max_pixels (#1518)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:23:19 +05:30
Adrian D'souzaandClaude Opus 5.5 112e202fed JPEG XL: check the size from the header before reading frames, under an allocation budget (#1517)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:23:18 +05:30
justinjohn0306 de038ca3d4 #1880: allow 102 MP AI denoise inputs 2026-10-15 16:31:06 +05:30
Michael Anderson 888a54f52a Merge remote-tracking branch 'upstream/main' into fix/1532-dns-deadline
# Conflicts:
#	docs/whats-new.md
2026-10-15 01:05:38 -05:00
Michael Anderson aed214da7e Merge remote-tracking branch 'upstream/main' into fix/1532-dns-deadline
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:50:50 -05:00
Ege Gursel 31f4ad43fe contributors: re-add my people.toml entry lost in the merge 2026-10-15 00:29:43 -04:00
Michael Anderson ef216d2110 Merge remote-tracking branch 'upstream/main' into fix/1532-dns-deadline
# Conflicts:
#	docs/whats-new.md
2026-10-14 23:29:27 -05:00
Ege Gursel f906998174 Merge branch 'main' into fix/xmp-import-local-grain-luminance-range 2026-10-15 00:25:35 -04:00
Ege Gursel 1130bda456 fix(import): keep Lightroom luminance ranges open at white, report unmapped local sliders
Two of the three problems reported in #1840:

- A luminance range at full strength up to white (LumRange "a b 1 1",
  a typical highlights mask) ended at our scene white (0 EV) with no
  feather, so anything brighter (the sun, speculars, a sky after a
  positive exposure) fell out of the mask along a hard edge. That end
  now stays open.
- Correction sliders we have no counterpart for (LocalGrain, ...) were
  dropped silently. They are now listed in `unmapped` as
  "Mask: LocalGrain"; LocalCurveRefineSaturation 100 counts as neutral.

Adobe base looks, the third problem, are handled by #1469.

Also adds my entry to contributors/people.toml.
2026-10-15 00:23:41 -04:00
NetterSilverandClaude Haiku 5.5 48d69bfae5 Drop out-of-range GPS coordinates read from XMP
Found while testing crates/meta/src/xmp.rs:364 (parse_gps_coord, used at xmp.rs:539-544)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 05:21:00 +02:00
Michael Anderson e65b97e239 Model downloads: Time out and cancel the name lookup (#1532)
The host name lookup blocked with no deadline before the cancel check, and each address then got the full connect timeout. The lookup now runs on a helper thread under the connect deadline, which all addresses share.
2026-10-14 13:56:34 -05:00
FredAppsandCodex 2b86ca0537 LR-EXP-TYPE: omit redundant alpha from opaque 8-bit exports (#1670)
Preserve real transparency, pixels and embedded metadata. Cover RGB PNG,
TIFF compression modes and WebP, plus real-alpha round trips.

Co-authored-by: Codex <[email protected]>
2026-10-14 10:23:40 +02:00
66 changed files with 1983 additions and 166 deletions

No files matched your search

Generated
+1
View File
@@ -3140,6 +3140,7 @@ version = "0.9.1"
dependencies = [
"avif-serialize",
"bytemuck",
"dhat",
"image",
"image-webp",
"jixel",
+42 -20
View File
@@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col
## Before & after
Hold <kbd>\\</kbd> to peek at the original, press <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a
Press <kbd>\\</kbd> to toggle the Before view, <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a
split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
[control channel](#built-for-agents).
@@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom
Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable
pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components
with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint,
Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise)
plus an overall Amount.
with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint,
Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise,
Moiré, Defringe), plus an overall Amount.
Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately
installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use
classical heuristics. Existing model-generated masks render and export without the model installed.
<p align="center">
<img src="docs/images/masking.jpg" alt="Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene" width="100%">
@@ -164,7 +168,7 @@ plus an overall Amount.
## Presets, profiles & the Color Mixer
Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark
favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose.
@@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
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.
The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe.
On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are
bundled. See [face models and setup](docs/faces.md).
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder"><br><sub>Photo Grid with albums nested in a folder, ratings and flags. <i>Demo library.</i></sub></td>
@@ -281,7 +289,13 @@ lightcraft --control 7980 ~/Pictures/trip
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation,
print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page
overlays (identity plate, watermark, page numbers) are not there yet.
- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order.
**Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports
support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain
missing. See [docs/slideshow.md](docs/slideshow.md).
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -293,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
as in PhotoCraft).
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
@@ -319,7 +333,7 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
@@ -327,9 +341,13 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md);
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and
colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback
and export work, with a dedicated module editor and music still missing.
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
@@ -337,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ |
| Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ |
| Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ |
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill |
| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -354,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) |
| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ |
| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays |
| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) |
| Video import/editing and Book module | ⬜ |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -373,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
cargo xtask web --serve # the same app in the browser: 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, tests, layering, wasm checks
cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
```
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
@@ -426,7 +448,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, Українська, Čeština, ქართული) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) 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
@@ -536,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `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` | |
+12
View File
@@ -45,3 +45,15 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.nledford]
real_name = "Nathaniel Ledford"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.egegl]
real_name = "Ege Gursel"
sync_github_name = true
+11 -4
View File
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
(rec.0 <= last + 1).then_some((i, rec))
(rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
const MAX_SEQ: u64 = u64::MAX / 2;
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapHeader {
@@ -331,6 +334,10 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
// a seq this high can't come from real use; the next record's seq would wrap
if s.seq > MAX_SEQ {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
}
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
@@ -392,7 +399,7 @@ impl Journal {
let _ = op;
report.stale += 1;
}
Some((seq, op)) if seq == j.seq + 1 => {
Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
@@ -476,7 +483,7 @@ impl Journal {
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
seq += 1;
seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
@@ -527,7 +534,7 @@ impl Journal {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
let seq = self.seq + unlogged;
let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
+1
View File
@@ -643,6 +643,7 @@ mod edited_tests {
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
("Canon Canon EOS R6 Mark II", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
+9 -2
View File
@@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
// Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
let cmp32 = |x: f32, s: &str| match num(s) {
Some(('>', v)) => x > v as f32,
Some(('<', v)) => x < v as f32,
Some((_, v)) => x == v as f32,
None => false,
};
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
"focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
+21
View File
@@ -144,6 +144,27 @@ fn free_text_with_a_colon_searches_the_text_fields() {
assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing");
}
/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
#[test]
fn search_tokens_match_f32_aperture_and_focal() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
let mut m = c.photo(a).unwrap().meta.clone();
m.aperture = Some(2.8);
m.focal_mm = Some(5.6);
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
assert_eq!(q("f:2.8"), vec![a], "f:2.8");
assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
assert!(q("f:2.9").is_empty(), "f:2.9");
assert!(q("f:<2.8").is_empty(), "f:<2.8");
assert!(q("f:>2.8").is_empty(), "f:>2.8");
assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
}
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
+15
View File
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
#[test]
fn snapshot_with_seq_near_u64_max_is_corrupt() {
for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
);
let e = Journal::open(Box::new(m)).err();
assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
}
}
+3
View File
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
dhat = { workspace = true }
+53 -3
View File
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
use jxl_oxide::{AuxBoxData, JxlImage};
use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
const F: Format = Format::Jxl;
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
/// The allocation budget never drops below this, so small images keep the decoder's fixed
/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
const MIN_TRACKED_BYTES: u64 = 64 << 20;
/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
const INPUT_CHUNK: usize = 4096;
/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
/// within a tracked allocation budget.
fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> {
let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
// Initialization can load already-buffered frame data, so small feeds limit work before
// dimension checking.
let mut rest = bytes;
let mut window = INPUT_CHUNK;
let mut img = loop {
let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
rest = rest.get(consumed..).unwrap_or_default();
match uninit.try_init().map_err(err)? {
InitializeResult::Initialized(img) => break img,
InitializeResult::NeedMoreData(more) => uninit = more,
}
if consumed == 0 {
// the parser wants more than the window holds at once
if window >= rest.len() {
return Err(err("truncated JPEG XL header"));
}
window = window.saturating_mul(2);
}
};
check_size(F, img.width() as u64, img.height() as u64, opts)?;
// Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
// keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
// after the last frame; feeding the remaining file at once bypasses the allocation budget.
while !img.is_loading_done() && !rest.is_empty() {
let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
if consumed == 0 {
break;
}
rest = rest.get(consumed..).unwrap_or_default();
}
img.finalize().map_err(err)?;
Ok(img)
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
let mut img = open(bytes, opts)?;
let (w, h) = (img.width(), img.height());
check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
+5
View File
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
// A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
// single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
// (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
// (JPEG headers, padding to the tile size).
limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
limits.ifd_value_size = 32 << 20;
limits
}
+98
View File
@@ -0,0 +1,98 @@
#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
fn codestream(bytes: &[u8]) -> Vec<u8> {
if bytes.starts_with(b"\xff\x0a") {
return bytes.to_vec();
}
let mut raw = Vec::new();
let mut at = 12;
while at + 8 <= bytes.len() {
let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
let end = if len == 0 { bytes.len() } else { at + len };
match &bytes[at + 4..at + 8] {
b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
_ => {}
}
at = end;
}
assert!(raw.starts_with(b"\xff\x0a"));
raw
}
fn box_bytes(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
}
#[test]
fn metadata_in_final_feed_matches_builder() {
let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let raw = codestream(&encoded);
let exif = minimal_exif(1);
let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
exif_box.extend_from_slice(&exif);
// Split the codestream across two feeds, with Exif and XMP following its last frame.
let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
let split = raw.len() / 2;
let mut part = 0u32.to_be_bytes().to_vec();
part.extend_from_slice(&raw[..split]);
box_bytes(&mut bytes, b"jxlp", &part);
let padding = vec![0; 4096 - bytes.len() - 8];
box_bytes(&mut bytes, b"free", &padding);
let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
part.extend_from_slice(&raw[split..]);
box_bytes(&mut bytes, b"jxlp", &part);
box_bytes(&mut bytes, b"Exif", &exif_box);
box_bytes(&mut bytes, b"xml ", b"<xmp/>");
assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
let old_exif = match old.aux_boxes().first_exif().unwrap() {
jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
_ => panic!("builder must preserve Exif in its final feed"),
};
let old_xml = match old.aux_boxes().first_xml() {
jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
_ => panic!("builder must preserve XMP in its final feed"),
};
let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
assert_eq!(decoded.to_srgb8(), pixels);
}
#[test]
fn progressive_truncation_matches_builder() {
let pixels = Rgba8::from_fn(96, 64, |x, y| {
let mut seed = (y * 96 + x + 1) as u32;
let mut rgb = [0; 4];
for channel in &mut rgb[..3] {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
*channel = seed as u8;
}
rgb[3] = 255;
rgb
});
for lossless in [false, true] {
let encoded =
encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
.unwrap();
let raw = codestream(&encoded);
assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
let bytes = &raw[..cut];
// `finalize` permits partial images: rendering determines whether frame 0 is usable.
let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
}
}
}
+43
View File
@@ -0,0 +1,43 @@
#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn codestream(bytes: &[u8]) -> Vec<u8> {
if bytes.starts_with(b"\xff\x0a") {
return bytes.to_vec();
}
let mut raw = Vec::new();
let mut at = 12;
while at + 8 <= bytes.len() {
let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
let end = if len == 0 { bytes.len() } else { at + len };
match &bytes[at + 4..at + 8] {
b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
_ => {}
}
at = end;
}
assert!(raw.starts_with(b"\xff\x0a"));
raw
}
#[test]
fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let mut raw = codestream(&encoded);
let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
decode(&raw, opts).unwrap();
raw.resize(raw.len() + (1 << 20), 0);
let _profiler = dhat::Profiler::builder().testing().build();
let decoded = decode(&raw, opts).unwrap();
assert_eq!((decoded.width, decoded.height), (8, 8));
let peak = dhat::HeapStats::get().max_bytes;
eprintln!("peak heap bytes: {peak}");
assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
}
+99
View File
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
#[test]
fn tiff_single_strip_larger_than_128_mib() {
use tiff::encoder::{TiffEncoder, colortype};
let (w, h) = (3923u32, 5877u32);
let mut cur = std::io::Cursor::new(Vec::new());
{
let mut enc = TiffEncoder::new(&mut cur).unwrap();
let mut im = enc.new_image::<colortype::RGB16>(w, h).unwrap();
im.rows_per_strip(h).unwrap();
im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
}
let bytes = cur.into_inner();
assert!(bytes.len() > 128 << 20);
let t = decode_thumbnail(&bytes, 256).unwrap();
assert_eq!((t.source_width, t.source_height), (w, h));
let px = t.image.get(10, 10);
assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
}
/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec<u8> {
let mut b = b"II*\0".to_vec();
b.extend(8u32.to_le_bytes());
b.extend((entries.len() as u16).to_le_bytes());
for &(tag, ty, value) in entries {
b.extend(tag.to_le_bytes());
b.extend(ty.to_le_bytes());
b.extend(1u32.to_le_bytes());
b.extend(value.to_le_bytes());
}
b.extend(0u32.to_le_bytes());
b.extend_from_slice(data);
b
}
/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
#[test]
fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
// 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
let mut jpeg = Vec::new();
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
let at = 8 + 2 + 10 * 12 + 4;
let entries = [
(256, 4, 1),
(257, 4, 1),
(258, 3, 8),
(259, 3, 7),
(262, 3, 1),
(273, 4, at),
(277, 3, 1),
(278, 4, 1),
(279, 4, jpeg.len() as u32),
(284, 3, 1),
];
let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
assert_eq!((d.image.width, d.image.height), (1, 1));
// 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
let at = 8 + 2 + 11 * 12 + 4;
let entries = [
(256, 4, 16),
(257, 4, 16),
(258, 3, 8),
(259, 3, 1),
(262, 3, 1),
(277, 3, 1),
(284, 3, 1),
(322, 4, 256),
(323, 4, 256),
(324, 4, at),
(325, 4, 65536),
];
let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
assert_eq!((d.image.width, d.image.height), (16, 16));
}
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
/// already packed into `strip`, one padded row after another.
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
#[test]
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
fn jxl_size_limit_and_truncation() {
let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
assert_eq!((d.width, d.height), (97, 61));
for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
let _ = decode(&b[..cut], DecodeOptions::default());
}
assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
+11 -2
View File
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
impl Rgb8 {
pub fn from_hex(s: &str) -> Option<Rgb8> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(s, 16).ok()?;
@@ -196,6 +196,15 @@ impl Rgb8 {
mod tests {
use super::*;
#[test]
fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
}
}
#[test]
fn srgb_matrix_matches_standard() {
let m = SRGB.to_xyz();
+16 -1
View File
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
pub const MAX_PIXELS: usize = 100_000_000;
pub const MAX_PIXELS: usize = 110_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
@@ -642,4 +642,19 @@ mod tests {
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
#[test]
fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
let layout = Layout { dx: 0, dy: 0 };
let model = Mock { tile: 32, scale: 1.0 };
let p = params(32, 8, Gain::None);
assert!(matches!(
denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
));
assert!(matches!(
denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
Err(Error::Input(message)) if message == "the picture has no size, or is too large"
));
}
}
+50 -1
View File
@@ -41,7 +41,7 @@ impl DevelopSettings {
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
/// Kelvin settings.
/// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
@@ -52,9 +52,34 @@ impl DevelopSettings {
{
out.wb.scale = WbScale::Legacy;
}
out.keep_in_range(self);
Ok(out)
}
/// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
/// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
/// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
/// may reach past 0..1 and is left to the crop tools, which know the photo's size.
fn keep_in_range(&mut self, before: &DevelopSettings) {
for c in controls::CONTROLS {
if let Some(v) = controls::get(self, c.id) {
controls::set(self, c.id, v);
}
}
let c = &mut self.curve;
for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
*p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
}
let g = &mut self.crop.geometry;
if g.angle == 0.0 {
g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
}
if g.rect.is_empty() {
let old = before.crop.geometry.rect;
g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
}
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
@@ -255,6 +280,30 @@ mod tests {
assert_eq!(m.light.exposure, 0.0);
}
#[test]
fn merged_settings_are_kept_in_range() {
// issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
let mut base = DevelopSettings::default();
base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
let m = base
.merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
.unwrap();
assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
// point curves stay in 0..1, as an imported XMP curve does
let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
// a crop with no area is left out, as XMP import leaves out one whose edges are inverted
for rect in
[json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
{
let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
}
// unrotated, the crop stays inside the photo
let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
}
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
+2 -8
View File
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
/// through the control specs.
/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
if let Some(v) = crate::controls::get(&out, c.id) {
crate::controls::set(&mut out, c.id, v);
}
}
out
s.merged(&patch).unwrap_or_else(|_| s.clone())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+7 -2
View File
@@ -9,8 +9,13 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
Ok((s.targets(p)?, template, start))
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
let ids = s.targets(p)?;
// every number of the sequence `start..start + n` must fit
let last = start.checked_add((ids.len() as u64).saturating_sub(1));
let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
let start = start.ok_or_else(|| bad(c, "start out of range"))?;
Ok((ids, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
+1 -1
View File
@@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
always, get),
cmd!(query "print.set", "Set Print Settings", [], None,
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
always, set),
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
cmd!(query "print.cell.add", "Add Print Cell", [], None,
+43 -1
View File
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
Value::Object(a)
}
/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
/// reported, never dropped silently.
fn unmapped_local(c: &XmpValue) -> Vec<String> {
let XmpValue::Struct(m) = c else { return Vec::new() };
m.keys()
.filter_map(|k| {
let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
// 100 = "curve saturation unchanged", written with every correction
let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
let set = num(c, k).is_some_and(|v| v != neutral);
(set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
})
.collect()
}
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String
Some("2") => {
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
// full strength at white (1, the top of their scale) takes in everything brighter: ours
// goes on above scene white, so that end stays open instead of cutting off at 0 EV
let open_top = c >= 1.0;
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
}
Some("3") => {
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
if parts.is_empty() {
continue;
}
skipped.extend(unmapped_local(c));
// brush dabs of one correction are one brush component
let mut merged: Vec<Value> = Vec::new();
for p in parts {
@@ -506,6 +526,28 @@ mod tests {
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let d = lightcraft_meta::parse_xmp(x).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
assert_eq!(s.masks[0].adjust.clarity, -30.0);
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
+46 -1
View File
@@ -1310,7 +1310,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
let e = EncodeImage::rgba8(&img);
// Opaque photo exports need no alpha channel (#1670). Keep real transparency for
// callers of this encoder, and leave unrelated formats on their existing path.
let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
&& img.data.iter().all(|p| p.last() == Some(&255)))
.then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
let e = match &rgb {
Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
None => EncodeImage::rgba8(&img),
};
let r = match o.format {
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
@@ -2281,6 +2289,43 @@ mod tests {
img
}
/// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
#[test]
fn opaque_eight_bit_exports_are_rgb() {
let img = test_image();
let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
if format == ExportFormat::Png {
assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
}
if format != ExportFormat::Tiff {
break;
}
}
}
}
#[test]
fn eight_bit_exports_preserve_real_alpha() {
let mut img = test_image();
img.data[0][3] = 128;
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
assert!(decoded.has_alpha, "{format:?} must retain real alpha");
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
}
}
#[test]
fn formats_have_magic() {
let img = test_image();
+71 -6
View File
@@ -167,6 +167,9 @@ impl Index {
self.entries.insert((photo, key), (emb.into(), view));
}
}
// a half-written last record (a crash while appending): written fresh at the next save, so later records are
// not appended after it and shifted
self.rewrite = !rest.is_empty();
}
fn header(&self) -> Vec<u8> {
@@ -201,12 +204,11 @@ impl Index {
return Ok(());
}
if self.file_matches && !self.rewrite {
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let mut buf = Vec::new();
for (k, e) in &self.unsaved {
buf.extend(self.record(k, e));
}
f.write_all(&buf)?;
append(&path, &buf)?;
} else {
let mut buf = self.header();
for (k, e) in &self.entries {
@@ -229,6 +231,22 @@ impl Drop for Index {
}
}
/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
/// the part that got in does not shift what is appended later.
fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
let old_len = f.metadata()?.len();
let written = f.write_all(buf);
if written.is_err() {
drop(f);
// an append-only handle may not truncate (Windows): cut through a writable one
if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
let _ = w.set_len(old_len);
}
}
written
}
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
const SCAN_VERSION: u8 = 1;
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
@@ -325,8 +343,11 @@ impl Scanned {
let head = self.header();
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
self.file_matches = true;
// a half-written last id (a crash while appending) is ignored
self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
// a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
// so later ids are not appended after it and shifted
let (ids, torn) = rest.as_chunks::<8>();
self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
self.rewrite = !torn.is_empty();
}
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
@@ -341,9 +362,8 @@ impl Scanned {
return Ok(());
}
if self.file_matches && !self.rewrite {
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
f.write_all(&buf)?;
append(&path, &buf)?;
} else {
let mut buf = self.header();
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
@@ -487,6 +507,51 @@ mod tests {
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
let _ = std::fs::remove_dir_all(&dir);
}
/// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
/// (issue #2129).
#[test]
fn records_appended_after_a_torn_tail_stay_aligned() {
let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
let junk = |p: &Path| {
let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
f.write_all(&[0x3f; 5]).unwrap();
};
let mut a = Index::default();
a.reset("m@abc", 3, Some(emb.clone()));
a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
a.save().unwrap();
drop(a);
junk(&emb);
let mut b = Index::default();
b.reset("m@abc", 3, Some(emb.clone()));
b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
b.save().unwrap();
drop(b);
let mut c = Index::default();
c.reset("m@abc", 3, Some(emb));
assert_eq!(c.len(), 2);
assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
let mut s = Scanned::default();
s.reset(Some(scan.clone()));
s.insert(42);
s.save().unwrap();
drop(s);
junk(&scan);
let mut t = Scanned::default();
t.reset(Some(scan.clone()));
t.insert(43);
t.save().unwrap();
drop(t);
let mut u = Scanned::default();
u.reset(Some(scan));
assert!(u.contains(42) && u.contains(43) && u.len() == 2);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
+3 -2
View File
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
covering: Option<usize>,
) -> Option<lightcraft_codecs::Decoded> {
let jpeg = stand_in_image(bytes, covering)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
// the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
// wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
+5
View File
@@ -300,6 +300,10 @@ pub struct Session {
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option<serde_json::Value>,
/// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
/// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
/// them. Persisted in prefs.json.
pub last_merge: std::collections::BTreeMap<String, serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
@@ -461,6 +465,7 @@ impl Session {
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
last_merge: Default::default(),
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
+4
View File
@@ -207,6 +207,8 @@ struct PrefsFile {
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option<serde_json::Value>,
/// Options of the last Photo Merge, per command.
last_merge: std::collections::BTreeMap<String, serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Print templates.
@@ -492,6 +494,7 @@ impl Session {
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
self.last_merge = prefs.last_merge;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
@@ -746,6 +749,7 @@ impl Session {
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
last_merge: self.last_merge.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
+246 -6
View File
@@ -1,5 +1,6 @@
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
//! marks, page number, page info) and returns [`PageRaster`]s.
//!
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
pub const MAX_STROKE_PT: f64 = 36.0;
/// Renders kept for cells that print the same photo at the same size.
const RENDER_CACHE: usize = 4;
/// A crop mark's length and its gap from the photo's corner, inches.
pub const CROP_MARK_IN: f64 = 0.25;
pub const CROP_MARK_GAP_IN: f64 = 0.0625;
/// A crop mark's weight, points.
pub const CROP_MARK_PT: f64 = 0.5;
/// Size of the page number and page info text, points.
pub const PAGE_TEXT_PT: f64 = 9.0;
/// Lightroom's Image Settings panel.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -85,11 +93,18 @@ impl ImageSettings {
pub struct PageStyle {
/// Page background colour, sRGB.
pub background: [u8; 3],
/// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
/// the page number at the bottom right of each page.
pub page_numbers: bool,
/// The print settings (sharpening, profile, resolution) at the bottom left of each page.
pub page_info: bool,
/// Marks at the corners of each photo to cut along.
pub crop_marks: bool,
}
impl Default for PageStyle {
fn default() -> Self {
Self { background: [255; 3] }
Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
}
}
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
}
/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
pub fn has_page_options(style: Style) -> bool {
!style.is_package()
}
/// The colour of crop marks and page text on `background`: black, or white on a dark page.
pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
let [r, g, b] = background.map(f64::from);
if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
}
/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
/// where the resolution is; this print goes to a PDF).
pub fn page_info_text(settings: &PrintSettings) -> String {
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
let c = &settings.job.color;
let media = match c.media {
PrintMedia::Matte => "Matte",
PrintMedia::Glossy => "Glossy",
};
let sharpen = match c.sharpen {
PrintSharpen::Off => "Off".to_string(),
PrintSharpen::Low => format!("Low ({media})"),
PrintSharpen::Standard => format!("Standard ({media})"),
PrintSharpen::High => format!("High ({media})"),
};
let profile = match &c.profile {
PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
};
format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
}
// ---- placement ------------------------------------------------------------------------------
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
@@ -355,8 +405,8 @@ impl CellStyle {
}
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
/// asked), then the inner stroke on top of it.
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
/// asked), then the inner stroke on top of it. Returns where the photo landed.
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> {
// The border may not eat the cell: at least one pixel of photo area stays.
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
let (ax, ay) = (cell.x + border, cell.y + border);
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
}
Ok(())
Ok(PxRect { x: dx, y: dy, w, h })
}
/// What the Page Options draw on every page, in page pixels.
struct Marks {
/// Crop mark length, gap from the corner and weight.
crop: Option<(usize, usize, usize)>,
numbers: bool,
info: Option<String>,
ink: [u16; 3],
text_px: f32,
/// The printable area: the text sits in the margin below it, aligned with its sides.
area: Option<PxRect>,
}
impl Marks {
fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
let on = has_page_options(settings.layout.style);
let page = &settings.page;
let px = |inches: f64| (inches * ppi).round() as usize;
Marks {
crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
numbers: on && page.page_numbers,
info: (on && page.page_info).then(|| page_info_text(settings)),
ink: rec2020_from_srgb8(mark_ink(page.background)),
text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
}
}
/// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
if let Some((len, gap, weight)) = self.crop {
for r in photos {
crop_marks(page, r, len, gap, weight, photos, self.ink);
}
}
let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
// centred in the bottom margin, and on the paper when the margin is thinner than the text
let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
if self.numbers
&& let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
{
let x = ((area.x + area.w) as f32 - t.width).max(0.0);
draw_text(page, &t, x, top(t.height), self.ink);
}
if let Some(info) = &self.info
&& let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
{
draw_text(page, &t, area.x as f32, top(t.height), self.ink);
}
}
}
/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
let segments = [
(x0 - g - l, y0, l, t),
(x1 + g, y0, l, t),
(x0 - g - l, y1 - t, l, t),
(x1 + g, y1 - t, l, t),
(x0, y0 - g - l, t, l),
(x0, y1 + g, t, l),
(x1 - t, y0 - g - l, t, l),
(x1 - t, y1 + g, t, l),
];
let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
for (sx, sy, sw, sh) in segments {
for y in sy.max(0)..(sy + sh).min(page.h as i64) {
for x in sx.max(0)..(sx + sw).min(page.w as i64) {
let (x, y) = (x as usize, y as usize);
if !on_photo(x, y) {
page.fill_rect(x, y, 1, 1, ink);
}
}
}
}
}
/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
let (w, h) = (page.w, page.h);
text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
if gx >= w || gy >= h {
return;
}
let i = (gy * w + gx) * 3;
if let Some(px) = page.rgb.get_mut(i..i + 3) {
let k = cov.clamp(0.0, 1.0);
for (p, c) in px.iter_mut().zip(ink) {
*p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
}
}
});
}
// ---- the job ---------------------------------------------------------------------------------
@@ -402,6 +550,7 @@ pub struct Prepared {
ppi: f32,
background: [u16; 3],
style: CellStyle,
marks: Marks,
/// Print sharpening, applied to each photo once it's rendered at its print size.
color: crate::print_color::PrintColorSettings,
}
@@ -430,6 +579,7 @@ impl Prepared {
let mut out = Vec::with_capacity(self.pages.len());
for (index, &(w, h)) in self.pages.iter().enumerate() {
let mut page = Image16::filled(w, h, self.background)?;
let mut placed = Vec::new();
while let Some(item) = items.next_if(|i| i.page == index) {
if !progress(done, &item.name) {
return Err("Print cancelled".into());
@@ -448,8 +598,9 @@ impl Prepared {
}
};
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
}
self.marks.draw(&mut page, &placed, index + 1);
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
}
if !progress(done, "") {
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
ppi: ppi as f32,
background: rec2020_from_srgb8(settings.page.background),
style,
marks: Marks::of(&settings, ppi, pw, ph),
color: settings.job.color.clone(),
})
}
@@ -825,6 +977,94 @@ mod tests {
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
}
/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
/// margins are bare background unless something draws there).
fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> {
let ids = first_ids(s, 2);
let mut set = settings(style);
if style.is_package() {
set.layout.add_cell(3.0, 5.0).unwrap();
set.layout.new_page().unwrap();
let id = set.layout.add_cell(3.0, 5.0).unwrap();
set.layout.set_cell_page(id, 1).unwrap();
}
set.image.zoom_to_fill = true;
set.page = page;
prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
}
fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
let i = (y * p.width as usize + x) * 3;
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
}
/// The pixels of `x0..x1` x `y0..y1` that are not the white background.
fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
}
#[test]
fn crop_marks_sit_outside_each_photo_corner() {
let mut s = Session::with_demo();
let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
// The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
// along each edge of the photo.
for p in &pages {
for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
}
for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
}
}
// a dark page gets white marks
let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
assert_eq!(rgb(&dark[0], 30, 50), WHITE);
}
#[test]
fn page_numbers_and_info_print_in_the_bottom_margin() {
let mut s = Session::with_demo();
let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
// bottom right: the number, different on each page; nothing at the bottom left
let (right, left) = ((200, 400), (0, 200));
for p in &numbered {
assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
}
let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>();
assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
for p in &info {
assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
}
// the packages have no Page Options in Lightroom: nothing is drawn
let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
let package = page_options_pages(&mut s, Style::PicturePackage, all);
assert_eq!(package.len(), 2);
for p in &package {
assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
}
}
#[test]
fn page_info_names_the_sharpening_profile_and_resolution() {
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
let mut set = PrintSettings::default();
let line = page_info_text(&set);
assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
set.job.color.sharpen = PrintSharpen::Off;
set.job.color.media = PrintMedia::Matte;
set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
set.job.ppi = 300.0;
assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
}
#[test]
fn caps_and_hostile_settings_are_refused() {
let mut s = Session::with_demo();
+2 -1
View File
@@ -916,7 +916,8 @@ fn plan_rename_core(
) -> Vec<RenamePlan> {
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
for (seq, p) in (start..).zip(photos) {
for (i, p) in photos.iter().enumerate() {
let seq = start.saturating_add(i);
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
+3 -1
View File
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
Value::String(s) => {
let h = s.strip_prefix('#').unwrap_or(s);
if h.len() != 6 || !h.is_ascii() {
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(bad());
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
@@ -747,6 +747,8 @@ mod tests {
json!({"repeat": 1}),
json!({"fadeColor": "#12345"}),
json!({"fadeColor": "#ééé"}),
json!({"fadeColor": "#+f+f+f"}),
json!({"fadeColor": "-12345"}),
json!({"fadeColor": [300, 0, 0]}),
json!({"fadeColor": [1, 2]}),
json!({"speed": 2}),
+20
View File
@@ -336,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
drop(reopened);
let _ = std::fs::remove_dir_all(dir);
}
/// A huge `start` is a validation error, not an arithmetic overflow in the numbering.
#[test]
fn rename_huge_start_is_a_bad_param() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
for cmd in ["photo.renamePreview", "photo.rename"] {
let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err();
assert!(e.to_string().contains("start out of range"), "{cmd}: {e}");
}
let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
assert_eq!(names_before, names_after, "a rejected rename changes nothing");
// the last start whose whole sequence fits is accepted, one more is not
let fits = usize::MAX as u64 - 2;
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids}));
assert!(r.is_ok(), "{r:?}");
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
assert!(r.is_err(), "{r:?}");
}
+19
View File
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn last_merge_settings_persist_with_the_library() {
let lib = temp_dir("merge");
let opts = json!({"align": false, "deghost": "high"});
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.last_merge.insert("merge.hdr".into(), opts.clone());
s.save_prefs().unwrap();
s.close_library().unwrap();
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
assert!(!s.last_merge.contains_key("merge.panorama"));
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn default_copyright_and_creator_fill_gaps_on_import() {
let dir = temp_dir("copyright");
+20
View File
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
assert!(c.params.len() > 3, "{id}");
}
}
#[test]
fn page_options_are_print_settings_and_template_fields() {
let mut s = Session::with_demo();
let g = run(&mut s, "print.get", json!({}));
assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
// a template keeps them
run(&mut s, "print.template.save", json!({"name": "Marked"}));
run(&mut s, "print.reset", json!({}));
assert!(!s.print.page.crop_marks);
run(&mut s, "print.template.apply", json!({"name": "Marked"}));
assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
let set = crate::find_command("print.set").unwrap();
assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
}
+127 -20
View File
@@ -10,7 +10,7 @@
//! the downloading thread. No proxies (see docs/ai-masks.md).
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
@@ -88,6 +88,9 @@ impl Url {
let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?;
let host = v6.get(..end).unwrap_or_default();
let after = v6.get(end + 1..).unwrap_or_default();
if !after.is_empty() && !after.starts_with(':') {
return Err(bad("unexpected text after IPv6 address"));
}
(host.to_string(), after.strip_prefix(':'))
} else {
match authority.rsplit_once(':') {
@@ -182,27 +185,68 @@ fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> {
Ok(Arc::new(config))
}
/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no
/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too
/// long is given up on, and Cancel works during it (issue #1532).
fn lookup(
host: &str,
resolve: impl FnOnce() -> std::io::Result<Vec<SocketAddr>> + Send + 'static,
deadline: Instant,
limits: &Limits,
) -> Result<Vec<SocketAddr>, HttpError> {
if limits.cancel.load(Ordering::Relaxed) {
return Err(HttpError::Cancelled);
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("lc-fetch-lookup".into())
.spawn(move || {
let _ = tx.send(resolve());
})
.map_err(|e| HttpError::Connect(format!("{host}: {e}")))?;
loop {
if limits.cancel.load(Ordering::Relaxed) {
return Err(HttpError::Cancelled);
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(HttpError::Connect(format!("{host}: the name lookup timed out")));
}
match rx.recv_timeout(left.min(TICK)) {
Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))),
}
}
}
/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole
/// connect timeout of its own, issue #1532).
fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result<TcpStream, HttpError> {
let mut last = format!("{host}: no address");
for a in addrs {
if limits.cancel.load(Ordering::Relaxed) {
return Err(HttpError::Cancelled);
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(HttpError::Connect(format!("{host}: connecting timed out")));
}
match TcpStream::connect_timeout(a, left) {
Ok(s) => return Ok(s),
Err(e) => last = format!("{host}: {e}"),
}
}
Err(HttpError::Connect(last))
}
impl Stream {
fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> {
let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect();
if addrs.is_empty() {
return Err(HttpError::Connect(format!("{}: no address", url.host)));
}
let mut last = String::new();
let mut sock = None;
for a in addrs {
if limits.cancel.load(Ordering::Relaxed) {
return Err(HttpError::Cancelled);
}
match TcpStream::connect_timeout(&a, limits.connect) {
Ok(s) => {
sock = Some(s);
break;
}
Err(e) => last = format!("{}: {e}", url.host),
}
}
let sock = sock.ok_or(HttpError::Connect(last))?;
// the lookup and the connection attempts share one connect timeout
let deadline = Instant::now() + limits.connect;
let (host, port) = (url.host.clone(), url.port);
let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?;
let sock = connect_any(&url.host, &addrs, deadline, limits)?;
sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
let _ = sock.set_nodelay(true);
@@ -512,6 +556,58 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res
Ok(Response { status, headers, conn, body })
}
#[cfg(test)]
mod tests_connect {
use super::*;
fn limits(cancel: &AtomicBool) -> Limits<'_> {
Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel }
}
/// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532).
#[test]
fn a_hung_name_lookup_times_out_and_can_be_cancelled() {
let cancel = AtomicBool::new(false);
let slow = || {
std::thread::sleep(Duration::from_secs(10));
Ok(Vec::new())
};
let t = Instant::now();
let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel));
assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}");
assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed());
// Cancel while the lookup is waiting on the resolver, not before it starts
let t = Instant::now();
let r = std::thread::scope(|scope| {
scope.spawn(|| {
std::thread::sleep(Duration::from_millis(100));
cancel.store(true, Ordering::Relaxed);
});
lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel))
});
assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}");
assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed());
// and a lookup asked for after Cancel doesn't start
assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled)));
// a lookup that answers is used
let cancel = AtomicBool::new(false);
let local: SocketAddr = "127.0.0.1:9".parse().unwrap();
assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]);
}
/// Several addresses that don't answer share one deadline instead of each getting the full timeout.
#[test]
fn addresses_share_one_connect_deadline() {
let cancel = AtomicBool::new(false);
// non-routable addresses: each either hangs until the deadline or fails at once, never longer
let addrs: Vec<SocketAddr> = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect();
let t = Instant::now();
let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel));
assert!(r.is_err());
assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed());
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -537,6 +633,17 @@ mod tests {
}
}
#[test]
fn rejects_text_after_a_bracketed_ipv6_host() {
for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] {
assert!(matches!(Url::parse(bad), Err(HttpError::BadUrl(_))), "{bad}");
}
let u = Url::parse("http://[::1]:8080/x").unwrap();
assert_eq!((u.host.as_str(), u.port), ("::1", 8080));
let u = Url::parse("http://[::1]/x").unwrap();
assert_eq!((u.host.as_str(), u.port), ("::1", 80));
}
#[test]
fn resolves_redirects() {
let u = Url::parse("https://a.example/x/y/file?q").unwrap();
+22
View File
@@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
})
}
/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
const GRID_BUDGET: u64 = 2;
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec<Frame>,
@@ -297,6 +301,24 @@ impl<'a> Coded<'a> {
let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?;
let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? };
let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format);
// #1518: the decoder allocates each picture at its SPS's uncropped size, which a
// conformance window (or a slice picking another SPS) can make far larger than the
// declared one, and a grid holds all its tiles at once. Both are refused before decoding.
let mut total = 0u64;
for t in &ids {
let tp = ctx.props(*t).map_err(err)?;
let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?;
let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?;
let mut largest = 0u64;
for (w, h) in sizes {
heic_rs::image::check_pixels(w, h, limit).map_err(err)?;
largest = largest.max(u64::from(w) * u64::from(h));
}
total = total.saturating_add(largest);
}
if total > limit.saturating_mul(GRID_BUDGET) {
return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit")));
}
let decode_one = |t: &u32| -> Result<Frame, Error> {
let tp = ctx.props(*t).map_err(err)?;
let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?;
+11
View File
@@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() {
assert!(matches!(r, Err(Error::Limit(_))), "{r:?}");
}
/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A
/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit.
#[test]
fn grid_tiles_are_held_to_the_pixel_budget_together() {
let bytes = build(&Spec::new(33, 33, &grey));
let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() });
assert!(matches!(r, Err(Error::Limit(_))), "{r:?}");
let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap();
assert_eq!((d.width, d.height), (33, 33));
}
#[test]
fn truncated_and_corrupted_files_are_errors_not_panics() {
let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) });
+36 -13
View File
@@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option<Signal> {
parse_sps(&unescape(sps.get(2..)?))
}
/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among
/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the
/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an
/// SPS this reader can't follow.
pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option<Vec<(u32, u32)>> {
parameter_sets
.iter()
.filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT))
.map(|sps| {
let rbsp = unescape(sps.get(2..)?);
let r = &mut Bits { data: &rbsp, pos: 0 };
sps_size(r).map(|(w, h, _)| (w, h))
})
.collect()
}
/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and
/// `sps_max_sub_layers_minus1`.
fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> {
r.skip(4)?; // sps_video_parameter_set_id
let max_sub_layers_minus1 = r.u(3)?;
if max_sub_layers_minus1 > 6 {
return None;
}
r.skip(1)?; // sps_temporal_id_nesting_flag
profile_tier_level(r, max_sub_layers_minus1)?;
r.ue()?; // sps_seq_parameter_set_id
if r.ue()? == 3 {
r.skip(1)?; // separate_colour_plane_flag
}
let width = r.ue()?; // pic_width_in_luma_samples
let height = r.ue()?; // pic_height_in_luma_samples
Some((width, height, max_sub_layers_minus1))
}
/// Removes emulation prevention bytes (`00 00 03` → `00 00`).
fn unescape(nal: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(nal.len());
@@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3
fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
let r = &mut Bits { data: rbsp, pos: 0 };
r.skip(4)?; // sps_video_parameter_set_id
let max_sub_layers_minus1 = r.u(3)?;
if max_sub_layers_minus1 > 6 {
return None;
}
r.skip(1)?; // sps_temporal_id_nesting_flag
profile_tier_level(r, max_sub_layers_minus1)?;
r.ue()?; // sps_seq_parameter_set_id
if r.ue()? == 3 {
r.skip(1)?; // separate_colour_plane_flag
}
r.ue()?; // pic_width_in_luma_samples
r.ue()?; // pic_height_in_luma_samples
let (_, _, max_sub_layers_minus1) = sps_size(r)?;
if r.flag()? {
for _ in 0..4 {
r.ue()?; // conf_win_*_offset
+75 -9
View File
@@ -185,11 +185,22 @@ fn entity(name: &str) -> Option<char> {
Some(h) => u32::from_str_radix(h, 16).ok()?,
None => n.parse().ok()?,
};
char::from_u32(v)?
char::from_u32(v).filter(|&c| is_xml_char(c))?
}
})
}
/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`).
fn is_xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}')
}
/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the
/// whole document for.
fn xml_safe(s: &str) -> String {
s.chars().filter(|&c| is_xml_char(c)).collect()
}
/// Unescape attribute values (predefined + numeric references; unknown references kept verbatim).
fn unescape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
@@ -506,10 +517,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
label: first("xmp:Label"),
..Default::default()
};
m.iso = num("exif:ISOSpeedRatings").or_else(|| num("exifEX:PhotographicSensitivity")).filter(|v| *v > 0.0).map(|v| v as u32);
if let Some(v) = props.get("exif:ISOSpeedRatings").and_then(|v| v.first()).and_then(|s| parse_number(s)) {
m.iso = Some(v as u32);
}
m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32);
if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) {
let v: Vec<f64> = spec.iter().filter_map(|s| parse_number(s)).collect();
if v.len() == 4 {
@@ -536,6 +544,8 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
}
if let (Some(lat), Some(lon)) =
(first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s)))
&& (-90.0..=90.0).contains(&lat)
&& (-180.0..=180.0).contains(&lon)
{
let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a });
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
@@ -623,12 +633,12 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
}
x.push_str(">\n");
for (k, v) in &simple {
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str())));
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v))));
}
for (k, v) in lc {
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
// element content: quotes needn't be escaped, which keeps the JSON readable
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v)));
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(xml_safe(v))));
}
}
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
@@ -638,9 +648,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n"));
for it in items {
if lang {
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(it.as_str())));
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it))));
} else {
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(it.as_str())));
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it))));
}
}
x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n"));
@@ -802,6 +812,24 @@ mod tests {
assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c"));
}
/// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and
/// must not hide a valid `exifEX:PhotographicSensitivity`.
#[test]
fn non_positive_iso_is_ignored() {
let read = |attrs: &str| {
let x = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:exifEX="http://cipa.jp/exif/1.0/" {attrs}/>
</rdf:RDF></x:xmpmeta>"#
);
parse_xmp(&x).unwrap().metadata.iso
};
assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400));
assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None);
assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None);
assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200));
}
#[test]
fn malformed_inputs() {
assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
@@ -840,6 +868,24 @@ mod tests {
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
}
/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°.
#[test]
fn xmp_gps_out_of_range_rejected() {
let gps = |lat: &str, lon: &str| {
let x = format!(
r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"#
);
parse_xmp(&x).unwrap().metadata.gps
};
assert_eq!(gps("95,0N", "200,30E"), None);
assert_eq!(gps("95,0N", "10,0E"), None);
assert_eq!(gps("10,0S", "200,30W"), None);
assert_eq!(gps("90,0,1N", "0,0E"), None);
assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None }));
assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None }));
}
/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how
/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one
/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas
@@ -1039,4 +1085,24 @@ mod tests {
a.overlay_user_fields(&b);
assert_eq!(a.regions, vec![with_region]);
}
#[test]
fn writer_drops_characters_xml_1_0_forbids() {
let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh";
let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() };
let x = write_xmp_lc(&m, &[("settings", bad)]);
let illegal: Vec<char> =
x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect();
assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}");
assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}");
}
#[test]
fn numeric_references_to_forbidden_characters_are_not_decoded() {
assert_eq!(entity("#1"), None);
assert_eq!(entity("#0"), None);
assert_eq!(entity("#xFFFE"), None);
assert_eq!(entity("#x41"), Some('A'));
assert_eq!(entity("#9"), Some('\t'));
}
}
+2 -2
View File
@@ -110,7 +110,7 @@ pub enum RawFormat {
X3f,
/// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a
/// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`):
/// Hasselblad 3FR and FFF. Read by the DNG reader.
/// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds).
CfaTiff,
/// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`).
KodakDcs,
@@ -397,7 +397,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
RawFormat::Pef => vendor::pef::decode(bytes, mode),
RawFormat::Orf => vendor::orf::decode(bytes, mode),
RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped),
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::Erf => vendor::erf::decode(bytes, mode),
+4 -2
View File
@@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
}
}
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
/// `lightcraft_codecs::decode`.
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which
/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`.
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
preview_covering(bytes, None)
}
@@ -220,6 +220,8 @@ fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> {
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
// a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note)
best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes)))
// Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF
.or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten())
}
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
+56 -3
View File
@@ -75,6 +75,21 @@ impl Ranges {
self.0 = out;
}
/// Add every `[start, end)` of `spans` in one sort and merge (empty ones add nothing).
pub fn extend(&mut self, spans: impl IntoIterator<Item = (u64, u64)>) {
let mut all: Vec<(u64, u64)> = std::mem::take(&mut self.0);
all.extend(spans.into_iter().filter(|&(a, b)| a < b));
all.sort_unstable();
let mut out: Vec<(u64, u64)> = Vec::with_capacity(all.len());
for (a, b) in all {
match out.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
self.0 = out;
}
/// Whether all of `[start, end)` is covered (an empty range always is).
pub fn contains(&self, start: u64, end: u64) -> bool {
start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b)
@@ -232,6 +247,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
}
let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") };
let mut holes = Ranges::new();
let mut spans = Vec::new();
for ifd in tiff.all_ifds() {
let chunks: Vec<(u64, u64)> = match ifd.image() {
// a preview's strips are read when the preview is wanted: only the raw image is skipped
@@ -249,11 +265,12 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
if total < BULK {
continue;
}
for (i, &(offset, size)) in chunks.iter().enumerate() {
spans.extend(chunks.iter().enumerate().map(|(i, &(offset, size))| {
let start = if i == 0 { offset.saturating_add(HEAD) } else { offset };
holes.insert(start, offset.saturating_add(size).min(len));
}
(start, offset.saturating_add(size).min(len))
}));
}
holes.extend(spans);
// A Panasonic RW2 keeps its raw data from `RawDataOffset` to the end, outside any TIFF image.
if holes.is_empty()
&& bytes.starts_with(b"IIU\0")
@@ -567,4 +584,40 @@ mod tests {
assert!(crate::probe_info(&file).is_ok());
assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
}
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is
/// built in one sort and merge, not one pass over all strips (or all holes) per strip.
#[test]
fn many_strips_without_byte_counts_plan_fast() {
let n = 1_000_000u32;
let (offsets_at, counts_at) = (8 + 2 + 5 * 12 + 4, 8 + 2 + 5 * 12 + 4 + 4 * n);
let data = counts_at + 4 * n;
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend_from_slice(&5u16.to_le_bytes());
let entry = |tiff: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
tiff.extend_from_slice(&tag.to_le_bytes());
tiff.extend_from_slice(&typ.to_le_bytes());
tiff.extend_from_slice(&count.to_le_bytes());
tiff.extend_from_slice(&value.to_le_bytes());
};
entry(&mut tiff, 256, 4, 1, 1);
entry(&mut tiff, 257, 4, 1, n);
entry(&mut tiff, 273, 4, n, offsets_at);
entry(&mut tiff, 278, 4, 1, 1);
entry(&mut tiff, 279, 4, n, counts_at);
tiff.extend_from_slice(&0u32.to_le_bytes());
for i in (0..n).rev() {
tiff.extend_from_slice(&(data + i).to_le_bytes());
}
tiff.resize(data as usize + n as usize, 0);
let len = tiff.len() as u64;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut loaded = Ranges::new();
loaded.insert(0, len);
let _ = tx.send(header_plan(&tiff, &loaded));
});
let plan = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s");
assert!(!matches!(plan, HeaderPlan::Need(_)), "{plan:?}");
}
}
+21 -3
View File
@@ -4,7 +4,8 @@
//! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG
//! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral
//! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the
//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number
//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other
//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number
//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice
//! (`docs/camera-preview-colour.md`).
use crate::ColorData;
@@ -40,6 +41,19 @@ impl Camera {
}
}
/// `(make, model as sold, table model)`: bodies sharing a table camera's sensor that rawtoaces-data's alias list
/// doesn't name, kept here because the table is generated. Fujifilm's 26.1 MP X-Trans CMOS 4 bodies use the X-T3's
/// sensor (X-T30 issue: a black-and-white Acros camera JPEG gives no colour to fit, and without matrices the photo
/// opened in the muted neutral fallback).
const SAME_SENSOR: &[(&str, &str, &str)] = &[
("Fujifilm", "X-T30", "X-T3"),
("Fujifilm", "X-T30 II", "X-T3"),
("Fujifilm", "X-Pro3", "X-T3"),
("Fujifilm", "X100V", "X-T3"),
("Fujifilm", "X-S10", "X-T3"),
("Fujifilm", "X-E4", "X-T3"),
];
/// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL`
/// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D").
fn key(brand: &str, model: &str) -> String {
@@ -70,7 +84,7 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> {
let wanted = key(&row_brand, model);
make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted
};
match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) {
match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) {
Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target),
None => table::CAMERAS.iter().find(|c| same(c.make, c.model)),
}
@@ -142,10 +156,14 @@ mod tests {
("Canon", "Canon EOS REBEL T3i", "EOS 600D"),
("Canon", "Canon EOS Kiss X5", "EOS 600D"),
("Canon", "Canon EOS R5 Mark II", "EOS R5m2"),
("Canon", "Canon EOS R6 Mark II", "EOS R6m2"),
("NIKON CORPORATION", "NIKON D800", "D800E"),
("NIKON CORPORATION", "NIKON Z 6_2", "Z f"),
("SONY", "ILCE-7RM4A", "ILCE-7RM4"),
("FUJIFILM", "GFX100S", "GFX 100"),
("FUJIFILM", "X-T30", "X-T3"),
("FUJIFILM", "X-T30 II", "X-T3"),
("FUJIFILM", "X100V", "X-T3"),
] {
assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}");
}
@@ -171,7 +189,7 @@ mod tests {
assert_eq!(find_camera(&joined), Some(c), "{joined}");
assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}");
}
for (make, alias, _) in table::ALIASES {
for (make, alias, _) in table::ALIASES.iter().chain(SAME_SENSOR) {
let joined = format!("{make} {alias}");
assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}");
assert!(find_camera(&joined).is_some(), "{joined}");
+1
View File
@@ -335,6 +335,7 @@ pub(super) const ALIASES: &[(&str, &str, &str)] = &[
("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name
("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name
("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name
("Canon", "EOS R6 Mark II", "EOS R6m2"), // same model, alternative name
("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor
("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor
("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor
+295
View File
@@ -0,0 +1,295 @@
//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]).
//!
//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box
//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number
//! below was measured on it.
//!
//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution
//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`.
//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about.
//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor
//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter.
//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each
//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at
//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847
//! with red, and the two green sites' diagonal differences are half those of the other pair.
//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in
//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag
//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a
//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read).
//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer
//! file's, which is the one the converter last saved.
//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the
//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB
//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used.
//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF.
use crate::dng;
use crate::{ColorData, Mode, RawFormat, RawImage, Result};
use lightcraft_tiff::image::{Layout, chunk_bytes};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t};
/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF.
pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b;
/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header.
const MAX_HEADER: usize = 1 << 20;
/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]).
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?;
if !crate::color::has_matrix(&img.color)
&& let Some(header) = original_header_color(bytes)
{
adopt_matrices(&mut img.color, &header);
}
Ok(img)
}
/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix.
fn original_header_color(bytes: &[u8]) -> Option<ColorData> {
let outer = Tiff::parse(bytes).ok()?;
let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?;
if block.len() > MAX_HEADER {
return None;
}
let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() };
let header = Tiff::parse_with(block, &opts).ok()?;
let ifd0 = header.ifds.first()?;
let raw = dng::raw_ifd(&header).unwrap_or(ifd0);
let color = dng::color_data(ifd0, raw, header.order);
crate::color::has_matrix(&color).then_some(color)
}
/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen.
const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3;
/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding
/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer
/// applies the raw file's, which is the one that stands over this image in IFD0.
pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option<Vec<u8>> {
let info = tiff
.all_ifds()
.into_iter()
.filter_map(|i| i.image().ok())
.filter(|i| {
i.photometric == t::photometric::RGB
&& i.compression == t::compression::NONE
&& i.samples_per_pixel == 3
&& i.bits_per_sample.iter().all(|&b| b == 8)
&& i.planar == 1
&& i.sample_format == 1
&& matches!(i.layout, Layout::Strips { .. })
&& i.width > 0
&& i.height > 0
})
.max_by_key(|i| u64::from(i.width) * u64::from(i.height))?;
let (w, h) = (info.width as usize, info.height as usize);
let size = w.checked_mul(h)?.checked_mul(3)?;
if size > MAX_PREVIEW_BYTES {
return None;
}
let mut pixels = Vec::with_capacity(size);
for chunk in info.chunks(bytes.len() as u64) {
pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?);
if pixels.len() >= size {
break;
}
}
// a short last strip is a truncated file: no preview rather than a picture with a black band
if pixels.len() < size {
return None;
}
pixels.truncate(size);
let mut ifd = IfdBuilder::new();
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE]));
ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] });
TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok()
}
/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays.
fn adopt_matrices(color: &mut ColorData, from: &ColorData) {
color.illuminant = from.illuminant;
color.color_matrix = from.color_matrix;
color.forward_matrix = from.forward_matrix;
color.camera_calibration = from.camera_calibration;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RawData, RawError, decode as decode_file, probe, probe_info};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t};
const MATRIX: [(i32, i32); 9] =
[(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)];
fn rgb_ifd() -> IfdBuilder {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] });
ifd
}
/// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix.
fn original_header(matrix: bool) -> Vec<u8> {
let mut ifd0 = rgb_ifd();
ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into()));
ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into()));
if matrix {
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec()));
}
ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)]));
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
}
/// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`,
/// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`.
fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> {
fff_with(header, own_matrix, vec![])
}
/// [`fff`] with more `SubIFDs` children of IFD0 (other preview images).
fn fff_with(header: Option<Vec<u8>>, own_matrix: bool, extra: Vec<IfdBuilder>) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![8]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![6]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)]));
raw.set(t::WHITE_LEVEL, Value::Short(vec![65535]));
raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] });
let mut ifd0 = rgb_ifd();
ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into()));
ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into()));
ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)]));
if own_matrix {
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)]));
}
if let Some(h) = header {
ifd0.set(ORIGINAL_HEADER, Value::Undefined(h));
}
ifd0.add_sub_ifd(raw);
for e in extra {
ifd0.add_sub_ifd(e);
}
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> {
c.color_matrix[0].map(|m| m.0[0])
}
/// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it
/// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback.
#[test]
fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() {
let bytes = fff(Some(original_header(true)), false);
assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff));
let r = decode_file(&bytes).unwrap();
assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect()));
assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB");
assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032]));
assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance");
assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback);
// the import probe says the same
assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032]));
}
/// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the
/// file still decodes, with the fallback colour.
#[test]
fn own_matrix_wins_and_a_missing_one_is_not_an_error() {
let own = decode_file(&fff(Some(original_header(true)), true)).unwrap();
assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0]));
for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] {
let r = decode_file(&fff(header, false)).unwrap();
assert!(!crate::color::has_matrix(&r.color));
}
}
/// A truncated or damaged file never panics: an error, or a decode.
#[test]
fn damaged_files_do_not_panic() {
let bytes = fff(Some(original_header(true)), false);
for n in (0..bytes.len()).step_by(7) {
let _ = decode_file(&bytes[..n]);
let _ = probe_info(&bytes[..n]);
let _ = crate::embedded_preview(&bytes[..n]);
}
let mut header = original_header(true);
for i in (8..header.len()).step_by(3) {
header[i] ^= 0x5a;
match decode_file(&fff(Some(header.clone()), false)) {
Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {}
}
}
}
/// `(width, height, pixels, has an Orientation tag)` of a wrapped preview.
fn wrapped(tiff: &[u8]) -> (u32, u32, Vec<u8>, bool) {
let t = Tiff::parse(tiff).unwrap();
let ifd = &t.ifds[0];
let info = ifd.image().unwrap();
let chunks = info.chunks(tiff.len() as u64);
let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect();
(info.width, info.height, pixels, ifd.contains(t::ORIENTATION))
}
/// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an
/// orientation of its own (the raw file's applies).
#[test]
fn rgb_ifd0_is_the_embedded_preview() {
let bytes = fff(Some(original_header(true)), false);
let preview = crate::embedded_preview(&bytes).expect("a preview");
assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false));
assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview));
}
/// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not
/// used, and nor is a planar or compressed one.
#[test]
fn only_8_bit_chunky_uncompressed_rgb_is_taken() {
let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
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![bits; 3]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression]));
ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar]));
let n = (w * h * 3 * u32::from(bits / 8)) as usize;
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] });
ifd
};
let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]);
assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3");
let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]);
assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false));
}
/// A preview strip cut short by a truncated file gives no preview, not a partial picture.
#[test]
fn short_preview_strip_gives_none() {
let mut ifd0 = rgb_ifd();
ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] });
let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None);
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod cr3;
pub mod crx;
pub(crate) mod crx_wavelet;
pub mod erf;
pub(crate) mod hasselblad;
pub mod kodak;
pub mod mef;
pub mod nef;
+4 -1
View File
@@ -293,7 +293,7 @@ pub fn parse_color(v: &Value) -> Option<[u8; 3]> {
match v {
Value::String(s) => {
let h = s.trim().strip_prefix('#').unwrap_or(s.trim());
if h.len() != 6 || !h.is_ascii() {
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
@@ -356,6 +356,9 @@ mod tests {
json!({"zoomToFill": 1}),
json!({"strokeColor": "#12345"}),
json!({"strokeColor": "#ééééé"}),
json!({"strokeColor": "#+f+f+f"}),
json!({"backdropColor": "+12345"}),
json!({"washColor": "#-00000"}),
json!({"washColor": [256, 0, 0]}),
json!({"washColor": [-1, 0, 0]}),
json!({"backdropColor": [1, 2]}),
+53 -1
View File
@@ -127,6 +127,13 @@ impl ImageInfo {
let (cw, ch) = self.chunk_size();
let n = self.offsets.len().min(per_plane.saturating_mul(planes));
let mut out = Vec::with_capacity(n);
// the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk)
let mut sorted: Vec<u64> = Vec::new();
if (0..n).any(|i| self.byte_counts.get(i).is_none_or(|&l| l == 0)) {
sorted = self.offsets.clone();
sorted.sort_unstable();
sorted.dedup();
}
for i in 0..n {
let plane = i / per_plane.max(1);
let j = i % per_plane.max(1);
@@ -142,7 +149,7 @@ impl ImageInfo {
Some(&l) if l > 0 => l,
_ => {
// infer: up to the next larger offset, else end of data
let next = self.offsets.iter().copied().filter(|&o| o > offset).min().unwrap_or(data_len);
let next = sorted.get(sorted.partition_point(|&o| o <= offset)).copied().unwrap_or(data_len);
next.saturating_sub(offset)
}
};
@@ -162,3 +169,48 @@ pub fn chunk_bytes<'a>(data: &'a [u8], c: &Chunk) -> Option<&'a [u8]> {
let end = (c.offset.saturating_add(c.len)).min(data.len() as u64) as usize;
Some(&data[start..end])
}
#[cfg(test)]
mod tests {
use super::*;
fn strips(offsets: Vec<u64>, byte_counts: Vec<u64>) -> ImageInfo {
ImageInfo {
width: 1,
height: offsets.len() as u32,
bits_per_sample: vec![8],
samples_per_pixel: 1,
compression: 1,
photometric: 1,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Strips { rows_per_strip: 1 },
offsets,
byte_counts,
}
}
#[test]
fn missing_byte_counts_run_to_the_next_larger_offset() {
let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]);
let lens: Vec<u64> = info.chunks(100).iter().map(|c| c.len).collect();
assert_eq!(lens, vec![20, 20, 5, 20, 30]);
}
/// A million strips without byte counts (offsets in descending order) are measured in one sort, not one
/// scan of all offsets per strip.
#[test]
fn many_strips_without_byte_counts_are_fast() {
let n = 1_000_000u64;
let info = strips((0..n).rev().map(|i| 1000 + i * 2).collect(), Vec::new());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let chunks = info.chunks(1000 + n * 2);
let _ = tx.send(chunks.iter().map(|c| c.len).collect::<Vec<u64>>());
});
let lens = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("chunks() took longer than 20 s");
assert!(lens.iter().all(|&l| l == 2));
}
}
+2 -2
View File
@@ -2575,7 +2575,7 @@
"on the CPU, are not waited for).": "CPU-ზე, არ ელოდება).",
"on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "DLL-ების საძიებო გზაზეა მისი `dxil.dll`-ის გარეშე (ხარვეზი #471): DirectX 12 შეიდერები ახლა ყოველთვის კომპილირდება",
"on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "ბარათზე იმპორტის გადახედვის შემდეგ, აღირიცხება იმის ნაცვლად, რომ მოძველებული მონაცემებით იმპორტირდეს. ექსპორტის მისამართის შემოწმება",
"on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; ✕ აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის",
"on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; × აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის",
"once you zoom out or move on.": "როცა დააპატარავებთ ან სხვაზე გადახვალთ.",
"one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "სათითაოდ — მათი ხელახლა ექსპორტი ყოველთვის შეიძლება, ხოლო USB დისკზე ან NAS-ზე თითოეული ფაილის სინქრონიზაცია დიდ",
"only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "მხოლოდ კამერის მამრავლებსა და კელვინებს შორის გარდაქმნის; ფერის იერი ისეთივეა, როგორიც იყო.",
@@ -2615,7 +2615,7 @@
"renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "რენდერები და ეკრანის ანაბეჭდები კვლავ დროებით ფაილში იწერება და ადგილზე გადაირქმევა, მაგრამ დისკზე იძულებით აღარ ჩაიწერება",
"reports each row's `label`.": "თითოეული სტრიქონის `label`-ს აბრუნებს.",
"restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "ფოტოს ასევე აღადგენს და ამბობს „ახლახან წაშლილიდან აღდგა 1 ფოტო“. წაშლილი ფოტო, რომლის ფაილიც გადაიტანეს,",
"runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (✕ აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს",
"runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (× აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს",
"save goes through.": "შენახვა შესრულდება.",
"saved in": "შენახულია:",
"saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).",
+2 -2
View File
@@ -249,7 +249,7 @@
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
"{what} merge cancelled": "已取消{what}合併",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)",
"New Folder in “{name}”": "在「{name}」中新增資料夾",
"Moved {} photo(s)": "已移動 {} 張照片",
"not added to Apple Photos: {e}": "未加入 Apple 照片:{e}",
@@ -292,7 +292,7 @@
"{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}",
"Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}",
"Renames {n} face{} across the library. The files are not changed.": "在整個照片庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
+17 -17
View File
@@ -44,7 +44,7 @@
"Add to My Photos": "加入我的照片",
"Can't be moved here": "無法移到這裡",
"This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在照片庫中:請先「加入我的照片」,再移動",
"Add to Selected Photos": "加入選取的照片",
"Add to Target Album": "加入目標相簿",
"Add to this mask": "加入此遮色片",
@@ -280,7 +280,7 @@
"Move Photos": "移動照片",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。",
"Don't ask again": "不再詢問",
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容",
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個照片庫:未移動任何內容",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟",
"Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認",
"Constrain Crop": "限制裁切範圍",
@@ -1938,7 +1938,7 @@
"Paper": "紙白",
"Show less": "收合",
"Quit while tasks are running?": "要在工作執行時結束嗎?",
"Exporting contact sheet": "正在匯出聯絡表",
"Exporting contact sheet": "正在匯出縮圖目錄",
"Reading folder": "正在讀取檔案夾",
"Scanning folder": "正在掃描檔案夾",
"Building previews": "正在建立預覽",
@@ -1953,7 +1953,7 @@
"Looking for changes": "正在尋找變更",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。",
"Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片",
"Albums hold library photos: Add to My Photos first": "相簿只能收錄照片庫中的照片:請先加入我的照片",
"Add to My Photos First": "先加入我的照片",
"Fill Location from GPS": "根據 GPS 填寫位置",
"Download Place Names (GeoNames)": "下載地名資料 (GeoNames)",
@@ -1978,14 +1978,14 @@
"No imported folders yet": "還沒有匯入的資料夾",
"Show Sidebar Section": "顯示側邊欄區段",
"Move Sidebar Section": "移動側邊欄區段",
"New Library…": "新增圖庫…",
"Open Recent Library": "開啟最近的圖庫",
"Recent Libraries": "最近的圖庫",
"Clear Recent Libraries": "清除最近的圖庫",
"No Recent Libraries": "沒有最近的圖庫",
"New Library: Choose Where to Put It": "新增圖庫:選擇存放位置",
"New Library": "新增圖庫",
"Library name": "圖庫名稱",
"New Library…": "新增照片庫…",
"Open Recent Library": "開啟最近的照片庫",
"Recent Libraries": "最近的照片庫",
"Clear Recent Libraries": "清除最近的照片庫",
"No Recent Libraries": "沒有最近的照片庫",
"New Library: Choose Where to Put It": "新增照片庫:選擇存放位置",
"New Library": "新增照片庫",
"Library name": "照片庫名稱",
"New Folder on Disk": "在磁碟上新增資料夾",
"New Folder on Disk…": "在磁碟上新增資料夾…",
"Move to Folder": "移至資料夾",
@@ -2004,10 +2004,10 @@
"On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。",
"Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。",
"Lightroom Catalog Import": "Lightroom 目錄匯入",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。",
"Show in Library": "在圖庫中顯示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。",
"Destination library": "目標圖庫",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的照片庫並再次匯入目錄以儲存相片。",
"Show in Library": "在照片庫中顯示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的照片庫,不會建立獨立的目錄資料夾。",
"Destination library": "目標照片庫",
"Playback": "播放",
"Layout": "版面",
"Link all margins": "連結所有邊界",
@@ -2028,7 +2028,7 @@
"Background colour": "背景顏色",
"Colour wash": "顏色漸層",
"Background image": "背景影像",
"(not in the library)": "(不在圖庫中)",
"(not in the library)": "(不在照片庫中)",
"Use selected photo": "使用所選照片",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。",
"Reset layout": "重設版面",
+51
View File
@@ -1772,6 +1772,23 @@ mod tests {
h.settle(SETTLE);
}
/// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered.
#[test]
fn hovering_the_applied_profile_drops_the_last_hover() {
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.profiles"}), t);
h.settle(SETTLE);
h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t);
h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t);
h.step();
assert!(h.app.hover_preview.is_some(), "another profile previews");
h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t);
h.step();
assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one");
}
/// Presets column: resting on a preset previews it in the loupe without a history entry;
/// thumbnails are variant renders; Create Preset includes only the checked groups.
#[test]
@@ -4292,6 +4309,40 @@ mod tests {
h.settle(SETTLE);
}
/// #911 (toast labels only): cycling the overlay announces each mode in the UI language.
#[test]
fn info_overlay_toasts_are_painted_in_the_users_language() {
use crate::i18n::Locale;
struct RestoreLanguage(Locale);
impl Drop for RestoreLanguage {
fn drop(&mut self) {
crate::i18n::set_language(self.0);
}
}
let _restore = RestoreLanguage(crate::i18n::language());
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true);
for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] {
assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true);
assert_eq!(h.app.ui.language, locale);
assert_eq!(crate::i18n::language(), locale);
for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] {
let expected = locale.tr(key);
if locale != Locale::En {
assert_ne!(expected, key, "this locale must translate the tested message");
}
assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true);
assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode));
assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected);
h.step();
let painted = h.painted_text();
assert!(painted.iter().any(|text| text == expected), "{painted:?}");
}
}
}
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
#[test]
+9 -1
View File
@@ -749,6 +749,12 @@ mod tests {
});
}
/// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped
/// script have no glyph for them and egui's bundled fonts do not either: the menu triangles,
/// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads
/// the same and every face has it.
const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕'];
#[test]
fn translated_catalogs_do_not_use_missing_menu_triangles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
@@ -759,7 +765,9 @@ mod tests {
}
let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for (source, text) in messages {
assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display());
for bad in UNPAINTABLE {
assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display());
}
}
}
}
+1 -1
View File
@@ -705,7 +705,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
crate::state::InfoOverlay::Basic => "Info Overlay: File",
crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure",
};
app.toast(&ctx, label);
app.toast(&ctx, crate::i18n::tr(label));
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.chooseLayoutImage" => {
+16 -7
View File
@@ -108,8 +108,6 @@ pub struct MergeState {
pub last_result: Option<Value>,
/// Photos the dialog merges (the selection when it opened).
pub ids: Vec<PhotoId>,
/// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings.
pub last: std::collections::HashMap<String, MergeDialog>,
/// HDR for Each Bracket Set: the sets still to merge after the running one.
pub batch: std::collections::VecDeque<Vec<PhotoId>>,
}
@@ -148,7 +146,15 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b
Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None })
}
/// Open the merge dialog for the selection.
/// The options of the last merge of kind `command` started (kept in the library preferences), or
/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their
/// defaults; options that don't parse at all fall back to the defaults.
pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog {
let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::<MergeDialog>(v.clone()).ok());
MergeDialog { command: command.into(), ..saved.unwrap_or_default() }
}
/// Open the merge dialog for the selection, with the options of the last merge of this kind.
pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
let ids = app.session.selected();
if ids.len() < 2 {
@@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
if let Some(t) = app.merge.preview_task.take() {
t.cancel.store(true, Ordering::Relaxed);
}
app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) });
app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) });
Ok(json!({"photos": app.merge.ids.len()}))
}
@@ -180,7 +186,10 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value,
guard.progress(0, 1000);
task.guard = Some(guard);
app.merge.final_task = Some(task);
app.merge.last.insert(opts.command.clone(), opts.clone());
app.session.last_merge.insert(opts.command.clone(), opts.params());
if let Err(e) = app.session.save_prefs() {
log::warn!("merge: {e}");
}
Ok(json!({"started": true, "photos": ids.len()}))
}
@@ -192,7 +201,7 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin
return Err("select at least 2 photos to merge".into());
}
app.merge.ids = ids;
let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command));
let opts = last_options(app, command);
start_final(app, &opts)
}
@@ -217,7 +226,7 @@ pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> {
let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) };
app.merge.ids = ids;
let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr"));
let opts = last_options(app, "merge.hdr");
start_final(app, &opts).map(|_| ())
}
+3 -1
View File
@@ -1580,8 +1580,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let _ = app.run("tool.done", json!({}));
return;
}
// egui reports a drag only once the pointer has moved a few px: the zone is where the button
// went down, where the cursor showed it, not where the pointer is by then
if resp.drag_started()
&& let Some(q) = resp.interact_pointer_pos()
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos())
{
if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null))
{
+3
View File
@@ -188,6 +188,9 @@ fn cell_ui(
app.hover_preview =
Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string())));
} else if resp.hovered() && applied {
// resting on the applied profile shows it (no preview), not the last other profile hovered
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover")));
}
let resp = resp.on_hover_text(format!(
"{} ({})",
+54 -4
View File
@@ -420,6 +420,19 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi,
patch(app, &ctx, json!({"page": {"background": c}}));
}
});
if compose::has_page_options(l.style) {
ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label));
let p = &st.page;
for (id, label, on, key) in [
("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"),
("printPageInfo", "Page Info", p.page_info, "pageInfo"),
("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"),
] {
if let Some(v) = check(ui, id, label, on) {
patch(app, &ctx, json!({"page": {(key): v}}));
}
}
}
heading(ui, t, "Print Job");
row(ui, t, "Resolution", |ui| {
@@ -607,11 +620,13 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s
Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(),
None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(),
};
let mut shown = Vec::new();
for (i, (r, photo)) in cells.iter().enumerate() {
let rect = sheet.rect(r);
register(&ctx, format!("print:gridcell:{i}"), rect);
paint_cell(app, &painter, t, st, rect, *photo, &sheet, r);
shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r));
}
page_options(&painter, st, &sheet, &shown, pu.page + 1);
}
// footer: page navigation and the selected cell
@@ -782,7 +797,7 @@ fn paint_cell(
photo: Option<PhotoId>,
sheet: &Sheet,
inches: &layout::Rect,
) {
) -> Option<Rect> {
let im = &st.image;
if st.guides.show_cells {
painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside);
@@ -794,7 +809,7 @@ fn paint_cell(
};
let avail = cell.shrink(border);
if avail.width() < 1.0 || avail.height() < 1.0 {
return;
return None;
}
// the photo's shape: its cropped size, or 3:2 for a placeholder
let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| {
@@ -804,7 +819,7 @@ fn paint_cell(
let scale = 8.0; // sub-point resolution for the rounding in `place`
let aw = (f64::from(avail.width()) * scale).round() as usize;
let ah = (f64::from(avail.height()) * scale).round() as usize;
let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return };
let p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?;
let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32);
let visible = shown.intersect(avail);
if border > 0.0 {
@@ -856,6 +871,41 @@ fn paint_cell(
painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160));
painter.galley(at, galley, Color32::WHITE);
}
Some(visible)
}
/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each
/// photo, the page number at the bottom right and the print settings at the bottom left.
/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]).
fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) {
if !compose::has_page_options(st.layout.style) {
return;
}
let p = &st.page;
let ink = compose::mark_ink(p.background);
let ink = Color32::from_rgb(ink[0], ink[1], ink[2]);
if p.crop_marks {
let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale);
let stroke = Stroke::new(1.0, ink);
for r in photos {
for (corner, dx, dy) in
[(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)]
{
painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke);
painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke);
}
}
}
let Ok(area) = st.layout.printable() else { return };
let area = sheet.rect(&area);
let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0));
let y = (area.bottom() + sheet.page.bottom()) / 2.0;
if p.page_numbers {
painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink);
}
if p.page_info {
painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink);
}
}
fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) {
+33
View File
@@ -361,6 +361,39 @@ fn final_merge_shows_a_row_and_cancels() {
let _ = std::fs::remove_dir_all(dir);
}
/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last
/// Settings" uses them; a kind never merged opens with the defaults.
#[test]
fn merge_dialog_remembers_the_last_settings_per_kind() {
let (mut h, dir) = bracket("merge-last");
let opts = crate::merge::MergeDialog {
align: false,
deghost: "high".into(),
auto_settings: false,
..crate::merge::MergeDialog::for_command("merge.hdr")
};
crate::merge::open(&mut h.app, "merge.hdr").unwrap();
h.app.ui.dialog = None;
crate::merge::start_final(&mut h.app, &opts).unwrap();
let row = h.app.session.activity.list()[0].id;
h.app.session.activity.cancel(row).unwrap();
assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
crate::merge::open(&mut h.app, "merge.hdr").unwrap();
let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") };
assert_eq!(shown, &opts);
crate::merge::open(&mut h.app, "merge.panorama").unwrap();
let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") };
assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama"));
h.app.ui.dialog = None;
// older or hand-edited preferences: missing fields take their defaults, junk is ignored
h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"}));
h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk"));
let pano = crate::merge::last_options(&h.app, "merge.panorama");
assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true));
assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama"));
let _ = std::fs::remove_dir_all(dir);
}
/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the
/// rest.
#[test]
+39
View File
@@ -415,3 +415,42 @@ fn rotating_the_crop_dims_nothing_inside_the_box() {
let lum = |c: egui::Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b());
assert!(lum(pixel(&rotated, corner_px)) < lum(pixel(&plain, corner_px)), "the photo's corner, outside the box, is dimmed");
}
fn crop_rect(h: &Headless) -> lightcraft_geom::Rect {
let id = h.app.session.active().expect("a photo");
h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.rect
}
/// Press where the pointer shows the resize cursor for the left edge handle, `dx` px from the
/// edge, and drag on by `step` px per event: the drag must resize, as the cursor said (issue #1930).
fn drag_left_handle(dx: f32, step: f32) -> Headless {
let mut h = crop_tool();
let img = h.app.image_rect.expect("the photo on screen");
hover(&mut h, egui::pos2(img.left() + dx, img.center().y));
assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal, "the pointer shows a resize");
let x = |px: f32| px / img.width();
let events: Vec<_> = std::iter::once(json!({"kind": "down", "x": x(dx), "y": 0.5}))
.chain((1..=4).map(|i| json!({"kind": "drag", "x": x(dx + step * i as f32), "y": 0.5})))
.collect();
let r = h.request("ui.pointer", json!({ "events": events }), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h
}
/// Just outside the box, inside the handle's reach: the drag resizes, it does not rotate.
#[test]
fn a_drag_from_just_outside_a_handle_resizes_not_rotates() {
let h = drag_left_handle(-8.0, -3.0);
assert_eq!(angle(&h), 0.0, "the resize cursor showed, so nothing rotates");
assert!(!has(&h, "cropAngleReadout"), "no rotation under way");
}
/// Just inside the box, inside the handle's reach: the drag moves the left edge, not the box.
#[test]
fn a_drag_from_just_inside_a_handle_resizes_not_moves() {
let h = drag_left_handle(8.0, 3.0);
let r = crop_rect(&h);
assert!(r.x0 > 0.0, "the left edge moved in: {r:?}");
assert!((r.x1 - 1.0).abs() < 1e-6, "the right edge stays: {r:?}");
}
+29
View File
@@ -378,3 +378,32 @@ fn the_print_preview_does_not_rebuild_thumbnails_every_frame() {
}
assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before);
}
#[test]
fn page_options_checkboxes_set_the_page_and_hide_for_packages() {
let mut h = demo(Services::default());
open_print(&mut h);
let scroll = |h: &mut Headless, dy: f32| {
let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..4 {
h.request("ui.scroll", json!({"dy": dy}), T);
h.settle(T);
}
};
// the Page panel is below Page Setup: scroll down to it
scroll(&mut h, -600.0);
for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] {
click(&mut h, id);
let p = &h.app.session.print.page;
assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}");
}
let p = &h.app.session.print.page;
assert!(p.page_numbers && p.page_info && p.crop_marks);
scroll(&mut h, 600.0);
click(&mut h, "button:printStyle-1");
assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options");
// Lightroom's packages have no Page Options
click(&mut h, "button:printStyle-2");
assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks"));
}
+5 -1
View File
@@ -173,7 +173,11 @@ For 52 camera models LightCraft has colour matrices fitted to the camera's measu
(`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from
[rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon,
11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for
the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits,
the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100), and the bodies `SAME_SENSOR`
in `spectral.rs` adds by hand because the table is generated: Fujifilm's 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II,
X-Pro3, X100V, X-S10, X-E4) take the X-T3's matrices. Without them an X-T30 raw whose camera JPEG was shot in Acros
(black and white) got its tone curve on the muted neutral fallback, with a grey sky where the scene had a deep blue
one. Per model the generator fits,
under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the
least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and
`ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the
+1 -1
View File
@@ -77,7 +77,7 @@ pool. A scalar implementation checks the optimized operators on synthetic networ
Tract is not a workspace dependency or a product feature.
CPU inference and the GPU model module are feature-gated; ordinary engine/web builds
do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`;
lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most
lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 110 megapixels; at most
four tile calls run together, chosen within the process-wide working-memory budget.
One idle CPU workspace is retained, and cache costs reserve room for the blended picture.
+5 -3
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -15,7 +15,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| # | Gap | Evidence | User impact | Hours | Doc |
|---:|---|---|---|---:|---|
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices (plus same-sensor bodies, e.g. the X-T30 through the X-T3); others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) |
| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) |
@@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) |
| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) |
| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) |
| 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) |
| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) |
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
@@ -77,6 +77,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) |
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
+9 -5
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered)Line truncated
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, SamsuLine truncated
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit.Line truncated
| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
@@ -355,7 +355,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap |
| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF |
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |
## P. Enhance (ENH)
@@ -390,7 +390,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build |
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) |
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
@@ -762,7 +762,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets |
| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview |
| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package |
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info |
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides |
| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode |
| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files |
| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | |
@@ -797,6 +797,10 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. |
| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) |
| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) |
| 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) |
| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) |
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) |
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) |
+7 -4
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -22,7 +22,7 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured |
| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence |
| …of which on a body we have verified | **~85%** | estimated, same weights |
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
| Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured |
| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) |
@@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
| DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) |
| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 |
| DNG | GoPro GPR (VC-5) | ⬜ | refused |
| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified |
| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF |
| 3FR / FFF | Hasselblad compression | ⬜ | preview |
| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` |
| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) |
@@ -205,7 +205,7 @@ sample files. A pointer for contributors, not legal advice.
| Item | Adobe | LightCraft |
|---|---|---|
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix |
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 X-Trans CMOS 4 Fujifilm bodies take the X-T3's); else a neutral matrix |
| Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) |
| Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) |
| DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) |
@@ -287,6 +287,9 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback |
| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged |
| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) |
| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) |
| 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% |
+5
View File
@@ -2,6 +2,11 @@
## 2026-10-14
### Model downloads time out on a name that doesn't resolve
- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout,
and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time
limit, and each of a server's addresses then got the whole connect timeout of its own.
### Lightroom radial masks on the right side
- Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now
apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in
+2 -1
View File
@@ -192,11 +192,12 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct
| `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV |
| `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 |
| `LocalToningHue` | `adjust.color_hue` | degrees |
| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) |
| `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point |
| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape). `Flipped="true"` means effect inside: in `MaskGroupBasedCorrections` with `Version` 2 (what Lightroom writes for an ordinary radial) and in the legacy `CircularGradientBasedCorrections` Radial Filter, whose default (no `Flipped`) is outside. A `MaskGroupBasedCorrections` radial without `Version` is inside whatever `Flipped` says, and its `Feather` is halved (Lightroom rescales it that way when it upgrades one). Inverting is `MaskInverted` |
| `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases |
| `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed |
| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported |
| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported |
| `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | |
Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount