mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
Merge storytold/lightcraft main into lightroom-import-skip-orphan-images
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
This commit is contained in:
Generated
+1
@@ -3140,6 +3140,7 @@ version = "0.9.1"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"dhat",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
|
||||
<b>Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.</b><br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
|
||||
</p>
|
||||
|
||||
@@ -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,17 +333,21 @@ 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%** (core features 98%), tracked row by row in
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
|
||||
- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **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
|
||||
@@ -491,9 +513,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp`
|
||||
|
||||
Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version:
|
||||
|
||||
- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
|
||||
- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
|
||||
work from public specs and black-box observation.
|
||||
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
|
||||
- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every
|
||||
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
|
||||
@@ -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` | |
|
||||
@@ -623,8 +645,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+5
-7
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
|
||||
<b>照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。</b><br>
|
||||
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
|
||||
</p>
|
||||
|
||||
@@ -299,9 +299,9 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
|
||||
- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
|
||||
[docs/parity.md](docs/parity.md)。
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
@@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
|
||||
|
||||
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
|
||||
|
||||
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
只依据公开规范和黑盒观察来工作。
|
||||
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
|
||||
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
|
||||
@@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改
|
||||
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
|
||||
分支和修改版本必须移除它们。
|
||||
|
||||
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 { .. });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+295
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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]}),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).",
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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": "重設版面",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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!(
|
||||
"{} ({})",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+7
-4
@@ -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% |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user