diff --git a/CLAUDE.md b/CLAUDE.md index 38f5bde4a..4a736f12a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI - (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset, `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules diff --git a/Cargo.lock b/Cargo.lock index e15467671..60fe1d8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,9 +3078,15 @@ dependencies = [ "lightcraft-ui-egui", "log", "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pollster 1.0.1", "rfd", "serde_json", + "winit", "winresource", + "zbus", ] [[package]] @@ -3091,6 +3097,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-geom", "lightcraft-meta", + "lightcraft-raw", "log", "proptest", "serde", @@ -3105,6 +3112,7 @@ version = "0.6.0" dependencies = [ "dhat", "lightcraft-codecs", + "lightcraft-denoise", "lightcraft-engine", "lightcraft-mcp", "lightcraft-merge", @@ -3210,9 +3218,11 @@ dependencies = [ "lightcraft-develop", "lightcraft-faces", "lightcraft-fetch", + "lightcraft-geocode", "lightcraft-geom", "lightcraft-gpu", "lightcraft-heif", + "lightcraft-lensdb", "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", @@ -3258,6 +3268,14 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "lightcraft-geocode" +version = "0.6.0" +dependencies = [ + "miniz_oxide 0.8.9", + "serde", +] + [[package]] name = "lightcraft-geom" version = "0.6.0" @@ -3292,6 +3310,22 @@ dependencies = [ "rayon", ] +[[package]] +name = "lightcraft-inpaint" +version = "0.6.0" +dependencies = [ + "rayon", +] + +[[package]] +name = "lightcraft-lensdb" +version = "0.6.0" +dependencies = [ + "lightcraft-develop", + "lightcraft-geom", + "quick-xml 0.38.4", +] + [[package]] name = "lightcraft-mcp" version = "0.6.0" @@ -3339,6 +3373,7 @@ dependencies = [ "lightcraft-color", "lightcraft-develop", "lightcraft-geom", + "lightcraft-inpaint", "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", diff --git a/Cargo.toml b/Cargo.toml index d2d837254..a22e269c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ lightcraft-raw = { path = "crates/raw" } lightcraft-codecs = { path = "crates/codecs" } lightcraft-meta = { path = "crates/meta" } lightcraft-develop = { path = "crates/develop" } +lightcraft-lensdb = { path = "crates/lensdb" } +lightcraft-inpaint = { path = "crates/inpaint" } lightcraft-scenes = { path = "crates/scenes" } lightcraft-pipeline = { path = "crates/pipeline" } lightcraft-gpu = { path = "crates/gpu" } @@ -36,6 +38,7 @@ lightcraft-mcp = { path = "crates/mcp" } lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } +lightcraft-geocode = { path = "crates/geocode" } lightcraft-slideshow = { path = "crates/slideshow" } # foundation @@ -89,6 +92,13 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) +# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same) +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] } +# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸ +# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it. +zbus = "=5.19.0" web-time = "1" # web (apps/lightcraft-web) diff --git a/NOTICE b/NOTICE index 5fd60227f..c9d650bb1 100644 --- a/NOTICE +++ b/NOTICE @@ -22,6 +22,12 @@ Foundation project licensed under the Apache License 2.0, and from the CIE 1931 functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors (2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md. +crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane +fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and +poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache +License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each +ported file says so. + crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers (https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker), Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0. @@ -48,3 +54,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai (the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's licence as OFL-.txt. + +Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and +are never distributed with it. On the user's request (Photo > Download Place Names) the app +downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames +(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed +under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/); +attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own. diff --git a/README.md b/README.md index 1a7ca07f8..a82d1df77 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@

Your photos. Your pixels. Your machine.

+

+ English · + 简体中文 +

+

Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP. @@ -372,7 +377,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; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). +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) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). @@ -487,6 +492,9 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window RISC-V (riscv64): `lightcraft--linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+). +On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives +windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop). + ### FreeBSD | Build | File | diff --git a/README.zh-hans.md b/README.zh-hans.md new file mode 100644 index 000000000..9a5331435 --- /dev/null +++ b/README.zh-hans.md @@ -0,0 +1,567 @@ +

+ + + + ArtCraft + + +

+ + +

LightCraft

+ +

Your photos. Your pixels. Your machine.

+ +

+ English · + 简体中文 +

+ +

+ 照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。
+ 在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。 +

+ +

+ Pure Rust + macOS, Windows, Linux and Web + MCP server included + License: MIT OR Apache-2.0 + Status: young and moving fast +

+ +

+ Join the ArtCraft community on Discord +

+ +

+ getartcraft.com 上的 LightCraft · + ArtCraft · + 全部 Crafting Apps +

+ +
+ +

+ LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片 +
+ 安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。 +

+ +> [!NOTE] +> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 — +> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。** + +

+ 编辑 · + 颜色分级 · + 修改前后对比 · + 蒙版 · + 预设 · + 图库 · + 智能体与 MCP · + 性能 · + 功能状态 · + 快速开始 · + 路线图 · + 下载 · + Crafting Apps +

+ +
+ +## Edit like you mean it + +LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。 +每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕, +色彩从拍摄到导出始终保持干净。 + + + + + + +
+ +### ☀️ Light +**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。 +把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。 + +### 🎨 Color +按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。 +保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的 +三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。 + + + +### ✨ Effects +让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化; +推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的 +胶片**颗粒**。 + +### 📈 Tone Curve +带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的, +所以不会意外出现影调反转。 + +
+ +

+ Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》 +
+ RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。安塞尔·亚当斯,1942(公有领域)。 +

+ +
+ +## Color grading, the cinematic way + +用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的 +*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。 + +

+ 中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调 +
+ Color 面板中的阴影、中间调和高光色轮。多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。 +

+ +
+ +## Before & after + +按住 \\ 偷看原图,按 Y 并排对比,或按 Shift+Y 分屏查看。 +下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。 + + + + + + + + + + +
《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔
《特顿山脉与蛇河》。 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。安塞尔·亚当斯,1942(公有领域)。
《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮
《移民母亲》。 阴影 +42、纹理 +18、分离色调、暗角。多萝西娅·兰格,1936(公有领域)。
《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁
《地出》。 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。
《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实
《蓝色弹珠》。 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。NASA,阿波罗 17 号,1972(公有领域)。
+ +
+ +## Masking that goes where you point + +用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**, +或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件, +任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、 +清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。 + +

+ 蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围 +
+ 叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。照片来自 LightCraft 程序生成的演示图库。 +

+ +
+ +## Presets, profiles & the Color Mixer + +内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp +Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏, +并在整个选区之间只复制、粘贴或同步你选定的那些分组。 + +

+ 预设列按 B&W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上 +
+ 预设列与 8 色段颜色混合器并排。照片来自演示图库。 +

+ + + + + + + + + + +
+ +### ✂️ Crop, straighten & geometry +自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框 +保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、 +缩放和位移变换。 + + + +### ⚫️ Black & White +一键(V)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。 +之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。 + +
《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块
一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。安塞尔·亚当斯,1942(公有领域)。
沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白
分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。演示图库。
+ +
+ +## Organize everything + +一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**, +以及**最近删除**。用 0–5 评星,用 P / X / +U 加旗标,用 6–9 加颜色标签。搜索支持字段: +`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、 +导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。 +两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化, +所以无论是四十张还是四万张照片都同样顺滑。 + + + + + + + + + +
24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册
照片网格,相册嵌套在文件夹里,带星级和旗标。演示图库。
正方形网格,显示四张公有领域展示照片及其星级和留用旗标
四张公有领域展示照片的正方形网格。
《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据
信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。
+ +
+ +## Built for agents + +LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和 +JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。 +智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。 + +```sh +lightcraft --control 7980 ~/Pictures/trip +``` +```jsonc +{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}} +{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}} +{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}} +{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件 +{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}} +{"method": "ui.screenshot", "params": {"path": "after.png"}} +``` + +- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、 + 默认值和当前值。 +- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端), + 同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着 + 到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。 + + ```sh + cargo build --release -p lightcraft-cli + claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面 + claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用 + ``` +- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export + path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用), + 每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。 +- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。 +- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、 + 行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入 + 而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 CLI 调用: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true + ``` + + `export.contactSheet` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后, + 它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化 + (中日韩文字需要 craft-fonts),过长的名称会在中间省略。 + 这是一套联系表工作流, + 不是打印驱动或自定义拼版编辑器。 +- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。 +- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。 + 协议参考:[docs/control-protocol.md](docs/control-protocol.md)。 + +
+ +## Fast, native, private + +- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、 + 松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、 + PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择, + 与 PhotoCraft 中一致)。 +- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪, + raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。 +- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出 + 看起来完全一致。 +- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12), + 并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染, + 冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。 + 没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片, + 所以一张的解码和编码能与另一张的渲染重叠。 +- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上 + (24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。 +- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图: + 拖动时用草稿,松手后出全质量。 +- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志, + 可以 diff、备份或重放。 + +
+ +## Feature status + +LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): + +- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在 + [docs/parity.md](docs/parity.md)。 +- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ + 较老佳能的 raw 都很出色。 +- **最大的缺口:** + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); + - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; + - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** +- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。 + +| 领域 | 状态 | +|---|---| +| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ | +| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ | +| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ | +| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) | +| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ | +| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ | +| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 | +| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ | +| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ | +| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ | +| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 | +| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ | +| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ | +| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ | +| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ | +| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ | +| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ | +| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ | +| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ | +| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 | +| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) | +| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 | + +✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始 + +
+ +## Quick start + +```sh +git clone https://github.com/storytold/lightcraft && cd lightcraft +cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片) +cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项) +cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容) +cargo run --release -p lightcraft -- --control 7980 # 带自动化通道 +cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/ +cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 +cargo xtask ci # fmt、clippy、测试、分层、wasm 检查 +``` + +CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务 +(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建, +而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的 +完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置 +`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。 +日常开发命令保持它们各自的设置。 + +**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它): + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft +``` + +不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到 +本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。 + +**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 → +语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。 + +**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log` +(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS +`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。 +从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志 +文件夹** 会在文件管理器中显示它)。每次启动会把 +上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后 +保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带 +`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到 +`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和 +错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为 +env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以 +`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里, +并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。 + +网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI +(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。 + +**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文 +文字有字形): + +```sh +nix run github:storytold/lightcraft # 桌面应用 +nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli} +nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体 +``` + +在 flake 配置中(NixOS、home-manager、nix-darwin): + +```nix +# flake.nix +inputs.lightcraft.url = "github:storytold/lightcraft"; +# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个 +# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs"; + +# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内): +nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用 +environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ]; +``` + +`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行 +`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。 + +**键盘:** G 网格 · D 细节 · E 编辑 · C 裁剪 · M 蒙版 · +Shift+P 预设 · \\ 原图 · Y 修改前后 · Z 缩放 · +J 溢出警告 · ⌘Z 撤销 · ⌘/ 全部快捷键。 + +## How it's built + +一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、 +`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。 +egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、 +`lightcraft-cli`)坐落在 `engine` 之上。 + +## Contributing + +人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本: + +- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); + 只依据公开规范和黑盒观察来工作。 +- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 + 图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中 + 于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到 + [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。 +- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过 + (一个提交一个任务 ID)。 +- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或 + `unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见 + [AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。 + +有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。 + +
+ +## Downloads + +**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。 + +**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。 + +### Windows + +| 构建 | 安装程序 | 便携版 | +|---|---|---| +| x64(64 位 Intel/AMD) | `lightcraft--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` | +| arm64(骁龙及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | +| x86(32 位) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | + +安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认 +`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, +并可选择启动它。无人值守安装:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 + +### macOS + +| 构建 | 文件 | 说明 | +|---|---|---| +| 应用,通用(Apple 芯片 + Intel) | `lightcraft--macos-universal.dmg` | 已签名并公证 | +| 命令行工具,通用 | `lightcraft-cli--macos-universal.zip` | 已签名并公证 | + +### Linux + +| 格式 | x86_64 | aarch64 (ARM64) | 说明 | +|---|---|---|---| +| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 | +| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | 沙箱化;`flatpak install --user ` | +| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | | +| 压缩包 | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | 解压到任意位置 | + +### FreeBSD + +| 构建 | 文件 | +|---|---| +| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` | + +### 网页版(WebAssembly) + +| 构建 | 文件 | 说明 | +|---|---|---| +| 静态站点 | `lightcraft-web-.zip` | 在现代浏览器中运行;托管到任意静态服务器 | + +
+ +## The Crafting Apps + +LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具, +每一个都用 Rust 从零编写,每一个都能独立使用。 + +| | 应用 | 用途 | 代码 | 了解更多 | +|:-:|---|---|---|---| +| | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) | +| | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) | +| | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) | +| | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) | +| | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) | +| | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) | +| | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) | + +以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。 + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

+ 我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、 + 排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西, + 寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。 + 无论你用什么媒介,无论你做了多久,这里都欢迎你。 +

+ +

+ discord.gg/artcraft · + getartcraft.com · + The Crafting Apps · + LightCraft +

+ +
+ +## License and credits + +LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。 +Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。 + +随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在 +[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。 + +展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》* +(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA, +*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体: +Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版) +还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其 +[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。 + +[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是 +ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为 +本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。 +分支和修改版本必须移除它们。 + +Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。 + +

+ ArtCraft
+ 由 ArtCraft 团队和社区制作。 +

+ +## Star history + +[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) diff --git a/ROADMAP.md b/ROADMAP.md index c8062d539..c804c36fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Lightroom Classic 15.5 and the installed Lightroom 9.6; restructured to the progress-docs standard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -29,11 +29,11 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%): | Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is -estimated rather than measured, CR3 decodes on three verified Canon bodies, compressed ORF and HE NEF +estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of -raw users, decodes CR3 on three verified bodies). It passes the alpha gate: all six core workflows +raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows (import, cull, develop, save and reopen, batch looks, export) work end to end on macOS ([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user). @@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see | Area | Ready | Breadth | Remaining | |---|---:|---:|---:| | Import and library | 80% | 90% | 25–45 h | -| RAW decoding and camera coverage | 76% | 81% | 120–220 h | +| RAW decoding and camera coverage | 77% | 81% | 120–220 h | | Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | | Global develop adjustments | 70% | 93% | 30–60 h | | Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | @@ -114,6 +114,10 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a + preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files + pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models + verified; RAW decoding 76% → 77%; headline numbers unchanged. - **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change. - 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943 unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a @@ -157,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | | 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h | diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 6471ab86d..b82aca1d5 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] lightcraft-engine = { workspace = true } +lightcraft-denoise = { workspace = true } lightcraft-sysmem = { workspace = true } lightcraft-mcp = { workspace = true } lightcraft-ui-egui = { workspace = true } diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index dbcef01ef..4eee250eb 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -7,6 +7,7 @@ //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] //! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR +//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu] //! lightcraft-cli commands [--json] //! lightcraft-cli controls [--json] //! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… @@ -85,6 +86,18 @@ USAGE: --preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files) lightcraft-cli synth-merge hdr|panorama -o DIR Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views). + lightcraft-cli denoise-serve [OPTIONS] + Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=:7990 and + LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings, + on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN + address (e.g. the Tailscale IP) or firewall the port to it. Options: + --model ID an installed model other than the selected one + --models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's) + --listen ADDR address:port to listen on (default 127.0.0.1:7990) + --token-env VAR environment variable holding the shared token, ≥ 16 characters + (default LIGHTCRAFT_DENOISE_TOKEN) + --run-on WHERE auto (default), gpu or cpu + --max-connections N concurrent clients (default 8) lightcraft-cli commands [--json] list every command id with its parameters lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… @@ -115,6 +128,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String { lightcraft_engine::EngineError::LibraryInUse(why) => format!( "{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`." ), + e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!( + "{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path." + ), e => format!("{dir}: {e}"), } } @@ -145,6 +161,7 @@ fn main() -> ExitCode { Some("merge") => merge(&args[1..]), Some("synth-merge") => synth_merge(&args[1..]), Some("controls") => controls(&args[1..]), + Some("denoise-serve") => denoise_serve(&args[1..]), Some("calibrate") => calibrate(&args[1..]), Some("--version" | "-V" | "version") => { println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION")); @@ -900,3 +917,48 @@ fn controls(args: &[String]) -> Result<(), String> { } Ok(()) } + +/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`). +#[cfg(feature = "denoise")] +fn denoise_serve(args: &[String]) -> Result<(), String> { + use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve}; + use lightcraft_engine::denoise::RunOn; + let mut model: Option = None; + let mut dir: Option = None; + let mut listen = format!("127.0.0.1:{DEFAULT_PORT}"); + let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string(); + let mut run_on = RunOn::Auto; + let mut max_connections = 8usize; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()), + "--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()), + "--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(), + "--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(), + "--run-on" => { + let v = take_value(args, &mut i, "--run-on")?; + run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?; + } + "--max-connections" => { + max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")? + } + other => return Err(format!("denoise-serve: unknown option `{other}`")), + } + i += 1; + } + let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?; + let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?; + let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?; + eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device); + if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") { + eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)"); + } + let opts = ServerOptions { token, max_connections, ..Default::default() }; + serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}"))) +} + +#[cfg(not(feature = "denoise"))] +fn denoise_serve(_: &[String]) -> Result<(), String> { + Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into()) +} diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index ecda1102a..9aafc9310 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -577,3 +577,69 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() { assert!(image.exists()); } } + +/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile +/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused. +#[cfg(feature = "denoise")] +#[test] +fn denoise_serve_runs_tiles_for_another_computer() { + use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain}; + use lightcraft_denoise::remote::Remote; + use lightcraft_denoise::run::TileRunner; + let models = tmp("denoise-models"); + let _ = std::fs::remove_dir_all(&models); + let dir = models.join("synthetic"); + std::fs::create_dir_all(&dir).unwrap(); + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: "made for a test".into(), + domain: Domain::BayerToRgb, + tile: 64, + overlap: 16, + gain: Gain::None, + }; + let onnx = dir.join("model.onnx"); + std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap(); + std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap(); + std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap(); + let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap(); + + // without its token the server doesn't start + let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap(); + assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR")); + + let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let addr = format!("127.0.0.1:{port}"); + let token = "cli-test-token-0123456789"; + let mut child = Command::new(BIN) + .args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"]) + .env("LIGHTCRAFT_DENOISE_TOKEN", token) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // wait for the banner + let mut err = BufReader::new(child.stderr.take().unwrap()); + let mut banner = String::new(); + err.read_line(&mut banner).unwrap(); + assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}"); + + let remote = Remote::new(&addr, token, &sha, 64); + let info = remote.hello().unwrap(); + assert!(info.device.starts_with("CPU"), "{info:?}"); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let got = remote.try_run(&input).unwrap().unwrap(); + let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap(); + let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs())); + let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale; + assert!(worst < 5e-3, "remote vs local: {worst}"); + assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err()); + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&models); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index bbf14ea50..83637cd7e 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -170,6 +170,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen Services { picker: None, pick_display_profile: None, + screen_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip @@ -229,11 +230,14 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) })), pick_folder: None, + // the browser reports prefers-color-scheme to egui + system_theme: None, } } diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 4796740ea..6b297b94f 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -36,6 +36,8 @@ pub struct WireJob { pub raw: bool, #[serde(default)] pub relative_wb: bool, + #[serde(default)] + pub legacy_relative_wb: bool, pub as_shot_temp: f64, pub as_shot_tint: f64, pub lens: Option, @@ -74,6 +76,7 @@ impl WireJob { max_edge, raw: job.info.raw, relative_wb: job.info.relative_wb, + legacy_relative_wb: job.info.legacy_relative_wb, as_shot_temp: job.info.as_shot_temp, as_shot_tint: job.info.as_shot_tint, lens: job.info.lens, @@ -97,6 +100,7 @@ impl WireJob { as_shot_tint: self.as_shot_tint, lens: self.lens, relative_wb: self.relative_wb, + legacy_relative_wb: self.legacy_relative_wb, ..Default::default() } } @@ -332,11 +336,46 @@ impl CacheWatch { } } +/// Hands out the positions that render workers will have in the main thread's worker list. +/// A worker that fails to start gets no position, so the position a callback captures stays the +/// index of its own worker in that list (#229). +#[derive(Debug, Default)] +pub struct WorkerSlots { + next: usize, +} + +impl WorkerSlots { + /// The position the worker being started will have, if it comes up. + pub fn peek(&self) -> usize { + self.next + } + + /// The worker at [`Self::peek`] came up and was added to the list. + pub fn commit(&mut self) { + self.next += 1; + } +} + #[cfg(test)] mod tests { use super::*; use lightcraft_engine::Session; + // Issue #229: a failed worker attempt must not shift the positions of the workers after it + #[test] + fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() { + let mut slots = WorkerSlots::default(); + let mut taken = Vec::new(); + for attempt_ok in [false, true, true, true] { + let i = slots.peek(); + if attempt_ok { + taken.push(i); + slots.commit(); + } + } + assert_eq!(taken, vec![0, 1, 2]); + } + #[test] fn clearing_index_changes_disk_namespace_and_survives_restart() { let key = lightcraft_preview::hash_bytes(b"thumbnail"); @@ -421,6 +460,7 @@ mod tests { max_edge: 512, raw: false, relative_wb: false, + legacy_relative_wb: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 86d54fc66..937f0add2 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -27,7 +27,7 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use crate::backend::Backend; -use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key}; +use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key}; /// Storage key of the thumbnail index. pub const THUMB_INDEX: &str = "thumbs/index.json"; @@ -247,14 +247,17 @@ impl Workers { log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread"); return w; } - for i in 0..n { + let mut slots = WorkerSlots::default(); + for attempt in 0..n { + // The callbacks name their worker by its place in `workers`; a failed attempt takes none. + let i = slots.peek(); let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); - opts.set_name(&format!("lightcraft-render-{i}")); + opts.set_name(&format!("lightcraft-render-{attempt}")); let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) { Ok(x) => x, Err(e) => { - log::warn!("render worker {i}: {e:?}"); + log::warn!("render worker {attempt}: {e:?}"); continue; } }; @@ -270,10 +273,15 @@ impl Workers { set(&init, "module", &module); set(&init, "store", &store.into()); if let Err(e) = worker.post_message(&init) { - log::warn!("render worker {i}: {e:?}"); + log::warn!("render worker {attempt}: {e:?}"); + // It never joins the list, so it must not answer to the index of the next worker. + worker.set_onmessage(None); + worker.set_onerror(None); + worker.terminate(); continue; } w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None }); + slots.commit(); } w } @@ -293,6 +301,7 @@ impl Workers { level: b.level, key: b.key, rendered: Err(msg), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -387,6 +396,7 @@ impl Workers { level: b.level, key: b.key, rendered, + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -440,6 +450,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, loaded: None, quick: None, display: None, diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index e4d7f2b3b..bbe495c7d 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -23,17 +23,28 @@ lightcraft-sysmem = { workspace = true } lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } +# probing the window's renderer before it opens (`window_gpu`) +pollster = { workspace = true } log = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } +objc2 = { workspace = true } +objc2-app-kit = { workspace = true } +objc2-foundation = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs new file mode 100644 index 000000000..74333a301 --- /dev/null +++ b/apps/lightcraft/src/appearance.rs @@ -0,0 +1,198 @@ +//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux +//! desktops whose windowing layer reports no theme to egui (many Wayland compositors). +//! +//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker +//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a +//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer. +//! Elsewhere egui's own report (`Context::system_theme`) is used. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest +/// another variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by +/// absolute path, and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Wake the first frame after a slow initial Read as well as subsequent real changes. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock, code: u8) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = wake.get() + { + ctx.request_repaint(); + } +} + +/// The host's system-appearance service (Linux), or `None` where egui's report is enough. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup is queued rather than lost. + let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, &worker_wake, code_of(initial)); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, &worker_wake, code); + } + }) + .is_err() + { + return None; + } + // The first frame shows the right theme when the portal answers quickly; a slow one + // only delays the switch (the worker wakes the UI when the value changes). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn delayed_initial_read_repaints_only_on_change() { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }; + let ctx = egui::Context::default(); + let settle = |ctx: &egui::Context| { + for _ in 0..8 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + }; + settle(&ctx); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let wake = OnceLock::new(); + assert!(wake.set(ctx.clone()).is_ok()); + let value = AtomicU8::new(0); + super::publish(&value, &wake, 2); + assert_eq!(value.load(Ordering::Relaxed), 2); + let after_change = calls.load(Ordering::Relaxed); + assert!(after_change > 0); + settle(&ctx); + let after_settle = calls.load(Ordering::Relaxed); + super::publish(&value, &wake, 2); + assert_eq!(calls.load(Ordering::Relaxed), after_settle); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_values_and_gsettings_output_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] { + assert_eq!(super::decode(u32::from(super::code_of(theme))), theme); + } + } +} diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs new file mode 100644 index 000000000..9a8cc22d2 --- /dev/null +++ b/apps/lightcraft/src/editors.rs @@ -0,0 +1,242 @@ +//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it, +//! or any other application the user named in Settings ▸ External Editing. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`). +const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT; +/// PhotoCraft's application id (Flatpak, desktop entry). +const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft"; + +/// How an editor is started. +#[derive(Debug, PartialEq)] +enum Launcher { + /// The system's default application for the file. + Default, + /// A program file, given the photo as its argument. + Program(PathBuf), + /// macOS: `open -a ` (an application name or bundle path). + MacApp(String), + /// The PhotoCraft Flatpak, with the file forwarded into its sandbox. + Flatpak, +} + +/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default): +/// for PhotoCraft, that it is installed. The error says what to do instead. +pub fn check(app: &str) -> Result<(), String> { + launcher(app).map(|_| ()) +} + +/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside. +pub fn open_with(path: &str, app: &str) -> Result<(), String> { + let mut c = match launcher(app)? { + Launcher::Default if cfg!(target_os = "macos") => { + let mut c = Command::new("open"); + c.arg(path); + c + } + Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path), + Launcher::Default => { + let mut c = Command::new("xdg-open"); + c.arg(path); + c + } + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app, path]); + c + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.arg(path); + c + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]); + c + } + }; + c.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Windows: open `path` in its default application through Explorer, given the path as its one +/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own +/// syntax (running what follows an `&` as a command). +fn windows_default_command(path: &str) -> Command { + let mut c = Command::new("explorer.exe"); + c.arg(path.replace('/', "\\")); + c +} + +fn launcher(app: &str) -> Result { + let app = app.trim(); + if app.is_empty() { + return Ok(Launcher::Default); + } + if !app.eq_ignore_ascii_case(PHOTOCRAFT) { + return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) }); + } + find_photocraft().ok_or_else(|| { + "PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string() + }) +} + +fn find_photocraft() -> Option { + let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string()); + if cfg!(target_os = "macos") { + // LaunchServices finds the app wherever it is; these are only the usual places + let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app")); + let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); + return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string()))); + } + if cfg!(target_os = "windows") { + let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir")); + let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None); + return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program); + } + // Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which + // registers one on first run), else the Flatpak + let on_path = + env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).map(|d| d.join("photocraft")).find(|p| p.is_file()); + let from_entry = || { + desktop_entry_dirs(env).into_iter().find_map(|d| { + let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; + desktop_exec(&text).filter(|p| p.is_file()) + }) + }; + if let Some(p) = on_path.or_else(from_entry) { + return Some(Launcher::Program(p)); + } + let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success()); + flatpak.then_some(Launcher::Flatpak) +} + +/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI +/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default +/// folders (per machine, then per user). +fn windows_candidates(install_dir: Option, app_path: Option, env: impl Fn(&str) -> Option) -> Vec { + let mut out: Vec = Vec::new(); + out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe"))); + out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"')))); + for var in ["ProgramW6432", "ProgramFiles"] { + out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe"))); + } + out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe"))); + out +} + +/// A string value from the Windows registry (`reg query`; `None` = the key's default value). +fn reg_query(key: &str, name: Option<&str>) -> Option { + let mut c = Command::new("reg"); + c.args(["query", key]); + match name { + Some(n) => c.args(["/v", n]), + None => c.arg("/ve"), + }; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + c.creation_flags(CREATE_NO_WINDOW); + } + let out = c.output().ok().filter(|o| o.status.success())?; + reg_value(&String::from_utf8_lossy(&out.stdout)) +} + +/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line. +fn reg_value(output: &str) -> Option { + output.lines().find_map(|l| { + let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?; + Some(v.trim().to_string()).filter(|v| !v.is_empty()) + }) +} + +/// The folders desktop entries live in (XDG base directories). +fn desktop_entry_dirs(env: impl Fn(&str) -> Option) -> Vec { + let mut dirs = Vec::new(); + match env("XDG_DATA_HOME") { + Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)), + _ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))), + } + let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from)); + dirs.into_iter().map(|d| d.join("applications")).collect() +} + +/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped). +fn desktop_exec(entry: &str) -> Option { + let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim(); + let program = match exec.strip_prefix('"') { + Some(rest) => rest.split('"').next()?, + None => exec.split_whitespace().next()?, + }; + // a bare name (`photocraft %F`) was already looked for on the PATH + Some(PathBuf::from(program)).filter(|p| p.is_absolute()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not + /// through a shell. + #[test] + fn windows_default_open_goes_through_explorer_not_cmd() { + let path = r"C:/Edits/a & b ^c %PATH% | d.psd"; + let c = windows_default_command(path); + assert_eq!(c.get_program(), "explorer.exe"); + let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]); + } + + #[test] + fn windows_lookup_prefers_the_recorded_install_folder() { + let env = |k: &str| match k { + "ProgramFiles" => Some(r"C:\Program Files".to_string()), + "LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()), + _ => None, + }; + let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env); + let c: Vec = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect(); + assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped"); + assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}"); + assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c.len(), 4); + } + + #[test] + fn reg_query_output_is_read() { + let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n"; + assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\")); + let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe")); + assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None); + } + + #[test] + fn desktop_entries_give_their_program() { + let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; + assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); + assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup"); + assert_eq!(desktop_exec("[Desktop Entry]\n"), None); + let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); + assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); + assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); + } + + #[test] + fn other_editors_are_used_as_named() { + assert_eq!(launcher("").unwrap(), Launcher::Default); + assert_eq!(launcher(" ").unwrap(), Launcher::Default); + let named = launcher("/usr/bin/gimp").unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into())); + } else { + assert_eq!(named, Launcher::Program("/usr/bin/gimp".into())); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 4403613c1..6e4973b69 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -23,11 +23,16 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; +mod appearance; mod control_server; mod dialog_filter; +mod editors; mod logging; #[cfg(target_os = "macos")] mod native_menu; +#[cfg(target_os = "macos")] +mod screen_profile; +mod window_gpu; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; @@ -71,7 +76,7 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } - fn on_exit(&mut self) { + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { if let Err(e) = self.1.save(&self.0) { log::error!("{e}"); } @@ -93,15 +98,16 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: /// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 /// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). +/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. +#[cfg(test)] fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { - let mut c = eframe::egui_wgpu::WgpuConfiguration::default(); - if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { - n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends(); - n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options(); - // no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250) - n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags); - } - c + window_gpu::base_options() +} + +/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions +/// and `LIGHTCRAFT_NO_PREFS`. +fn window_files(in_memory: bool) -> window_gpu::Files { + window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) } /// Written while the GPU compute device is created, removed once that returned @@ -377,9 +383,27 @@ fn explorer_select_command(path: &str) -> std::process::Command { c } -/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is -/// for Help ▸ Open Log Folder. -fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services { +/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is +/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray +/// top-level ("Failed to associate portal window with parent window" in the portal's log): a +/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so +/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself; +/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including +/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog +/// becomes a sheet and on Windows a modal, a behaviour change this does not make. +static DIALOG_PARENT: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A file dialog owned by the app's window, or a plain one before the window exists. +fn file_dialog() -> rfd::FileDialog { + DIALOG_PARENT.get().cloned().unwrap_or_default() +} + +/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints +/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder. +fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services { + let ctx = cc.egui_ctx.clone(); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); Services { // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread // stopped the window answering the compositor, which then reported the app as hung. @@ -390,7 +414,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { - let mut d = rfd::FileDialog::new(); + let mut d = file_dialog(); if let Some(t) = &req.title { d = d.set_title(t); } @@ -416,37 +440,17 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services })), log_file: log_file.map(|p| p.display().to_string()), pick_lightroom_catalog: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), pick_folder: Some(Box::new(|| { - rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) - })), - open_with: Some(Box::new(|path: &str, app: &str| { - // spawned, never waited for: the editor runs alongside - let app = app.trim(); - let mut c = if cfg!(target_os = "macos") { - let mut c = std::process::Command::new("open"); - if !app.is_empty() { - c.args(["-a", app]); - } - c - } else if app.is_empty() { - if cfg!(target_os = "windows") { - let mut c = std::process::Command::new("cmd"); - c.args(["/C", "start", ""]); - c - } else { - std::process::Command::new("xdg-open") - } - } else { - std::process::Command::new(app) - }; - c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string()) + file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), + open_with: Some(Box::new(editors::open_with)), + check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { return Err("only https links are opened".into()); @@ -474,7 +478,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) })), pick_files: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) .pick_files() .unwrap_or_default() @@ -483,14 +487,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .collect() })), pick_denoise_model: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) .pick_file() .map(|p| vec![p.to_string_lossy().into_owned()]) .unwrap_or_default() })), pick_preset_files: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) .add_filter_nocase( lightcraft_ui_egui::i18n::tr("Presets & Profiles"), @@ -503,7 +507,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .collect() })), pick_model_file: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"]) .pick_file() @@ -511,15 +515,20 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .unwrap_or_default() })), pick_tracklog: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) .pick_file() .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() })), + // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) + #[cfg(target_os = "macos")] + screen_profile: Some(Box::new(screen_profile::icc)), + #[cfg(not(target_os = "macos"))] + screen_profile: None, pick_display_profile: Some(Box::new(|| { - let mut d = rfd::FileDialog::new() + let mut d = file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); if let Some(dir) = display_profile_dir() { @@ -528,7 +537,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() })), save_preset_file: Some(Box::new(|name: &str| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) .set_file_name(name) @@ -536,7 +545,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| p.to_string_lossy().to_string()) })), pick_keyword_list: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) .pick_file() @@ -544,7 +553,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .unwrap_or_default() })), save_keyword_list: Some(Box::new(|name: &str| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) .set_file_name(name) @@ -552,7 +561,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| p.to_string_lossy().to_string()) })), pick_curve_preset_files: Some(Box::new(|| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) .pick_files() @@ -562,7 +571,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .collect() })), save_curve_preset_file: Some(Box::new(|name: &str| { - rfd::FileDialog::new() + file_dialog() .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) .set_file_name(name) @@ -596,6 +605,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services // the library is a folder on disk: backed up with the user's other files backup_library: None, restore_library: None, + system_theme: appearance::service(), } } @@ -793,7 +803,14 @@ fn main() -> eframe::Result { let gpu_crash = gpu_crash_check(in_memory); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); + // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't + // build egui's pipeline never kills the start-up + let win_files = std::sync::Arc::new(window_files(in_memory)); + let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); + win_files.arm(window_renderer); + let win_files_app = win_files.clone(); let options = eframe::NativeOptions { + renderer, viewport: egui::ViewportBuilder::default() .with_title("LightCraft") .with_inner_size([1600.0, 1000.0]) @@ -805,73 +822,97 @@ fn main() -> eframe::Result { .with_icon(app_icon()) // Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id .with_app_id(APP_ID), - wgpu_options: window_wgpu_options(), + wgpu_options, ..Default::default() }; // the app's copy: `log_file` is still named in the message of a start that fails let app_log_file = log_file.clone(); - let started = eframe::run_native( - "LightCraft", - options, - Box::new(move |cc| { - let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); - // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR - // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) - session.segmenter.dir = - std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); - // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) - session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); - let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref())); - if let Some(ui) = prefs { - app.ui = ui; - } - lightcraft_ui_egui::i18n::set_language(app.ui.language); - app.integrated_titlebar = cfg!(target_os = "macos"); - app.notices.extend(prefs_warning); - app.notices.extend(launch_notices); - // what's on disk now: only changes are written - let writer = PrefsWriter { - path: if in_memory { None } else { prefs_path() }, - written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), - library: app.ui.settings.library_path.clone(), - keep_file: keep_prefs_file, - ..Default::default() - }; - // after the snapshot above, so the writer saves the switched-off preference - if let Some(notice) = gpu_crash { - app.ui.settings.gpu = false; - app.notices.push(notice); - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - if let Some(mut p) = problem { - // imported once the user has chosen where (into the library, or the temporary session) - p.pending_import = files; - app.library_problem = Some(p); - } else if !files.is_empty() { - // listed and read on the import worker once the window shows (issue #374: never - // here, before the first frame, where a large folder kept the window from - // appearing) - if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) { - log::warn!("import at launch: {e}"); + #[cfg(all(unix, not(target_os = "macos")))] + let mut options = options; + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } + let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + eframe::run_native( + "LightCraft", + options, + Box::new(move |cc| { + // the window's renderer is up (eframe calls this after creating it) + win_files_app.started(window_renderer); + let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR + // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) + // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) + session = session.with_default_sam3_model(); + let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); + if let Some(ui) = prefs { + app.ui = ui; } - app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; - } - // native menu bar generated from the command registry (macOS; elsewhere the menus are - // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. - #[cfg(target_os = "macos")] - let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); - Ok(Box::new(App( - app, - writer, + lightcraft_ui_egui::i18n::set_language(app.ui.language); + app.integrated_titlebar = cfg!(target_os = "macos"); + app.notices.extend(prefs_warning); + app.notices.extend(launch_notices); + app.notices.extend(window_notice); + // what's on disk now: only changes are written + let writer = PrefsWriter { + path: if in_memory { None } else { prefs_path() }, + written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), + library: app.ui.settings.library_path.clone(), + keep_file: keep_prefs_file, + ..Default::default() + }; + // after the snapshot above, so the writer saves the switched-off preference + if let Some(notice) = gpu_crash { + app.ui.settings.gpu = false; + app.notices.push(notice); + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + if let Some(mut p) = problem { + // imported once the user has chosen where (into the library, or the temporary session) + p.pending_import = files; + app.library_problem = Some(p); + } else if !files.is_empty() { + // listed and read on the import worker once the window shows (issue #374: never + // here, before the first frame, where a large folder kept the window from + // appearing) + if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) { + log::warn!("import at launch: {e}"); + } + app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; + } + // native menu bar generated from the command registry (macOS; elsewhere the menus are + // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. #[cfg(target_os = "macos")] - menu, - ))) - }), - ); + let menu = + (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); + Ok(Box::new(App( + app, + writer, + #[cfg(target_os = "macos")] + menu, + ))) + }), + ) + })); + // a panic that still escaped the renderer's start (the probe and error handler should + // prevent it): an error like any other failed start + let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into()))); if let Err(e) = &started { + // the renderer never came up: start again, which tries the next one in the chain + if window_gpu::relaunch_after_failure(&win_files) { + log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name()); + return Ok(()); + } startup_failed(&e.to_string(), log_file.as_deref()); } started diff --git a/apps/lightcraft/src/screen_profile.rs b/apps/lightcraft/src/screen_profile.rs new file mode 100644 index 000000000..c787d6989 --- /dev/null +++ b/apps/lightcraft/src/screen_profile.rs @@ -0,0 +1,30 @@ +//! The ICC profile of the screen showing LightCraft's window (macOS). +//! +//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's +//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so +//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in +//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the +//! previews are shown through this profile instead (`Services::screen_profile`). + +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSScreen}; + +/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None` +/// when it isn't known: off the main thread, or a screen without a usable profile. +pub fn icc() -> Option> { + let mtm = MainThreadMarker::new()?; + let app = NSApplication::sharedApplication(mtm); + let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject()); + let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?; + let icc = screen.colorSpace()?.ICCProfileData()?.to_vec(); + (!icc.is_empty()).then_some(icc) +} + +#[cfg(test)] +mod tests { + #[test] + fn unknown_off_the_main_thread() { + // AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour + assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None); + } +} diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs new file mode 100644 index 000000000..66e5a78d7 --- /dev/null +++ b/apps/lightcraft/src/window_gpu.rs @@ -0,0 +1,316 @@ +//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails +//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in +//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up). +//! +//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then +//! eframe's glow) is *probed* before the window opens: a device is created with an error handler +//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first +//! that builds cleanly is handed to eframe as an existing device (so its error handler stays). +//! - **Everywhere:** a marker file names the renderer being started and is removed once the +//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that +//! renderer last; [`relaunch_after_failure`] starts that next launch right away. +//! - The renderer that worked is remembered (`/window-renderer`, Windows only) and tried +//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one. + +use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting}; +use eframe::wgpu; +use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer}; +use std::path::PathBuf; + +/// Environment variable counting automatic relaunches after a failed start (bounds the chain). +const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY"; +/// Never more relaunches than renderers to try. +const MAX_RETRIES: u32 = 4; + +/// The window's renderer for this start. +pub struct Chosen { + pub renderer: eframe::Renderer, + pub wgpu_options: WgpuConfiguration, + pub which: WindowRenderer, + /// For the user: the last start failed with another renderer, or one failed now. + pub notice: Option, +} + +/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`). +pub struct Files { + dir: Option, +} + +impl Files { + pub fn new(dir: Option) -> Self { + Self { dir } + } + fn marker(&self) -> Option { + self.dir.as_ref().map(|d| d.join("window-init.marker")) + } + fn remembered(&self) -> Option { + self.dir.as_ref().map(|d| d.join("window-renderer")) + } + fn read(p: Option) -> Option { + std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse) + } + fn write(p: Option, r: WindowRenderer) { + let Some(p) = p else { return }; + if let Some(d) = p.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) { + log::warn!("window: could not write {}: {e}", p.display()); + } + } + /// The renderer the last start died in (marker left behind), removing the marker. + pub fn take_failed(&self) -> Option { + let m = self.marker()?; + let r = Self::read(Some(m.clone())); + let _ = std::fs::remove_file(m); + r + } + /// Before the window opens: record what is being started. + pub fn arm(&self, r: WindowRenderer) { + Self::write(self.marker(), r); + } + /// The window's renderer is up: clear the marker and (Windows) remember it. + pub fn started(&self, r: WindowRenderer) { + if let Some(m) = self.marker() { + let _ = std::fs::remove_file(m); + } + if cfg!(windows) { + Self::write(self.remembered(), r); + } + } + /// The marker is still there: the window never came up. + pub fn armed(&self) -> bool { + self.marker().is_some_and(|m| m.exists()) + } +} + +/// The renderer for this start: see the module docs. +pub fn choose(files: &Files) -> Chosen { + let forced = + backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref()); + let failed = files.take_failed(); + let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None }; + let compiled = wgpu::Instance::enabled_backend_features(); + let os = std::env::consts::OS; + let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true); + let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); + let mut problems = Vec::new(); + let chosen = if cfg!(windows) { + pick_probed(&order, &mut problems) + } else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) { + // elsewhere eframe's own wgpu set-up stays the default; glow after a failed start + Some((WindowRenderer::Glow, None)) + } else { + Some((WindowRenderer::Wgpu(backend::window_backends()), None)) + }; + let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None)); + if !problems.is_empty() { + notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); + } + let notice = notice.map(|n| { + format!( + "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ + (see docs/gpu-pipeline.md → Troubleshooting).", + which.name(), + app = "LightCraft", + var = "LIGHTCRAFT_GPU_BACKEND", + ) + }); + log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::>()); + let mut wgpu_options = base_options(); + let renderer = match (which, existing) { + (WindowRenderer::Glow, _) => eframe::Renderer::Glow, + (WindowRenderer::Wgpu(_), Some(e)) => { + wgpu_options.wgpu_setup = WgpuSetup::Existing(e); + eframe::Renderer::Wgpu + } + (WindowRenderer::Wgpu(b), None) => { + if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup { + n.instance_descriptor.backends = b; + } + eframe::Renderer::Wgpu + } + }; + Chosen { renderer, wgpu_options, which, notice } +} + +/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471). +pub fn base_options() -> WgpuConfiguration { + let mut c = WgpuConfiguration::default(); + if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { + n.instance_descriptor.backends = backend::window_backends(); + n.instance_descriptor.backend_options = backend::backend_options(); + // no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250) + n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags); + } + c +} + +/// The first renderer of `order` that probes cleanly (glow is taken without a probe). +fn pick_probed(order: &[WindowRenderer], problems: &mut Vec) -> Option<(WindowRenderer, Option)> { + for &r in order { + match r { + WindowRenderer::Glow => return Some((r, None)), + WindowRenderer::Wgpu(b) => { + let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into())); + match probed { + Ok(e) => return Some((r, Some(e))), + Err(e) => { + log::error!("window: the {} renderer failed: {e}", r.name()); + problems.push(format!("The {} renderer failed ({e}).", r.name())); + } + } + } + } + } + None +} + +/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned. +fn probe(backends: Backends) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()), + backend_options: backend::backend_options(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance), + ..Default::default() + })) + .map_err(|e| format!("no adapter: {e}"))?; + let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("egui wgpu device"), + required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base }, + ..Default::default() + })) + .map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?; + install_error_handler(&device); + check_pipeline(&device, |d| { + drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default())); + })?; + Ok(WgpuSetupExisting { instance, adapter, device, queue }) +} + +/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as +/// fatal by default"); this one logs. +pub fn install_error_handler(device: &wgpu::Device) { + device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}"))); +} + +/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is +/// returned instead of reaching the uncaptured-error handler. +pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> { + let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + build(device); + let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())]; + match errors.into_iter().flatten().next() { + Some(e) => Err(e.to_string()), + None => Ok(()), + } +} + +/// The start failed (error or panic) before the window's renderer came up: start the app again, +/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to +/// disk, or the retries are used up). +pub fn relaunch_after_failure(files: &Files) -> bool { + if !files.armed() { + return false; + } + let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::().ok()).unwrap_or(0); + if tries >= MAX_RETRIES { + return false; + } + let Ok(exe) = std::env::current_exe() else { return false }; + log::warn!("window: the renderer failed to start; starting again with the next one"); + std::process::Command::new(exe) + .args(std::env::args_os().skip(1)) + .env(RETRY_VAR, (tries + 1).to_string()) + .spawn() + .map_err(|e| log::error!("window: could not start again: {e}")) + .is_ok() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + /// The marker names the renderer being started; left behind, the next start reads it as the + /// failed one (and removes it); a good start removes it and remembers the renderer (Windows). + #[test] + fn marker_records_the_failed_renderer() { + let d = dir("marker"); + let f = Files::new(Some(d.clone())); + let dx = WindowRenderer::Wgpu(Backends::DX12); + f.arm(dx); + assert!(f.armed()); + assert_eq!(f.take_failed(), Some(dx)); + assert!(!f.armed()); + assert_eq!(f.take_failed(), None); + f.arm(WindowRenderer::Glow); + f.started(WindowRenderer::Glow); + assert!(!f.armed()); + assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow)); + // nothing written without a settings folder + let none = Files::new(None); + none.arm(dx); + assert!(!none.armed() && !relaunch_after_failure(&none)); + let _ = std::fs::remove_dir_all(&d); + } + + /// The crash report: a pipeline that fails validation must come back as an error, never as + /// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU). + #[test] + fn pipeline_errors_are_captured_not_panics() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: backend::window_backends(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { + eprintln!("no GPU adapter: skipped"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { + eprintln!("no GPU device: skipped"); + return; + }; + install_error_handler(&device); + // a shader that does not validate, then a pipeline using it (like a failed FXC compile) + let r = check_pipeline(&device, |d| { + let m = d.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("broken"), + source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4 { return undefined_thing; }".into()), + }); + let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("egui_pipeline"), + layout: None, + vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: None, + multiview_mask: None, + cache: None, + }); + }); + assert!(r.is_err(), "{r:?}"); + // egui's own pipeline builds cleanly here + check_pipeline(&device, |d| { + drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default())); + }) + .unwrap(); + // outside a scope: logged by our handler, not a panic + let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) }); + } +} diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 604fb0397..97d8868de 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-raw = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index f52d08db8..697ddf010 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -49,6 +49,7 @@ //! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` | //! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | +//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -73,7 +74,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 6; +pub const VERSION: u32 = 7; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed6150847..cc24266f2 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -214,6 +214,13 @@ pub enum Op { id: PhotoId, lens: Option>, }, + /// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the + /// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping, + /// not an edit: the look does not change, only the As Shot readout. + SetAsShotWb { + id: PhotoId, + wb: Option<(f64, f64)>, + }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { label: ColorLabel, @@ -685,6 +692,13 @@ impl Catalog { let p = self.photo_mut(id)?; Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) } } + Op::SetAsShotWb { id, wb } => { + if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) { + return Err(CatalogError::Invalid("as-shot white balance is not a number".into())); + } + let p = self.photo_mut(id)?; + Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index e81d48805..ff83a179d 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -284,7 +284,8 @@ impl Op { | Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } - | Op::SetEmbeddedLens { id, .. } => out(*id), + | Op::SetEmbeddedLens { id, .. } + | Op::SetAsShotWb { id, .. } => out(*id), Op::AddAlbum { album } => { album.photos.iter().for_each(|p| out(*p)); album.cover.into_iter().for_each(&mut *out); diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index 879127c5b..8a2b496d6 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -7,9 +7,15 @@ //! takes the lock first, and a second opener gets [`LockError::InUse`] instead. //! //! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]): -//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when -//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock -//! that's actually held refuses the open. +//! it is released explicitly when the [`LibraryLock`] is dropped and, after a crash or a kill, by +//! the OS when the process ends. A `catalog.lock` file left behind is therefore never "stale": +//! only a lock that's actually held refuses the open. +//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing. +//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor +//! on it is open; a process that another thread is spawning holds a copy of every descriptor +//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already +//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our +//! descriptor frees the lock whatever copies a child still holds. //! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for //! the error message. It's a separate file because a Windows lock also blocks reading the //! locked file. It's only a hint: it may be missing or left over from a crash. @@ -87,7 +93,7 @@ pub enum LockError { pub struct LibraryLock { dir: PathBuf, /// `None` when the file system can't lock (the library is open unlocked). - _file: Option, + file: Option, held: bool, } @@ -96,7 +102,7 @@ impl LibraryLock { pub fn acquire(dir: &Path, program: &str) -> Result { let unlocked = |why: io::Error| { log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display()); - LibraryLock { dir: dir.to_path_buf(), _file: None, held: false } + LibraryLock { dir: dir.to_path_buf(), file: None, held: false } }; let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) { Ok(f) => f, @@ -116,7 +122,7 @@ impl LibraryLock { { log::warn!("library {}: can't write {OWNER}: {e}", dir.display()); } - Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true }) + Ok(LibraryLock { dir: dir.to_path_buf(), file: Some(file), held: true }) } /// The OS lock is held (`false`: the file system can't lock; opened unprotected). @@ -127,14 +133,29 @@ impl LibraryLock { pub fn dir(&self) -> &Path { &self.dir } + + /// Another descriptor on the locked file: what a process spawned by another thread holds + /// between fork and exec (a `dup` refers to the same open file description). Tests only. + #[cfg(test)] + pub(crate) fn descriptor_copy(&self) -> Option { + self.file.as_ref().and_then(|f| f.try_clone().ok()) + } } impl Drop for LibraryLock { /// Remove the owner note while still holding the lock (so it can't delete the next holder's), - /// then the file handle closes and the OS releases the lock. + /// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts + /// while any copy of the descriptor is open, and a process another thread is spawning holds a + /// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still + /// closes right after, which releases the lock once every copy is gone, as before. fn drop(&mut self) { if self.held { let _ = std::fs::remove_file(self.dir.join(OWNER)); + if let Some(f) = &self.file + && let Err(e) = f.unlock() + { + log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display()); + } } } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 199edac1b..abeaadad4 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -307,14 +307,25 @@ pub struct Analysis { pub best: bool, } -/// Raw formats whose readers have vendor white-balance multipliers but no measured camera -/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means -/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's -/// `fileFormat` name, in any case. See [`Photo::relative_wb`]. +/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting +/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same +/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a +/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: +/// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) } +/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed +/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs): +/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera +/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into +/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730). +/// The engine's render path (`files.rs`) decides the same way from the file; both must agree. +pub fn relative_wb_camera(format: &str, camera: &str) -> bool { + relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() +} + impl Photo { pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { @@ -355,13 +366,18 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera - /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered - /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and - /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). - pub fn relative_wb(&self) -> bool { + /// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor + /// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies' + /// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats. + pub fn fitted_look(&self) -> bool { self.develops_raw() && relative_wb_format(&self.format) } + /// White balance relative to the camera's as-shot look, as for rendered photographs: a + /// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either + /// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin. + pub fn relative_wb(&self) -> bool { + self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera) + } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults, /// in the photo's own rendering process. @@ -377,11 +393,15 @@ impl Photo { /// process a photo renders with is not an edit, so comparisons with its defaults (edited? /// still as imported?) don't change when a newer process exists. A new photo has the latest. pub fn camera_defaults(&self) -> DevelopSettings { - let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb }; + let relative = self.relative_wb(); + let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb }; let mut d = match wb { Some((t, tint)) => DevelopSettings::for_raw(t, tint), None => DevelopSettings::default(), }; + if self.develops_raw() && !relative { + d.wb.scale = lightcraft_develop::WbScale::Kelvin; + } if self.embedded_lens.is_some() { d.optics.lens_profile = true; } @@ -487,6 +507,7 @@ impl Album { #[cfg(test)] mod edited_tests { use super::*; + use lightcraft_develop::WbScale; #[test] fn raw_with_import_settings_is_not_edited() { @@ -547,10 +568,13 @@ mod edited_tests { ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); p.kind = MediaKind::Raw; + p.meta.camera = "Pentax K-1".into(); p.as_shot_wb = Some((5200.0, 4.0)); assert_eq!(p.relative_wb(), relative, "{format}"); + assert_eq!(p.fitted_look(), relative, "{format}"); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}"); p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0)); assert!(!p.is_edited(), "{format}: old As Shot values are not an edit"); let mut custom = (*p.develop).clone(); @@ -559,7 +583,43 @@ mod edited_tests { assert!(p.is_edited(), "{format}: Custom WB is still an edit"); // shown from the embedded preview: a rendered image, not a relative-WB raw p.preview_only = Some("unsupported".into()); - assert!(!p.relative_wb()); + assert!(!p.relative_wb() && !p.fitted_look()); + } + } + + /// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730): + /// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on. + #[test] + fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() { + for (camera, format, relative) in [ + ("SONY ILCE-7M3", "ARW", false), + ("SONY ILCE-7RM4A", "ARW", false), + ("NIKON CORPORATION NIKON D850", "NEF", false), + ("Canon Canon EOS 5D Mark III", "CR2", false), + ("FUJIFILM X-T4", "RAF", false), + ("ILCE-7M3", "ARW", false), + ("SONY ILCE-7M2", "ARW", true), + ("SONY ILCE-1", "ARW", true), + ("", "ARW", true), + ("SONY ILCE-7M3", "DNG", false), + ] { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some((4986.0, -2.0)); + assert_eq!(p.relative_wb(), relative, "{camera} {format}"); + assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}"); + assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}"); + let wb = p.camera_defaults().wb; + assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}"); + assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}"); + // a photo from an older catalog: stored As Shot on the relative scale, still not an edit + p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0)); + assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit"); + // its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot + p.as_shot_wb = Some((6500.0, 0.0)); + assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot); + assert!(!p.is_edited()); } } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 20f4b7282..bedc94e75 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -466,7 +466,7 @@ fn set_embedded_lens_is_undoable_and_journaled() { photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")), }) .unwrap(); - let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None }; + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() }; let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) }; let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); let inv = c.apply(op).unwrap(); @@ -476,6 +476,27 @@ fn set_embedded_lens_is_undoable_and_journaled() { assert_eq!(c.photo(a).unwrap().embedded_lens, None); } +/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the +/// journal's JSON) takes it back, and a value that is not a number is refused. +#[test] +fn set_as_shot_wb_is_undoable_journaled_and_checked() { + let mut c = Catalog::default(); + let a = c.alloc_photo_id(); + let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00"); + p.as_shot_wb = Some((6500.0, 0.0)); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) }; + let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); + let inv = c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0))); + let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); + c.apply(inv).unwrap(); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0))); + assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err()); + assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err()); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0))); +} + /// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found, /// with its problems; a good one has none; an old album operator is read as it was meant. #[test] diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index ed2bfe7d1..37bd639e8 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -210,8 +210,9 @@ fn op_variants_are_versioned() { Op::SetEmbeddedLens { .. } => 4, Op::SetKeyword { .. } => 5, Op::SetFolderRecord { .. } => 6, + Op::SetAsShotWb { .. } => 7, } } - let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None }); + let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index c885ec490..1f5dcec24 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -5,6 +5,17 @@ use std::path::PathBuf; use crate::lock::{LOCK, OWNER}; use crate::*; +/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of +/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file +/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock +/// another test drops in that moment stays held by the copy, so acquiring it again could fail with +/// "in use". +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); @@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf { #[test] fn second_opener_is_refused_until_the_first_lets_go() { + let _serial = serial(); let dir = temp_dir("twice"); let first = LibraryLock::acquire(&dir, "LightCraft").unwrap(); assert!(first.held()); @@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() { /// Files left behind by a crash (lock file + owner note) don't lock anyone out. #[test] fn leftover_lock_files_are_not_a_lock() { + let _serial = serial(); let dir = temp_dir("stale"); std::fs::write(dir.join(LOCK), b"").unwrap(); std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap(); @@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() { /// Another process holds the lock; when it is killed (a crash), the OS releases it. #[test] fn lock_held_by_another_process_is_released_when_it_dies() { + let _serial = serial(); let dir = temp_dir("child"); let exe = std::env::current_exe().unwrap(); let mut child = std::process::Command::new(exe) @@ -91,3 +105,69 @@ fn lock_holder_child() { std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap(); std::thread::sleep(std::time::Duration::from_secs(120)); } + +/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs +/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is +/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather +/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's +/// copy: it refers to the same open file description, which is exactly what the child gets. +#[cfg(unix)] +#[test] +fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() { + let dir = temp_dir("copy"); + let first = LibraryLock::acquire(&dir, "LightCraft").unwrap(); + let copy = first.descriptor_copy().unwrap(); + drop(first); + let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped"); + assert!(again.held()); + drop((again, copy)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires, +/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix +/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail. +#[cfg(unix)] +#[test] +fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let dir = temp_dir("spawn"); + let stop = Arc::new(AtomicBool::new(false)); + let spawner = { + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + let mut spawned = 0usize; + while !stop.load(Ordering::Relaxed) { + if let Ok(mut c) = + std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + { + let _ = c.wait(); + spawned += 1; + } + } + spawned + }) + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut cycles = 0usize; + let mut refused = Vec::new(); + while cycles < 200_000 && std::time::Instant::now() < deadline { + match LibraryLock::acquire(&dir, "LightCraft") { + Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back + Err(e) => refused.push(format!("cycle {cycles}: {e:?}")), + } + cycles += 1; + } + stop.store(true, Ordering::Relaxed); + let spawned = spawner.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(spawned > 0, "the spawning thread never got a child off the ground"); + assert!( + refused.is_empty(), + "{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}", + refused.len(), + &refused[..refused.len().min(3)] + ); +} diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index c002abd98..36392dbcd 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -1,4 +1,4 @@ -//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and, +//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and, //! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is //! embedded where the format allows. @@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> { EncodeImage { width, height, channels, samples } } - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { if !(1..=4).contains(&self.channels) { return Err(Error::Encode(format!("unsupported channel count {}", self.channels))); } @@ -291,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod Ok(cur.into_inner()) } +/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image +/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. +pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + crate::psd::encode(img, meta) +} + /// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container. pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result> { img.validate()?; @@ -498,6 +504,60 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: } } +/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0 +/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve +/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition: +/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same +/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP` +/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ +/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback. +pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], meta: &EncodeMeta) -> Result> { + let (w, h) = (width as usize, height as usize); + let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?; + if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) { + return Err(Error::Encode("HDR PNG: sample buffer too short".into())); + } + const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance + let mut max_nits = 0f32; + let mut sum_nits = 0f64; + let mut bytes = Vec::with_capacity(n * 6); + for c in rgb.as_chunks::<3>().0.iter().take(n) { + let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS); + max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2])); + sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64; + for v in nits { + bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes()); + } + } + let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32); + let mut info = png::Info::with_size(width, height); + info.color_type = png::ColorType::Rgb; + info.bit_depth = png::BitDepth::Sixteen; + info.exif_metadata = meta.exif.map(|b| b.to_vec().into()); + info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| { + let ppm = (p as f64 / 0.0254).round() as u32; + png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter } + }); + let mut out = Vec::new(); + let e = |e: png::EncodingError| Error::Encode(e.to_string()); + { + let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?; + enc.set_compression(png::Compression::Fast); + if let Some(xmp) = meta.xmp { + enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?; + } + let mut wr = enc.write_header().map_err(e)?; + // BT.2020 primaries (9), PQ (16), RGB (0), full range (1) + wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?; + // MaxCLL and MaxFALL in units of 0.0001 cd/m² + let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat(); + wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?; + wr.write_image_data(&bytes).map_err(e)?; + wr.finish().map_err(e)?; + } + Ok(out) +} + /// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input). #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 657987189..bd573248c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -29,6 +29,7 @@ pub mod jpeg_par; mod jxl; mod other; mod png_codec; +pub mod proof; mod psd; mod sniff; pub mod space; @@ -37,7 +38,7 @@ mod webp; pub use encode::{ ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_tiff, encode_webp_lossless, + encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, }; pub use sniff::{Format, sniff}; pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; diff --git a/crates/codecs/src/proof.rs b/crates/codecs/src/proof.rs new file mode 100644 index 000000000..55b63b25e --- /dev/null +++ b/crates/codecs/src/proof.rs @@ -0,0 +1,174 @@ +//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix +//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020) +//! through the device and back, so the loupe shows what the print can hold. +//! +//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative +//! colorimetric: colours outside the device gamut come back mapped into it. +//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white +//! (the profile's media white point) replaces the screen's white, and the ink's black is the +//! darkest the device tables give (no black-point compensation). The white is applied as a +//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the +//! near-neutral whites of papers). +//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour. +//! +//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling); +//! the transforms are moxcms's. Own design otherwise. + +use lightcraft_color::{D50, D65, REC2020, bradford}; +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; + +/// Rendering intent of the forward (image → device) conversion. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ProofIntent { + #[default] + Perceptual, + Relative, +} + +impl ProofIntent { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "perceptual" => Some(ProofIntent::Perceptual), + "relative" | "relativecolorimetric" => Some(ProofIntent::Relative), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + ProofIntent::Perceptual => "perceptual", + ProofIntent::Relative => "relative", + } + } +} + +/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index +/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance +/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside). +#[derive(Clone, Debug, PartialEq)] +pub struct ProofTable { + pub n: usize, + pub data: Vec<[f32; 4]>, + pub description: Option, +} + +/// Grid size of [`ProofTable`]s. +pub const PROOF_GRID: usize = 33; + +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here +/// panics on a hostile profile. +pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?; + let (layout, ch) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let fwd_i = match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + }; + let rel = RenderingIntent::RelativeColorimetric; + let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?; + let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?; + let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?; + + let n = PROOF_GRID; + let grid: Vec = (0..n * n * n) + .flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32))) + .collect(); + let round_trip = |there: &dyn moxcms::TransformExecutor| -> Option> { + let mut device = vec![0f32; n * n * n * ch]; + there.transform(&grid, &mut device).ok()?; + let mut out = vec![0f32; n * n * n * 3]; + back.transform(&device, &mut out).ok()?; + Some(out) + }; + let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?; + let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?; + let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] }; + let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)); + let data = (0..n * n * n) + .map(|i| { + let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0); + let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max); + [c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist] + }) + .collect(); + let description = crate::icc::parse(bytes).and_then(|i| i.description); + Ok(ProofTable { n, data, description }) +} + +/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point. +fn paper_white(p: &ColorProfile) -> [f32; 3] { + let Some(w) = p.media_white_point else { return [1.0; 3] }; + let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz()); + let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] }; + // the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it + let d50 = to_xyz.apply([1.0; 3]); + let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]]; + let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]); + if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::NamedSpace; + + fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] { + t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]] + } + + #[test] + fn srgb_proof_flags_wide_colours_and_keeps_grey() { + let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap(); + let top = t.n - 1; + // Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged + assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0])); + let g = at(&t, [16, 16, 16]); + assert!(g[3] < 0.01, "{g:?}"); + let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0); + assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}"); + let w = at(&t, [top; 3]); + assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}"); + } + + #[test] + fn paper_white_dims_and_tints_white() { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + // a warm, slightly dull paper + p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 }); + let bytes = p.encode().unwrap(); + let top = PROOF_GRID - 1; + let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap(); + let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap(); + let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3])); + assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}"); + assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}"); + } + + #[test] + fn hostile_profiles_are_errors() { + assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err()); + assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(11) { + let mut b = good.clone(); + b[i] ^= 0x5a; + let _ = proof_table(&b, ProofIntent::Perceptual, true); + } + } +} diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs index 3d26984ef..7cccec0f8 100644 --- a/crates/codecs/src/psd.rs +++ b/crates/codecs/src/psd.rs @@ -1,8 +1,11 @@ //! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description): //! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data; //! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative. +//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document +//! with no layers, so an image editor opens it as one background layer and saves back to it. use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::encode::{EncodeImage, EncodeMeta, Samples}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; const F: Format = Format::Psd; @@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) } +/// The largest side a PSD (version 1) may have; larger documents need PSB. +pub const MAX_SIDE: u32 = 30_000; + +/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF +/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits +/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB; +/// alpha is dropped (the render is opaque). Float samples are refused. +pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + img.validate()?; + let (w, h) = (img.width, img.height); + if w > MAX_SIDE || h > MAX_SIDE { + return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF"))); + } + let ch = usize::from(img.channels); + let color = if ch <= 2 { 1 } else { 3 }; + let (depth, bpc) = match img.samples { + Samples::U8(_) => (8u16, 1usize), + Samples::U16(_) => (16, 2), + Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())), + }; + let (wu, hu) = (w as usize, h as usize); + let mut b = Vec::new(); + b.extend_from_slice(b"8BPS"); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&(color as u16).to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&depth.to_be_bytes()); + b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + let res = resource_section(meta); + b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes()); + b.extend_from_slice(&res); + b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none + // planar samples, one plane per colour channel (big-endian) + let plane = |c: usize| -> Vec { + let mut p = Vec::with_capacity(wu * hu * bpc); + match img.samples { + Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)), + Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())), + Samples::F32(_) => {} + } + p + }; + if bpc == 1 { + b.extend_from_slice(&1u16.to_be_bytes()); + let mut counts = Vec::with_capacity(color * hu * 2); + let mut data = Vec::new(); + for c in 0..color { + for row in plane(c).chunks_exact(wu) { + let start = data.len(); + packbits(row, &mut data); + let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?; + counts.extend_from_slice(&n.to_be_bytes()); + } + } + b.extend_from_slice(&counts); + b.extend_from_slice(&data); + } else { + b.extend_from_slice(&0u16.to_be_bytes()); + for c in 0..color { + b.extend_from_slice(&plane(c)); + } + } + Ok(b) +} + +/// The image resources section for `meta`. +fn resource_section(meta: &EncodeMeta) -> Vec { + let mut out = Vec::new(); + let mut add = |id: u16, data: &[u8]| { + out.extend_from_slice(b"8BIM"); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + if data.len() % 2 == 1 { + out.push(0); + } + }; + if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { + // ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice + let fixed = (u32::from(ppi) << 16).to_be_bytes(); + let one = 1u16.to_be_bytes(); + add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat()); + } + if let Some(icc) = meta.icc { + add(1039, icc); + } + if let Some(exif) = meta.exif { + add(1058, exif); + } + if let Some(xmp) = meta.xmp { + add(1060, xmp.as_bytes()); + } + out +} + +/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the +/// rest as literals of at most 128 bytes). +fn packbits(src: &[u8], out: &mut Vec) { + let mut rest = src; + while let Some(&b) = rest.first() { + // a run of 2 to 128 equal bytes + let run = rest.iter().take(128).take_while(|&&x| x == b).count(); + if run >= 2 { + // 1 - run in 2..=128: -1..=-127, as a signed byte + out.push((1 - run as i16) as i8 as u8); + out.push(b); + rest = rest.get(run..).unwrap_or_default(); + continue; + } + // a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row + let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count(); + let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired }; + let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..])); + out.push(lit.len().saturating_sub(1) as u8); + out.extend_from_slice(lit); + rest = tail; + } +} + /// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources, /// without reading the merged image data. Refused, as a decode would refuse it: an unsupported /// depth, colour mode or compression, or merged image data that runs past the end of the file @@ -434,4 +560,55 @@ mod tests { let q = d.image.get(0, 0); assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}"); } + #[test] + fn packbits_round_trips() { + let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::>()]; + for row in rows { + let mut enc = Vec::new(); + super::packbits(row, &mut enc); + let mut out = vec![0u8; row.len()]; + unpackbits(&enc, &mut out).unwrap(); + assert_eq!(out, row); + } + } + + #[test] + fn flat_psd_round_trips_8_and_16_bit() { + let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb); + let meta = EncodeMeta { icc: Some(&icc), xmp: Some(""), ppi: Some(300), ..Default::default() }; + // 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient + let px: Vec = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect(); + let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap(); + assert_eq!(super::header(&b).unwrap(), (5, 3, 1)); + let d = decode(&b, &DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (5, 3)); + assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept"); + assert_eq!(d.xmp.as_deref(), Some("")); + // 16-bit RGB, raw planes: the samples come back exactly + let px16: Vec = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect(); + let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap(); + assert_eq!(&b[22..24], &16u16.to_be_bytes()); + let mut r = Rd { b: &b, p: 26 }; + let cm = r.u32().unwrap() as usize; + r.take(cm).unwrap(); + let res = r.u32().unwrap() as usize; + r.take(res).unwrap(); + assert_eq!(r.u32().unwrap(), 0, "no layers"); + assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw"); + let red: Vec = (0..8).map(|_| r.u16().unwrap()).collect(); + assert_eq!(red, px16.iter().step_by(3).copied().collect::>()); + assert!(decode(&b, &DecodeOptions::default()).is_ok()); + // gray 16-bit + let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap(); + assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode"); + assert!(decode(&g, &DecodeOptions::default()).is_ok()); + } + + #[test] + fn flat_psd_refuses_what_it_cannot_hold() { + let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("float"), "{e}"); + let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("TIFF"), "{e}"); + } } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index a700ddf6c..8aa6f06de 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -635,3 +635,49 @@ fn print_resolution_is_written() { let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]); } + +#[test] +fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() { + // linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x + let (w, h) = (64u32, 4u32); + let rgb: Vec = (0..w * h) + .flat_map(|i| { + let v = 0.01 * 1.11f32.powi((i % w) as i32); + [v, v * 0.5, v * 0.25] + }) + .collect(); + let xmp = r#""#; + let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap(); + let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap(); + let cicp = r.info().coding_independent_code_points.expect("cICP"); + assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ"); + let mut buf = vec![0; r.output_buffer_size().unwrap()]; + let f = r.next_frame(&mut buf).unwrap(); + assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h)); + // every sample decodes back to its luminance + for x in [0usize, 20, 63] { + for c in 0..3 { + let o = (x * 3 + c) * 2; + let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0; + let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS; + assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e)); + } + } + // cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m² + let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI"); + let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0; + let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS; + assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}"); + assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept"); + assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept"); + assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP"); +} + +#[test] +fn pq_png_rejects_short_buffers_and_survives_hostile_samples() { + assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err()); + assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err()); + assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err()); + let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0]; + assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok()); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d137d569..855c3d049 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -149,7 +149,8 @@ impl RgbSpace { } } -const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]); +/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in. +pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]); /// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`. pub fn bradford(src: Xy, dst: Xy) -> Mat3 { diff --git a/crates/denoise/src/lib.rs b/crates/denoise/src/lib.rs index e72860ba4..f9005c7d5 100644 --- a/crates/denoise/src/lib.rs +++ b/crates/denoise/src/lib.rs @@ -12,6 +12,7 @@ //! blend with the plain demosaic that the Amount slider controls. //! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test. //! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU. +//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server). //! //! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must //! be finite, and a malformed file is an error, never a panic. @@ -26,6 +27,7 @@ pub mod onnx; mod onnx_proto; pub mod reference; +pub mod remote; #[cfg(feature = "runtime")] pub mod runtime; diff --git a/crates/denoise/src/remote.rs b/crates/denoise/src/remote.rs new file mode 100644 index 000000000..b8c51b98a --- /dev/null +++ b/crates/denoise/src/remote.rs @@ -0,0 +1,567 @@ +//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as +//! Tailscale or WireGuard: the connection itself is not encrypted. +//! +//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli +//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet. +//! +//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of +//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every +//! high byte) and deflated, which roughly halves what crosses a slow link. +//! +//! ```text +//! → {"v":1,"token":"…","op":"hello"} +//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}} +//! → {"v":1,"token":"…","op":"run","model":"","tile":T,"bytes":N} + N bytes (4 planes of T × T) +//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T) +//! ← {"ok":false,"error":"…"} (any failure) +//! ``` +//! +//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of +//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent +//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::product::{from_f16, to_f16}; +use crate::run::{Error, TileRunner}; + +pub const PROTOCOL: u64 = 1; +pub const DEFAULT_PORT: u16 = 7990; +const MAX_LINE: usize = 4096; +/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer). +pub const MAX_TILE: usize = 1024; +/// Shortest token a server accepts. +pub const MIN_TOKEN: usize = 16; + +/// Which model a server runs: both ends must agree before tiles cross. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ModelId { + pub id: String, + pub version: String, + /// SHA-256 of the model file (hex). + pub sha256: String, +} + +fn bad(m: impl Into) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, m.into()) +} + +fn read_line(r: &mut impl BufRead) -> std::io::Result> { + let mut buf = Vec::new(); + let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?; + if n == 0 { + return Ok(None); + } + if buf.last() != Some(&b'\n') { + return Err(bad("message header too long or truncated")); + } + String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8")) +} + +/// Samples → half floats → byte-shuffled → deflated. +pub fn pack(data: &[f32]) -> Vec { + let n = data.len(); + let mut b = vec![0u8; n * 2]; + let (lo, hi) = b.split_at_mut(n); + for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) { + [*l, *h] = to_f16(*v).to_le_bytes(); + } + miniz_oxide::deflate::compress_to_vec(&b, 1) +} + +/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more. +pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result> { + let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?; + if b.len() != n * 2 { + return Err(bad("payload size doesn't match the tile")); + } + let (lo, hi) = b.split_at(n); + Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect()) +} + +fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> { + let packed = pack(data); + header["bytes"] = json!(packed.len()); + writeln!(w, "{header}")?; + w.write_all(&packed) +} + +/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us +/// allocate more than an uncompressed tile plus slack). +fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result> { + if bytes > n * 2 + 4096 { + return Err(bad("payload larger than its tile")); + } + let mut b = vec![0u8; bytes]; + r.read_exact(&mut b)?; + unpack(&b, n) +} + +fn field(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} + +/// Constant-time comparison (the token must not leak through timing). +fn same(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + let mut d = a.len() ^ b.len(); + for i in 0..a.len().max(b.len()) { + d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize; + } + d == 0 +} + +/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out. +pub type RunTile = Arc Result, Error> + Send + Sync>; + +/// A server's settings. +#[derive(Clone, Debug)] +pub struct ServerOptions { + /// Shared secret every request must carry (at least [`MIN_TOKEN`] characters). + pub token: String, + pub max_connections: usize, + /// Idle and transfer limit per connection. + pub timeout: Duration, +} + +impl Default for ServerOptions { + fn default() -> Self { + ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) } + } +} + +/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event. +pub fn serve( + listener: TcpListener, + model: ModelId, + tile: usize, + device: String, + run: RunTile, + opts: ServerOptions, + log: Arc, +) -> Result<(), String> { + if opts.token.trim().chars().count() < MIN_TOKEN { + return Err(format!("the token must be at least {MIN_TOKEN} characters")); + } + if tile == 0 || tile > MAX_TILE { + return Err(format!("tile size {tile} is outside 1..={MAX_TILE}")); + } + let active = Arc::new(AtomicUsize::new(0)); + let shared = Arc::new((model, device)); + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into()); + if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) { + let mut s = stream; + let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"})); + log(&format!("{peer}: refused (busy)")); + continue; + } + active.fetch_add(1, Ordering::SeqCst); + let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone()); + let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || { + if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) { + l(&format!("{peer}: {e}")); + } + n.fetch_sub(1, Ordering::SeqCst); + }); + if spawned.is_err() { + active.fetch_sub(1, Ordering::SeqCst); + log("could not start a connection thread"); + } + } + Ok(()) +} + +fn handle( + stream: TcpStream, + model: &ModelId, + tile: usize, + device: &str, + run: &dyn Fn(&[f32]) -> Result, Error>, + opts: &ServerOptions, + log: &dyn Fn(&str), +) -> std::io::Result<()> { + stream.set_read_timeout(Some(opts.timeout))?; + stream.set_write_timeout(Some(opts.timeout))?; + stream.set_nodelay(true)?; + let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default(); + let mut r = BufReader::new(stream.try_clone()?); + let mut w = stream; + let mut tiles = 0usize; + let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg})); + while let Some(line) = read_line(&mut r)? { + let Ok(req) = serde_json::from_str::(&line) else { + fail(&mut w, "malformed request")?; + break; + }; + if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) { + fail(&mut w, &format!("protocol version {PROTOCOL} only"))?; + break; + } + if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) { + fail(&mut w, "bad token")?; + log(&format!("{peer}: bad token")); + break; + } + match req.get("op").and_then(Value::as_str) { + Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?, + Some("run") => { + let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else { + fail(&mut w, "missing tile size or payload size")?; + break; + }; + if t != tile { + fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?; + break; + } + // the payload is read before any refusal below, so the stream stays in step + let input = match read_payload(&mut r, bytes, 4 * t * t) { + Ok(d) => d, + Err(e) => { + fail(&mut w, &e.to_string())?; + break; + } + }; + if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) { + fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?; + continue; + } + match run(&input) { + Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => { + write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?; + tiles += 1; + } + Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?, + Err(e) => fail(&mut w, &e.to_string())?, + } + } + _ => { + fail(&mut w, "unknown op")?; + break; + } + } + w.flush()?; + } + if tiles > 0 { + log(&format!("{peer}: {tiles} tiles")); + } + Ok(()) +} + +/// What a server said in its greeting. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ServerInfo { + pub address: String, + pub device: String, + pub model: ModelId, + pub tile: usize, +} + +/// Tiles the server may get wrong (an error, a broken connection) before it stops being used. +pub const MAX_FAILURES: usize = 3; + +/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after +/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself). +pub struct Remote { + address: String, + token: String, + model_sha256: String, + tile: usize, + slots: usize, + timeout: Duration, + busy: AtomicUsize, + failures: AtomicUsize, + last_error: Mutex>, + pool: Mutex, TcpStream)>>, +} + +impl Remote { + /// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored). + pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote { + let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/'); + let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::().is_ok()) && !a.ends_with(']'); + let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") }; + Remote { + address, + token: token.into(), + model_sha256: model_sha256.to_ascii_lowercase(), + tile, + slots: 4, + timeout: Duration::from_secs(60), + busy: AtomicUsize::new(0), + failures: AtomicUsize::new(0), + last_error: Mutex::new(None), + pool: Mutex::new(Vec::new()), + } + } + + pub fn with_slots(mut self, n: usize) -> Remote { + self.slots = n.clamp(1, 16); + self + } + + pub fn address(&self) -> &str { + &self.address + } + + /// Tiles it can take at once. + pub fn slots(&self) -> usize { + self.slots + } + + /// Still used (fewer than [`MAX_FAILURES`] failed tiles). + pub fn healthy(&self) -> bool { + self.failures.load(Ordering::Relaxed) < MAX_FAILURES + } + + pub fn failures(&self) -> usize { + self.failures.load(Ordering::Relaxed) + } + + /// Why the last tile failed. + pub fn last_error(&self) -> Option { + self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + fn err(&self, reason: impl std::fmt::Display) -> Error { + Error::Runtime(format!("remote {}: {reason}", self.address)) + } + + fn connect(&self) -> Result<(BufReader, TcpStream), Error> { + let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?; + let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?; + s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?; + s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?; + s.set_nodelay(true).map_err(|e| self.err(e))?; + let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?); + Ok((r, s)) + } + + fn reply(&self, r: &mut BufReader) -> Result { + let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?; + let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?; + if v.get("ok").and_then(Value::as_bool) != Some(true) { + return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed"))); + } + Ok(v) + } + + /// Greet the server: its device and model. Fails when it runs another model or tile size than this one. + pub fn hello(&self) -> Result { + let (mut r, mut s) = self.connect()?; + writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?; + let v = self.reply(&mut r)?; + let m = v.get("model").cloned().unwrap_or_default(); + let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let info = ServerInfo { + address: self.address.clone(), + device: text(&v, "device"), + model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") }, + tile: field(&v, "tile").unwrap_or(0), + }; + if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) { + return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version))); + } + if info.tile != self.tile { + return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile))); + } + self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s)); + Ok(info) + } + + /// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs + /// the tile elsewhere). A failure counts towards giving up on the server. + pub fn try_run(&self, input: &[f32]) -> Option, Error>> { + if !self.healthy() { + return None; + } + if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots { + self.busy.fetch_sub(1, Ordering::SeqCst); + return None; + } + let r = self.run_now(input); + self.busy.fetch_sub(1, Ordering::SeqCst); + if let Err(e) = &r { + self.failures.fetch_add(1, Ordering::Relaxed); + *self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string()); + } + Some(r) + } + + fn run_now(&self, input: &[f32]) -> Result, Error> { + let t = self.tile; + if input.len() != 4 * t * t { + return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len()))); + } + let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop(); + let (mut r, mut s) = match pooled { + Some(c) => c, + None => self.connect()?, + }; + let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t}); + write_payload(&mut s, req, input).map_err(|e| self.err(e))?; + s.flush().map_err(|e| self.err(e))?; + let v = self.reply(&mut r)?; + if field(&v, "tile") != Some(t) { + return Err(self.err("the answer is for another tile size")); + } + let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?; + let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?; + self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s)); + // the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry + // both) is a failed tile, which `Shared` runs here instead + if !out.iter().all(|v| v.is_finite()) { + return Err(self.err("the answer holds values that aren't finite")); + } + Ok(out) + } +} + +/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds +/// speed to this computer's runner instead of replacing it. +pub struct Shared<'a> { + pub remote: &'a Remote, + pub local: &'a dyn TileRunner, +} + +impl TileRunner for Shared<'_> { + fn run(&self, input: &[f32]) -> Result, Error> { + match self.remote.try_run(input) { + Some(Ok(out)) => Ok(out), + Some(Err(_)) | None => self.local.run(input), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TOKEN: &str = "0123456789abcdef-test"; + const T: usize = 8; + + /// A stand-in model: each output pixel is its cell's first plane, times `k`. + fn model(k: f32) -> RunTile { + Arc::new(move |input: &[f32]| { + let t = T; + let mut out = vec![0f32; 3 * 4 * t * t]; + for c in 0..3 { + for y in 0..2 * t { + for x in 0..2 * t { + out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k; + } + } + } + Ok(out) + }) + } + + fn id(sha: &str) -> ModelId { + ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() } + } + + fn start(sha: &str, run: RunTile) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap().to_string(); + let opts = ServerOptions { token: TOKEN.into(), ..Default::default() }; + let m = id(sha); + std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {}))); + addr + } + + struct Local(RunTile, AtomicUsize); + impl TileRunner for Local { + fn run(&self, input: &[f32]) -> Result, Error> { + self.1.fetch_add(1, Ordering::SeqCst); + (self.0)(input) + } + } + + fn tile_input(seed: f32) -> Vec { + (0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect() + } + + #[test] + fn remote_tiles_match_local_ones_within_half_float_rounding() { + let addr = start("aa11", model(2.0)); + let remote = Remote::new(&addr, TOKEN, "AA11", T); + let info = remote.hello().unwrap(); + assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T)); + let input = tile_input(0.3); + let got = remote.try_run(&input).unwrap().unwrap(); + let want = model(2.0)(&input).unwrap(); + assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3)); + } + + #[test] + fn shared_runner_falls_back_and_gives_up_on_a_failing_server() { + let local = Local(model(2.0), AtomicUsize::new(0)); + // nobody listening: every tile runs locally, and after a few the server is given up + let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T); + let shared = Shared { remote: &dead, local: &local }; + for i in 0..6 { + assert!(shared.run(&tile_input(i as f32)).is_ok()); + } + assert_eq!(local.1.load(Ordering::SeqCst), 6); + assert!(!dead.healthy() && dead.last_error().is_some()); + // a server with another model is refused at the greeting and per tile + let addr = start("bb22", model(2.0)); + let other = Remote::new(&addr, TOKEN, "aa11", T); + assert!(other.hello().unwrap_err().to_string().contains("another model")); + assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch")); + // a wrong token + let addr = start("aa11", model(2.0)); + assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token")); + // all slots busy: the tile runs locally without waiting + let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1); + busy.busy.store(1, Ordering::SeqCst); + assert!(busy.try_run(&tile_input(0.0)).is_none()); + } + + #[test] + fn hostile_requests_are_refused_and_the_server_keeps_working() { + let addr = start("aa11", model(1.0)); + let talk = |bytes: &[u8]| -> String { + let mut s = TcpStream::connect(&addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + s.write_all(bytes).unwrap(); + let mut line = String::new(); + let _ = BufReader::new(s).read_line(&mut line); + line + }; + assert!(talk(b"not json\n").contains("malformed")); + let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"})); + assert!(talk(v2.as_bytes()).contains("protocol")); + let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10})); + assert!(talk(big.as_bytes()).contains("8-cell")); + // a payload that inflates past its tile + let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9); + let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()})); + assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false")); + assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok()); + assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd")); + // a short token is refused when the server starts + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let opts = ServerOptions { token: "short".into(), ..Default::default() }; + assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err()); + } + + #[test] + fn packing_round_trips_and_addresses_are_normalised() { + let v: Vec = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect(); + let back = unpack(&pack(&v), v.len()).unwrap(); + assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3)); + assert!(unpack(&pack(&v), v.len() + 1).is_err()); + assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990"); + assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000"); + assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990"); + } +} diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index c2edd76c9..f376b3c67 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -38,11 +38,21 @@ impl DevelopSettings { serde_json::from_value(v.clone()) } - /// Merge a partial JSON object into these settings (fields not mentioned are kept). + /// 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. pub fn merged(&self, partial: &Value) -> Result { let mut v = self.to_json(); presets::deep_merge(&mut v, partial); - DevelopSettings::from_json(&v) + let mut out = DevelopSettings::from_json(&v)?; + if let Some(wb) = partial.get("wb").and_then(Value::as_object) + && (wb.contains_key("temp") || wb.contains_key("tint")) + && !wb.contains_key("scale") + { + out.wb.scale = WbScale::Legacy; + } + Ok(out) } /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. @@ -167,6 +177,47 @@ mod tests { assert_ne!(back.hash64(), DevelopSettings::default().hash64()); } + #[test] + fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() { + // settings from before the scale existed: no field, `Legacy`, and written back without it + let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}}); + let s = DevelopSettings::from_json(&old).unwrap(); + assert_eq!(s.wb.scale, WbScale::Legacy); + assert_eq!(s.to_json()["wb"], old["wb"]); + assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy); + assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none()); + // written on the Kelvin scale: said so, and read back + let mut k = s.clone(); + k.wb.scale = WbScale::Kelvin; + assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin")); + assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k); + assert_ne!(k.hash64(), s.hash64()); + // an unknown scale from a newer build is an error, not a panic + assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err()); + } + + #[test] + fn a_partial_white_balance_without_a_scale_is_legacy() { + let mut base = DevelopSettings::default(); + base.wb.scale = WbScale::Kelvin; + // an old preset's white balance lands on Kelvin settings: it keeps its relative meaning + let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0); + assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0)); + let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Legacy); + // one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was + let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + // the plain merge (`develop.merge`) follows the same rule + assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy); + assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin); + assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin); + } + #[test] fn default_hdr_is_not_serialized_so_existing_hashes_stay() { let s = DevelopSettings::default(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 8bd150600..acaa303fc 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -187,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { let base = s.to_json(); let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, 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) { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 54577bffc..1895776b3 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -81,16 +81,20 @@ impl ProcessVersion { pub enum Process { /// The rendering of LightCraft before process versions existed (2026-10). V1, + /// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base + /// is taken of a fine median of log luminance (issue #632). + V2, } impl Process { /// Every process, oldest first. - pub const ALL: [Process; 1] = [Process::V1]; - pub const LATEST: Process = Process::V1; + pub const ALL: [Process; 2] = [Process::V1, Process::V2]; + pub const LATEST: Process = Process::V2; pub const fn version(self) -> ProcessVersion { match self { Process::V1 => ProcessVersion(1), + Process::V2 => ProcessVersion(2), } } } @@ -242,7 +246,13 @@ impl WbMode { WbMode::Custom => "Custom", } } - /// Preset temperature/tint for raw files (K, tint). + /// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 / + /// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for + /// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction + /// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta + /// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same + /// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws + /// without matrices) `develop.wb` scales them around 6500 K = as shot. pub fn preset(self) -> Option<(f64, f64)> { match self { WbMode::Daylight => Some((5500.0, 10.0)), @@ -256,9 +266,35 @@ impl WbMode { } } -/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files -/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an -/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom). +/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on. +/// +/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin; +/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0 +/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them +/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's +/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the +/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WbScale { + /// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot) + /// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written. + #[default] + Legacy, + /// Absolute Kelvin and tint, as Lightroom writes them for raws. + Kelvin, +} + +impl WbScale { + fn is_legacy(&self) -> bool { + *self == WbScale::Legacy + } +} + +/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the +/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is +/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a +/// relative −100..100 scale for those, like Lightroom). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct WhiteBalance { @@ -266,11 +302,17 @@ pub struct WhiteBalance { pub temp: f64, /// Correction direction: negative adds green, positive adds magenta. pub tint: f64, + /// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings + /// from before it existed read and write unchanged. + #[serde(skip_serializing_if = "WbScale::is_legacy")] + pub scale: WbScale, } impl Default for WhiteBalance { fn default() -> Self { - Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 } + // (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the + // scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`) + Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy } } } @@ -656,6 +698,33 @@ impl Default for Optics { pub struct EmbeddedLens { pub warp: Option, pub vignette: Option, + /// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps + /// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode. + pub stages: [Option; WARP_STAGES], +} + +/// Most warp stages an [`EmbeddedLens`] chain keeps. +pub const WARP_STAGES: usize = 3; + +/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B) +/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset +/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is +/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct EmbeddedWarpStage { + pub planes: [[f64; 19]; 3], + /// Optical centre, normalized to the oriented image (0..1). + pub center: Point, + /// Normalisation radius as a fraction of the oriented image's long edge. + pub radius: f64, + pub reciprocal: bool, +} + +impl EmbeddedLens { + /// Whether a `WarpRectilinear2` chain is set. + pub fn has_stages(&self) -> bool { + self.stages.iter().any(Option::is_some) + } } /// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at @@ -963,6 +1032,8 @@ pub enum SpotMode { Remove, Heal, Clone, + /// Content-aware: the spot is rebuilt from patches of its surroundings (no single source). + Fill, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a17e0885a..c8a3a565f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,9 +24,11 @@ lightcraft-raw = { workspace = true } lightcraft-denoise-core = { workspace = true } lightcraft-denoise = { workspace = true, optional = true } lightcraft-meta = { workspace = true } +lightcraft-lensdb = { workspace = true } lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } +lightcraft-geocode = { workspace = true } lightcraft-slideshow = { workspace = true } rayon = { workspace = true } serde = { workspace = true } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index d47c135a5..858f3b4cf 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -22,8 +22,10 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa /// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies. /// /// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO) -/// and the ILCE-7CR profile (#528, #583, #568, #616). -pub const LOOK_VERSION: u32 = 2; +/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF +/// states, for every make (#778). +/// 4: a tone curve from black-and-white camera JPEGs (#577). +pub const LOOK_VERSION: u32 = 4; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -63,16 +65,20 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to /// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); /// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is -/// accepted renders as without them; when used they are written into `raw.color`. Returns the as-shot white, the -/// camera transform at it and the fitted look. +/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and +/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's +/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the +/// as-shot white, the camera transform at it and the fitted look. pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t)) + starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) } -/// [`starting_colour`] with the JPEG fit passed in (tests pin the order with it). +/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white +/// JPEG's tone fit passed in (tests pin the order with them). fn starting_colour_with( raw: &mut RawImage, - fit: impl FnOnce(&RawImage, &CameraTransform) -> Option, + fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), + monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, ) -> (Xy, CameraTransform, Option) { let start = |raw: &RawImage| { let xy = lightcraft_raw::color::as_shot_white_xy(raw); @@ -82,30 +88,47 @@ fn starting_colour_with( if !t.matrix_is_fallback { return (xy, t, None); } - if let Some(look) = fit(raw, &t) { + let (look, black_and_white) = fit(raw, &t); + if let Some(look) = look { return (xy, t, Some(look)); } - let Some(camera) = raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) else { - return (xy, t, None); + let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) { + Some(camera) => { + camera.fill(&mut raw.color); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); + } + start(raw) + } + None => (xy, t), }; - camera.fill(&mut raw.color); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); - } - let (xy, t) = start(raw); - (xy, t, None) + // only a black-and-white JPEG: a colour one that gave no look pays nothing more + let look = if black_and_white { monochrome(raw, &t) } else { None }; + (xy, t, look) } -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { +/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { - return None; + return (None, false); } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - let look = fit_look(&sensor, &reference, &clipped, colour)?; + let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) { + Some(look) => look, + // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve + // (`monochrome_look` does it without a profile). + None if colour.is_some() && black_and_white => { + let unclipped = without_clipped(&sensor, &clipped); + let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) }; + look + } + None => return (None, black_and_white), + }; // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); @@ -120,7 +143,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf profile.is_some() ); } - Some(look) + (Some(look), black_and_white) } /// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in @@ -761,6 +784,38 @@ fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { if variance > 0.0 { covariance / variance } else { 0.0 } } +/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without +/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order, +/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related. +fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + let mut start = 0; + while start < order.len() { + let mut end = start + 1; + while end < order.len() && values[order[end]] == values[order[start]] { + end += 1; + } + let mean = (start + end - 1) as f64 / 2.0; + for &i in &order[start..end] { + ranks[i] = mean; + } + start = end; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, scene_variance, jpeg_variance) = scene + .iter() + .zip(&jpeg) + .fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean))); + if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 } +} + /// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of /// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs /// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered @@ -828,6 +883,107 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) (samples > 0 && better).then_some(look) } +/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. +const GREY_SPREAD: f32 = 0.002; + +/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon +/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within +/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one. +fn monochrome(reference: &Rgb32f) -> bool { + let coloured = reference + .data + .iter() + .filter(|p| { + let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + !p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD + }) + .count(); + reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len() +} + +/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera +/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo +/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker +/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. +/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and +/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The +/// chroma curve stays neutral: the JPEG has no colourfulness to learn. +fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + if !monochrome(&reference) { + return None; + } + let unclipped = without_clipped(&sensor, &clipped); + let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?; + // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = if dro { with_dro_off_tone(look) } else { look }; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}", + raw.format, + look.tone, + if transform.matrix_is_fallback { "fallback" } else { "spectral" } + ); + } + Some(look) +} + +/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's +/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of +/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below +/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. +fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit)) +} + +fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { + // every pair counts: the JPEG has no colour to require + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let related = rank_correlation_ties(&pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None)); + let correction = hue_sat.as_ref().and_then(HueSat::new); + let coloured = |x: [f64; 3]| { + let p = matrix.apply(x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let tone_pairs: Vec<_> = + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(); + let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() }; + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target)); + before += (fallback - target).powi(2); + after += (toned - target).powi(2); + before_encoded += (encoded(fallback) - encoded(target)).powi(2); + after_encoded += (encoded(toned) - encoded(target)).powi(2); + samples += 1; + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} + /// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. fn to_prophoto() -> Mat3 { PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) @@ -1552,7 +1708,11 @@ mod tests { let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); - let (_, t, look) = starting_colour_with(&mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices")); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| panic!("no JPEG fit for a raw with its own matrices"), + |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), + ); assert_eq!(raw.color, color); assert!(!t.matrix_is_fallback && look.is_none()); } @@ -1566,10 +1726,14 @@ mod tests { assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); - let (xy, t, look) = starting_colour_with(&mut raw, |raw, t| { - assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); - Some(fitted.clone()) - }); + let (xy, t, look) = starting_colour_with( + &mut raw, + |raw, t| { + assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); + (Some(fitted.clone()), false) + }, + |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), + ); assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); assert!(t.matrix_is_fallback, "{model}"); @@ -1591,7 +1755,7 @@ mod tests { let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour_with(&mut raw, |_, _| None); + let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step")); assert!(look.is_none()); assert_eq!(raw.color.illuminant, [17, 21]); assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); @@ -1602,6 +1766,43 @@ mod tests { assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); } + /// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the + /// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise + /// (issue #577). The black-and-white fit sees that transform. + #[test] + fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() { + let toned = CameraLook { + matrix: Mat3::IDENTITY, + tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone, + hue_sat: None, + }; + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |raw, t| { + assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); + Some(toned.clone()) + }, + ); + assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); + Some(toned.clone()) + }, + ); + assert!(look.is_some() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); + } + /// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. #[test] fn a_camera_outside_the_table_keeps_the_fallback() { @@ -1642,7 +1843,31 @@ mod tests { assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - assert_eq!(info.relative_wb, !own, "{name}"); + // white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the + // spectral matrices, whichever look it got (issue #730) + assert_eq!(info.relative_wb, !own && !covered, "{name}"); + assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); + if covered && !own { + assert!( + (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, + "{name}: {info:?}" + ); + } + // the same as the catalog decides from the probe (format and camera) + let probe = crate::files::probe_bytes(name, &bytes).unwrap(); + let mut p = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + lightcraft_catalog::Source::File { path: name.into() }, + name, + &probe.format, + 1, + 1, + "", + ); + p.kind = probe.kind; + p.meta = probe.meta.clone(); + assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); + assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); } } @@ -1718,7 +1943,7 @@ mod tests { assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform); + let look = fit_preview(&raw, &bytes, &transform).0; eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -1751,7 +1976,7 @@ mod tests { let raw = lightcraft_raw::decode(&bytes).unwrap(); assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { @@ -1997,7 +2222,7 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).expect("a camera look"); + let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); // the look fitted to the JPEG, as before this change let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); @@ -2071,15 +2296,24 @@ mod tests { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); - for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] { + // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those + // matrices; the X-T2 and X-T20 have none and stay relative) + for (name, accepted, kelvin) in + [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] + { let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; let header = crate::files::probe_bytes(name, &bytes).unwrap(); - assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); - assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); + assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); + if kelvin { + assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); + } else { + assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } } } @@ -2397,4 +2631,183 @@ mod tests { reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); } + + /// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's + /// luminance, grey in every pixel. + fn black_and_white_scene() -> (Rgb32f, Rgb32f) { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 53) as f32 * 0.009; + *p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)]; + } + let mut reference = sensor.clone(); + reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]); + (sensor, reference) + } + + #[test] + fn black_and_white_jpegs_are_recognised() { + let (sensor, mut reference) = black_and_white_scene(); + assert!(monochrome(&reference)); + assert!(!monochrome(&sensor), "a colour JPEG"); + // a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour + let n = reference.data.len(); + reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05); + assert!(monochrome(&reference)); + reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05); + assert!(!monochrome(&reference)); + // a sepia-toned monochrome + let (_, mut toned) = black_and_white_scene(); + toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]); + assert!(!monochrome(&toned)); + assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell"); + // the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts + let (_, mut faint) = black_and_white_scene(); + faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]); + assert!(monochrome(&faint)); + faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]); + assert!(!monochrome(&faint)); + // pixels that aren't numbers can't vouch for grey + let (_, mut broken) = black_and_white_scene(); + broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]); + assert!(!monochrome(&broken)); + } + + /// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's + /// brightness like the camera, with the colour left as it was and the chroma curve neutral. + #[test] + fn a_black_and_white_jpeg_gives_its_tone_curve() { + let (sensor, reference) = black_and_white_scene(); + let none = vec![false; sensor.data.len()]; + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit"); + let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was"); + assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma()); + let n = sensor.data.len() as f64; + let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::() / n; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / n; + let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)); + assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}"); + assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker"); + // with a camera profile's colour, the look carries it + let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]); + let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour"); + assert_eq!(look.matrix, matrix); + } + + /// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries + /// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's. + #[test] + fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { + // saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped) + let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]]; + let mut sensor = Rgb32f::new(96, 64); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 47) as f32 * 0.008; + *p = hues[i % 6].map(|c| c * ev); + } + // a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]); + let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks"); + let own = fit_tone_only(&sensor, &reference, None); + assert!(own.is_some(), "the photo's own colour passes"); + // and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices) + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(!t.matrix_is_fallback, "on the spectral matrices"); + own.clone() + }, + ); + assert!(look.is_some() && !t.matrix_is_fallback); + } + + /// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, + /// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the + /// same file with a tinted JPEG gets nothing from this step. + #[test] + fn monochrome_look_reads_the_files_black_and_white_jpeg() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; + let (w, h) = (192usize, 128usize); + let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let raw = RawImage { + format: RawFormat::Arw, + width: w, + height: h, + cpp: 3, + data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + }; + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG + let file = |tint: [f32; 3]| { + let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + let pixels: Vec = (0..w * h) + .flat_map(|i| { + let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); + tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it + let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(4u16.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + assert_eq!(tiff.len(), 38); + tiff.extend(jpeg); + tiff + }; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); + let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let mid = curve.apply(0.25); + assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white"); + } + + /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve + /// already renders, gives no look. + #[test] + fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() { + let (sensor, mut reference) = black_and_white_scene(); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]); + assert!(monochrome(&reference)); + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve"); + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 29) as f32 * 0.02; 3]; + } + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture"); + // a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties + // ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey) + let mut sorted = sensor.clone(); + sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b))); + reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); + assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); + } } diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 6406db479..a681ec14b 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result { v.sort(); v }; - let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?; + // looking at a folder never brings its deleted photos back (importing the files does) + let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; + let report = import_with(s, &files, &opts)?; stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; @@ -105,7 +107,7 @@ impl Session { let smart = self.media.smart_dir.clone(); let plan = self .catalog - .forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists())); + .forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists())); if dry_run { return plan; } @@ -195,6 +197,24 @@ pub fn specs() -> Vec { Ok(json!({"path": to, "relinked": n})) } ), + cmd!( + "folder.create", + "New Folder on Disk", + [], + None, + "{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}", + always, + create_folder + ), + cmd!( + "photo.moveToFolder", + "Move to Folder", + [], + None, + "{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}", + always, + move_to_folder + ), cmd!( "library.browse", "Browse Folder", @@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}")) } +/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the +/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites. +pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> { + match (from.is_empty(), to.is_empty()) { + (false, false) => rename_folder_on_disk(from, to), + (false, true) => remove_empty_folder(from), + (true, false) => { + if Path::new(to).exists() { + return Err(format!("{to} already exists")); + } + std::fs::create_dir(to).map_err(|e| format!("{to}: {e}")) + } + (true, true) => Ok(()), + } +} + +/// Remove an empty folder; one with anything in it (hidden files too) is refused. +fn remove_empty_folder(path: &str) -> std::result::Result<(), String> { + let p = Path::new(path); + if !p.is_dir() { + return Err(format!("{path} is not a folder")); + } + let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?; + if entries.next().is_some() { + return Err(format!("{path} is not empty")); + } + std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}")) +} + +/// A folder path parameter without trailing separators (`None` when missing or blank). +fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty()) +} + +/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty. +fn create_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "folder.create"; + let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?; + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..") + .ok_or_else(|| bad(C, "give a folder name (no slashes)"))?; + if !Path::new(parent).is_dir() { + return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)"))); + } + let path = Path::new(parent).join(name).to_string_lossy().to_string(); + if Path::new(&path).exists() { + return Err(bad(C, format!("{path} already exists: choose another name"))); + } + folder_on_disk("", &path).map_err(|e| bad(C, e))?; + let undo = crate::FolderMove { from: path.clone(), to: String::new() }; + if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) { + let _ = remove_empty_folder(&path); + return Err(e); + } + Ok(json!({"path": path})) +} + +/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink +/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name +/// already taken in `folder` refuses the whole move. +fn move_to_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.moveToFolder"; + let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?; + let dir = Path::new(folder); + if !dir.is_dir() { + return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); + } + let ids = s.targets(p); + if ids.is_empty() { + return Err(bad(C, "no photos to move: select some or give ids")); + } + let mut plans: Vec = Vec::new(); + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut clashes: Vec = Vec::new(); + for id in ids { + let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?; + if ph.local { + return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name))); + } + let lightcraft_catalog::Source::File { path } = &ph.source else { + return Err(bad(C, format!("{} has no file on disk", ph.file_name))); + }; + if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) { + continue; // a virtual copy of a photo already moving + } + let src = Path::new(path); + if !src.is_file() { + return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)"))); + } + let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) }; + if src.parent() == Some(dir) { + continue; // already there + } + let to = dir.join(file); + let to_s = to.to_string_lossy().to_string(); + if to.exists() || !taken.insert(to_s.to_lowercase()) { + clashes.push(file.to_string_lossy().to_string()); + continue; + } + let name = ph.file_name.clone(); + plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) }); + } + if !clashes.is_empty() { + let s_ = if clashes.len() == 1 { "" } else { "s" }; + return Err(bad( + C, + format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")), + )); + } + let n = plans.len(); + if n == 0 { + return Ok(json!({"moved": 0, "folder": folder})); + } + let plural = |k: usize| if k == 1 { "" } else { "s" }; + let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No); + let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string()); + s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?; + Ok(json!({"moved": n, "folder": folder})) +} + /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { // a library folder chosen at or below it follows too, as a source or as a filter @@ -285,6 +426,9 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) { return Err("the library's own folders can't be moved here".into()); } + // Repairs retain their exact old paths, including those on surviving history entries. + // Complete them before the directory moves; an unavailable repair leaves it in place. + s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?; rename_folder_on_disk(from, to)?; let mut ops: Vec = s .catalog diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 2b1b368b2..bc2137948 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -120,8 +120,8 @@ pub fn specs() -> Vec { } /// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit -/// TIFF next to the original (`-Edit.tif`, never overwriting), add it to the library and -/// stack it on top of the original. The host opens the file in the editor. +/// TIFF (or PSD, or 8-bit) next to the original (`-Edit.tif`, never overwriting), add it to +/// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.editExternal"; let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; @@ -139,9 +139,19 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { "prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto, _ => lightcraft_pipeline::OutputSpace::AdobeRgb, }; + let format = match super::str_param(p, "format").unwrap_or("tiff") { + "tiff" | "tif" => crate::export::ExportFormat::Tiff, + "psd" => crate::export::ExportFormat::Psd, + f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))), + }; + let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) { + 8 => 8, + 16 => 16, + d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))), + }; let opts = crate::export::ExportOptions { - format: crate::export::ExportFormat::Tiff, - bit_depth: Some(16), + format, + bit_depth: Some(bit_depth), color_space: space, naming: "{name}-Edit".into(), conflict: crate::export::Conflict::Unique, @@ -156,7 +166,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { }; let to = crate::export::Destination { dir, exact: None }; crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?; - let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?; + let ext = format!(".{}", format.extension()); + let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?; let r = s.execute("library.import", &json!({"paths": [out]}))?; let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?; // stack: the edit on top of the original, expanded so both show @@ -229,6 +240,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog: ops } +/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the +/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices +/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730). +pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option { + let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?; + (ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) }) +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -248,14 +267,16 @@ fn reload(s: &mut Session, p: &Value) -> Result { // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; // nothing already set is overwritten let meta_ops = fill_missing_meta(id, ph, &info); - let mut mine: Vec = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect(); + let mut mine: Vec = + content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect(); if !mine.is_empty() { ops.append(&mut mine); reloaded.push(id); } // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - let theirs: Vec = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect(); + let theirs: Vec = + content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect(); if !theirs.is_empty() { ops.extend(theirs); reloaded.push(c.id); @@ -342,7 +363,7 @@ pub fn edit_specs() -> Vec { "Edit Copy for External Editor", [], None, - "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", + "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", has_active, edit_external ), @@ -381,6 +402,7 @@ mod tests { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), vignette: None, + stages: Default::default(), } } fn old_rw2() -> lightcraft_catalog::Photo { diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 75c6c0a7a..8802460a1 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -653,7 +653,7 @@ pub fn specs() -> Vec { ), cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue), cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel), - cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status), + cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status), cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump), cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear), ] diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 3a84b1dcc..42f59fe72 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) { if o.swaps_axes() { (h, w) } else { (w, h) } } +/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`: +/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in +/// canvas outside the photo (issue #742). +fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) { + if touched { + d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h); + } +} + +const CROP_ANGLE: &str = "crop.angle"; + /// `v` as a list of numbers; `None` unless it is an array of nothing but numbers. fn numbers(v: Option<&Value>) -> Option> { v?.as_array()?.iter().map(Value::as_f64).collect() @@ -146,7 +157,7 @@ pub fn specs() -> Vec { "Set Develop Values", [], None, - "{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls", + "{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)", has_active, |s, p| { let mut vals: Vec<(String, f64)> = Vec::new(); @@ -167,10 +178,15 @@ pub fn specs() -> Vec { } } let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() }; - let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| { - if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") { - d.wb.temp = info.as_shot_temp; - d.wb.tint = info.as_shot_tint; + let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE); + let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| { + let before = d.crop.geometry; + if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") { + lightcraft_pipeline::local::normalize_wb(d, info); + if d.wb.mode == WbMode::AsShot { + d.wb.temp = info.as_shot_temp; + d.wb.tint = info.as_shot_tint; + } } for (k, v) in &vals { controls::set(d, k, *v); @@ -178,6 +194,7 @@ pub fn specs() -> Vec { d.wb.mode = WbMode::Custom; } } + refit_crop_angle(angle, before, d, dims); }; if let Some(ids) = p.get("ids").and_then(Value::as_array) { let ids: Vec = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect(); @@ -187,7 +204,7 @@ pub fn specs() -> Vec { .filter_map(|(id, d)| { let mut d = (*d).clone(); let info = s.source_info(id); - apply(&mut d, &info); + apply(&mut d, &info, crop_dims(s, id)); s.develop_op(id, d, &label) }) .collect(); @@ -196,8 +213,9 @@ pub fn specs() -> Vec { } let id = active(s, "develop.set")?; let info = s.source_info(id); + let dims = crop_dims(s, id); edit(s, "develop.set", &label, |d| { - apply(d, &info); + apply(d, &info, dims); Ok(()) }) } @@ -206,9 +224,12 @@ pub fn specs() -> Vec { let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string(); let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?; let delta = f64_req(p, "delta", "develop.adjust")?; - let info = s.source_info(active(s, "develop.adjust")?); + let id = active(s, "develop.adjust")?; + let (info, dims) = (s.source_info(id), crop_dims(s, id)); edit(s, "develop.adjust", spec.label, |d| { + let before = d.crop.geometry; if c == "wb.temp" || c == "wb.tint" { + lightcraft_pipeline::local::normalize_wb(d, &info); if d.wb.mode == WbMode::AsShot { d.wb.temp = info.as_shot_temp; d.wb.tint = info.as_shot_tint; @@ -217,6 +238,7 @@ pub fn specs() -> Vec { } let v = controls::get(d, &c).unwrap_or(spec.default); controls::set(d, &c, v + delta); + refit_crop_angle(c == CROP_ANGLE, before, d, dims); Ok(()) }) }), @@ -244,10 +266,17 @@ pub fn specs() -> Vec { // of its current as-shot reference let info = s.source_info(id); let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default(); - DevelopSettings { - wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint }, + let mut fresh = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { + mode: WbMode::AsShot, + temp: info.as_shot_temp, + tint: info.as_shot_tint, + ..Default::default() + }, ..defaults - } + }; + lightcraft_pipeline::local::normalize_wb(&mut fresh, &info); + fresh } }; // a fresh start renders with the current process (a section or slider reset doesn't change it) @@ -306,8 +335,11 @@ pub fn specs() -> Vec { cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| { let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string(); let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?; + let dims = crop_dims(s, active(s, "develop.resetControl")?); edit(s, "develop.resetControl", spec.label, |d| { + let before = d.crop.geometry; controls::set(d, &c, spec.default); + refit_crop_angle(c == CROP_ANGLE, before, d, dims); Ok(()) }) }), @@ -381,7 +413,7 @@ pub fn specs() -> Vec { m => m .preset() .map(|(t, ti)| { - // presets are relative to daylight for rendered files + // presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) } }) .unwrap_or((info.as_shot_temp, info.as_shot_tint)), @@ -391,6 +423,7 @@ pub fn specs() -> Vec { // The resolved temperature/tint is stored, so rendering never depends on the mode; the // mode is kept for the UI's WB dropdown. edit(s, "develop.wb", "White Balance", |d| { + lightcraft_pipeline::local::normalize_wb(d, &info); d.wb.mode = mode; d.wb.temp = t.clamp(2000.0, 50000.0); d.wb.tint = tint.clamp(-150.0, 150.0); @@ -420,6 +453,7 @@ pub fn specs() -> Vec { let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc); let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info); edit(s, "develop.wbPick", "White Balance", |d| { + lightcraft_pipeline::local::normalize_wb(d, &info); d.wb.mode = WbMode::Custom; d.wb.temp = t; d.wb.tint = tint; @@ -493,10 +527,12 @@ pub fn specs() -> Vec { } d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])); } - if let Some(a) = p.get("angle").and_then(Value::as_f64) { - d.crop.geometry.angle = a.clamp(-45.0, 45.0); - } - d.crop.geometry = d.crop.geometry.constrained(w, h); + let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0)); + d.crop.geometry = match (p.get("rect"), angle) { + // a new angle alone turns the crop as Straighten does (issue #742) + (None, Some(a)) => d.crop.geometry.with_angle(a, w, h), + (_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h), + }; Ok(()) }) }), @@ -505,7 +541,7 @@ pub fn specs() -> Vec { "Drag Crop Handle", [], None, - "{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock", + "{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)", has_active, |s, p| { let id = active(s, "crop.drag")?; @@ -530,11 +566,13 @@ pub fn specs() -> Vec { } _ => return Err(bad("crop.drag", "`start` needs 4 numbers")), }; + let keep_ratio = bool_or(p, "keepRatio", false); edit(s, "crop.drag", "Crop", |d| { let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle }; // A locked crop keeps the shape it has: the lock stores a width:height tuple whose // orientation is reinterpreted per image, so the frame itself is the truth. - let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0); + // `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock. + let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0); d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio); Ok(()) }) @@ -552,11 +590,8 @@ pub fn specs() -> Vec { let (w, h) = crop_dims(s, id); let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0); edit(s, "crop.straighten", "Straighten", |d| { - let r = d.crop.geometry.rect_px(w, h); - let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height())); - // keep the current crop size if it still fits, else shrink - let cand = CropGeometry { rect: d.crop.geometry.rect, angle }; - d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit }; + // keep the current crop if it still fits, else the largest of its aspect that does + d.crop.geometry = d.crop.geometry.with_angle(angle, w, h); Ok(()) }) } @@ -851,13 +886,19 @@ pub fn specs() -> Vec { .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { let mut nd = (*d).clone(); - if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") { + if ctl == "wb.temp" || ctl == "wb.tint" { + // each photo on its own scale: Kelvin (an old relative value converted first) or relative let info = s.source_info(id); - nd.wb.temp = info.as_shot_temp; - nd.wb.tint = info.as_shot_tint; + lightcraft_pipeline::local::normalize_wb(&mut nd, &info); + if nd.wb.mode == WbMode::AsShot { + nd.wb.temp = info.as_shot_temp; + nd.wb.tint = info.as_shot_tint; + } } + let before = nd.crop.geometry; let v = controls::get(&nd, &ctl).unwrap_or(spec.default); controls::set(&mut nd, &ctl, v + delta); + refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id)); if ctl == "wb.temp" || ctl == "wb.tint" { nd.wb.mode = WbMode::Custom; } diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 3a8e401dd..6033c4dcc 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -464,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result { if st.embedder.as_deref() == Some(id) { // another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id); + // on with nothing to run is not a state (issue #678) + st.enabled &= st.embedder.is_some(); write_settings(&dir, &st)?; } Ok(json!({"removed": id, "embedder": st.embedder})) @@ -491,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result { const C: &str = "faces.enable"; let dir = models_dir(s, C)?; let mut st = read_settings(&dir); - st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled); + let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled); + // on means something only with a model to run (issue #678): a toggle that is on with nothing + // chosen would sit there doing nothing, with no word about why + let usable = + st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder)); + if on && !usable { + return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)")); + } + st.enabled = on; write_settings(&dir, &st)?; // the background scan notices at once, not within the second it trusts its last look #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs new file mode 100644 index 000000000..ddbe6b13a --- /dev/null +++ b/crates/engine/src/cmd/geocode.rs @@ -0,0 +1,472 @@ +//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the +//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user +//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder; +//! after that every lookup is local and no position ever leaves the machine. +//! +//! Sources: GeoNames dump readme (prose); own design. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::SystemTime; + +use serde_json::{Value, json}; + +use lightcraft_catalog::{Op, PhotoId}; +use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon}; + +use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param}; +use crate::activity::Cancel; +use crate::{Result, Session}; + +/// Where the place names live: `dataset` (a folder) or `/geonames`. +fn dataset_dir(p: &Value) -> Option { + str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames"))) +} + +/// How a place-names download ended. +#[derive(Clone, Debug, PartialEq)] +pub enum DownloadOutcome { + /// Done: this many places. + Done(usize), + /// Failed, in words the user can act on. + Failed(String), + Cancelled, +} + +#[derive(Default)] +struct DownloadShared { + running: bool, + last: Option, + /// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]). + unreported: Option, +} + +/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state. +#[derive(Default)] +pub struct PlaceNames { + shared: Arc>, + /// (cities file, its size, its modification time, the parsed names). + cache: Option<(PathBuf, u64, Option, Arc)>, +} + +impl PlaceNames { + fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> { + self.shared.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A download that ended since the last call (the UI shows it once: an error stays readable as a toast). + pub fn take_outcome(&self) -> Option { + self.lock().unreported.take() + } + + pub fn downloading(&self) -> bool { + self.lock().running + } + + /// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes. + fn load(&mut self, dir: &Path) -> std::result::Result, lightcraft_geocode::GeocodeError> { + let file = dir.join(GEONAMES_FILES[0]); + let meta = std::fs::metadata(&file).ok(); + let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok())); + if let Some((f, l, t, g)) = &self.cache + && meta.is_some() + && *f == file + && *l == len + && *t == mtime + { + return Ok(g.clone()); + } + let g = Arc::new(GeoNames::load(dir)?); + self.cache = Some((file, len, mtime, g.clone())); + Ok(g) + } +} + +fn status(s: &mut Session, p: &Value) -> Result { + let dir = dataset_dir(p); + let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len()); + let sh = s.place_names.lock(); + let (last, error) = match &sh.last { + None => (Value::Null, Value::Null), + Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null), + Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)), + Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null), + }; + Ok(json!({ + "downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error, + "attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE, + })) +} + +/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`. +fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) { + for (field, value) in [ + (&mut meta.location, &place.sublocation), + (&mut meta.city, &place.city), + (&mut meta.state, &place.state), + (&mut meta.country, &place.country), + ] { + if !value.is_empty() && (overwrite || field.trim().is_empty()) { + *field = value.clone(); + } + } +} + +fn reverse(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.reverseGeocode"; + let overwrite = bool_or(p, "overwrite", false); + let ids = s.targets(p); + let todo: Vec<(PhotoId, LatLon)> = ids + .iter() + .filter_map(|id| { + let (la, lo) = s.catalog.photo(*id)?.meta.gps?; + let at = LatLon::new(la, lo); + at.is_valid().then_some((*id, at)) + }) + .collect(); + if todo.is_empty() { + return Err(bad(C, "none of these photos has a GPS position")); + } + let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?; + let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?; + // a large selection shows in the activity stack and can be stopped + let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes)); + let mut ops = Vec::new(); + let mut found = Vec::new(); + for (n, (id, at)) in todo.iter().enumerate() { + if let Some(t) = &task { + if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) { + return Err(bad(C, "cancelled")); + } + t.progress(n as u64, todo.len() as u64); + } + let Some(place) = names.reverse(*at) else { continue }; + let Some(ph) = s.catalog.photo(*id) else { continue }; + let mut meta = ph.meta.clone(); + fill(&mut meta, &place, overwrite); + if meta != ph.meta { + ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); + } + found.push(json!({"id": id.0, "place": place})); + } + drop(task); + let changed = ops.len(); + if !bool_or(p, "dryRun", false) && changed > 0 { + s.commit("Reverse Geocode", Op::Batch { ops })?; + } + Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION})) +} + +#[cfg(not(target_arch = "wasm32"))] +mod download { + use std::path::Path; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP}; + + use super::*; + use crate::activity::Unit; + + const MAX_ZIP: u64 = 64 * 1024 * 1024; + const MAX_TEXT: usize = 256 * 1024 * 1024; + + fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> { + let _ = std::fs::remove_file(dir.join(name)); + let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP }; + lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| { + task.progress(pr.done, pr.total.max(pr.done)) + }) + .map_err(|e| e.to_string()) + } + + /// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete. + pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result { + let staging = dir.join(".download"); + std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?; + get(GEONAMES_ZIP, base, &staging, cancel, task)?; + let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?; + let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?; + let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len(); + if places == 0 { + return Err("the downloaded place list is empty".into()); + } + let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP)); + let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}")); + for name in &GEONAMES_FILES[1..] { + // optional: without them states stay blank and countries are ISO codes + match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) { + Ok(b) => write(name, &b)?, + Err(e) => log::warn!("place names: {name}: {e}"), + } + let _ = std::fs::remove_file(staging.join(name)); + } + write(GEONAMES_FILES[0], &cities)?; + let _ = std::fs::remove_dir(&staging); + Ok(places) + } + + /// A download base: https, or plain http to this machine's loopback address only (a test + /// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/` + /// is refused. + pub(super) fn base_allowed(base: &str) -> bool { + base.starts_with("https://") + || base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/')) + } + + /// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable). + pub(super) fn start(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.downloadPlaceNames"; + let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?; + let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string(); + if !base_allowed(&base) { + return Err(bad(C, "`base` must be an https:// address")); + } + let shared = s.place_names.shared.clone(); + { + let mut sh = s.place_names.lock(); + if sh.running { + return Err(bad(C, "the place names are already downloading")); + } + sh.running = true; + } + let cancel = Arc::new(AtomicBool::new(false)); + let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail("GeoNames"); + let handle = task.handle(); + let thread_dir = dir.clone(); + let thread_shared = shared.clone(); + let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || { + // the activity row goes when the thread ends, however it ends + let _task = task; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle))); + let outcome = match outcome { + Ok(Ok(n)) => DownloadOutcome::Done(n), + _ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled, + Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")), + Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()), + }; + if let DownloadOutcome::Failed(e) = &outcome { + log::warn!("{e}"); + } + let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner); + sh.running = false; + sh.last = Some(outcome.clone()); + sh.unreported = Some(outcome); + }); + if let Err(e) = spawned { + shared.lock().unwrap_or_else(PoisonError::into_inner).running = false; + return Err(bad(C, format!("could not start the download: {e}"))); + } + Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION})) + } +} + +pub fn specs() -> Vec { + #[allow(unused_mut)] + let mut v = vec![ + cmd!( + "metadata.reverseGeocode", + "Fill Location from GPS", + ["Photo"], + None, + "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step", + has_selection, + reverse + ), + cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status), + ]; + #[cfg(not(target_arch = "wasm32"))] + v.push(cmd!( + "metadata.downloadPlaceNames", + "Download Place Names (GeoNames)", + ["Photo"], + None, + "{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent", + always, + download::start + )); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\ + 2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n"; + + fn dataset(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap(); + dir + } + + fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) { + let mut meta = s.catalog.photo(id).unwrap().meta.clone(); + meta.gps = gps; + meta.city = city.into(); + meta.state.clear(); + meta.country.clear(); + meta.location.clear(); + s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + + #[test] + fn fills_empty_fields_keeps_typed_ones_and_undoes() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let dir = dataset("fill"); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster"); + set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), ""); + set_gps(&mut s, ids[3], Some((0.0, 0.0)), ""); + let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap(); + assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}"); + let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone(); + assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France")); + let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone(); + assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom")); + assert!(s.activity.list().is_empty(), "the activity row ends with the command"); + // one undo step + s.undo_step().unwrap(); + assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty()); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster"); + // overwrite replaces typed text + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap(); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explains_missing_data_and_gps() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + set_gps(&mut s, ids[0], None, ""); + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err(); + assert!(e.to_string().contains("GPS"), "{e}"); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id())); + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err(); + assert!(e.to_string().contains("download"), "{e}"); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap(); + assert_eq!(st["downloaded"], false); + } + + #[test] + fn the_gazetteer_is_cached_until_its_file_changes() { + let dir = dataset("cache"); + let mut names = PlaceNames::default(); + let a = names.load(&dir).unwrap(); + assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read"); + // a different size invalidates it (a new download) + std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap(); + let b = names.load(&dir).unwrap(); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome"); + std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap(); + assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip + /// that isn't one), `short` (the body ends before its Content-Length), anything else 404. + #[cfg(not(target_arch = "wasm32"))] + fn serve() -> String { + use std::io::{BufRead, BufReader, Write}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes()); + std::thread::spawn(move || { + for conn in l.incoming() { + let Ok(mut c) = conn else { continue }; + let mut r = BufReader::new(c.try_clone().unwrap()); + let mut line = String::new(); + let _ = r.read_line(&mut line); + loop { + let mut h = String::new(); + if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() { + break; + } + } + let path = line.split(' ').nth(1).unwrap_or("").to_string(); + let (status, body, len): (&str, Vec, Option) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) { + ("good", true) => ("200 OK", zip.clone(), None), + ("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None), + ("garbage", true) => ("200 OK", b"not a zip".to_vec(), None), + ("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())), + _ => ("404 Not Found", b"nope".to_vec(), None), + }; + let n = len.unwrap_or(body.len()); + let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes()); + let _ = c.write_all(&body); + } + }); + format!("http://127.0.0.1:{port}") + } + + #[cfg(not(target_arch = "wasm32"))] + fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome { + s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap(); + assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some()); + let started = std::time::Instant::now(); + loop { + if let Some(o) = s.place_names.take_outcome() { + // the activity row ends with the thread + while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(s.activity.list().is_empty()); + return o; + } + assert!(started.elapsed().as_secs() < 60, "the download never ended"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn download_end_to_end_and_its_failures() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let base = serve(); + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err()); + + for bad in ["missing", "garbage", "short"] { + let o = download(&mut s, &format!("{base}/{bad}/"), &dir); + let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") }; + assert!(e.contains("try again"), "{bad}: {e}"); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap(); + assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}"); + assert_eq!(st["lastError"].as_str(), Some(e.as_str())); + assert!(s.place_names.take_outcome().is_none(), "reported once"); + } + + assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2)); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap(); + assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2))); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap(); + let m = s.catalog.photo(ids[0]).unwrap().meta.clone(); + // admin1 was a 404 (optional): the state stays blank, the rest is filled + assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn download_base_is_https_or_loopback_only() { + assert!(download::base_allowed("https://download.geonames.org/export/dump/")); + assert!(download::base_allowed("http://127.0.0.1:8080")); + assert!(download::base_allowed("http://127.0.0.1/dump")); + assert!(!download::base_allowed("http://127.0.0.1.example.com/")); + assert!(!download::base_allowed("http://127.0.0.1@example.com/")); + assert!(!download::base_allowed("http://download.geonames.org/")); + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 27e96f4fd..263f94c2b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -4,7 +4,7 @@ use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { @@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result { } let on_deleted = match str_param(p, "onDeleted") { Some(v) => { - crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))? + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? } None => Default::default(), }; @@ -149,6 +149,24 @@ fn ids_param(p: &Value) -> Option> { p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()) } +/// The targets an album can hold and how many were browsed (Local) photos, which album views +/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. +fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { + let before = targets.len(); + let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); + let skipped = before - kept.len(); + (kept, skipped) +} + +/// Album targets, or the error for a selection that was nothing but Local photos. +fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { + let (kept, skipped) = album_targets(s, targets); + if kept.is_empty() && skipped > 0 { + return Err(bad(command, super::LOCAL_ONLY)); + } + Ok((kept, skipped)) +} + /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { let targets = s.targets(p); @@ -866,22 +884,31 @@ pub fn specs() -> Vec { } ), // ---- albums - cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.create", "empty name")); + cmd!( + "album.create", + "New Album", + ["File"], + None, + "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.create", "empty name")); + } + let folder = bool_or(p, "folder", false); + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let (photos, skipped) = + if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let id = s.catalog.alloc_album_id(); + let cover = photos.first().copied(); + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, + )?; + Ok(json!({"id": id.0, "skipped": skipped})) } - let folder = bool_or(p, "folder", false); - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] }; - let id = s.catalog.alloc_album_id(); - let cover = photos.first().copied(); - s.commit( - if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, - )?; - Ok(json!({"id": id.0})) - }), + ), cmd!( "album.createSmart", "New Smart Album…", @@ -1103,34 +1130,44 @@ pub fn specs() -> Vec { Ok(json!({"changed": changed})) } ), - cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| { - let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); - let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); - } - let mut photos = al.photos.clone(); - let before = photos.len(); - for t in targets { - if !photos.contains(&t) { - photos.push(t); + cmd!( + "album.addPhotos", + "Add to Album", + ["Photo"], + None, + "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", + has_library_selection, + |s, p| { + let id = album_param(p, "id", "album.addPhotos")?; + let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); } + let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; + let mut photos = al.photos.clone(); + let before = photos.len(); + for t in targets { + if !photos.contains(&t) { + photos.push(t); + } + } + let added = photos.len() - before; + let cover = al.cover.or(photos.first().copied()); + s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; + Ok(json!({"added": added, "skipped": skipped})) } - let added = photos.len() - before; - let cover = al.cover.or(photos.first().copied()); - s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; - Ok(json!({"added": added})) - }), + ), cmd!( "album.toggleTarget", "Add to Target Album", [], None, - "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}", - has_selection, + "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", + has_library_selection, |s, p| { - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { Some(id) => id, @@ -1154,7 +1191,7 @@ pub fn specs() -> Vec { if all_in { "Remove from Target Album" } else { "Add to Target Album" }, Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, )?; - Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)})) + Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) } ), cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { @@ -1246,7 +1283,9 @@ pub fn specs() -> Vec { return Ok(json!({"imported": [], "folder": folder})); } let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; - let mut params = json!({"paths": fresh, "mode": mode}); + // a photo deleted from the library doesn't come back on its own because its file + // is still in the watched folder (a copy's source is not a known path) + let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); if let Some(a) = s.import_defaults.auto_album.clone() { match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { Some(al) => params["album"] = json!(al.id.0), @@ -1326,7 +1365,7 @@ pub fn specs() -> Vec { "Import Photos", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", always, |s, p| { let req = import_params(s, p)?; diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 8eb9574e8..349e66ea8 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -34,22 +34,34 @@ pub fn register_all(s: &Session) { } } -/// `.cube` files in `paths` (folders searched, zip bundles opened). -fn cube_files(paths: &[String]) -> Vec<(String, String, Option)> { +/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't +/// be read with the reason (`[name, reason]`). +fn cube_files(paths: &[String]) -> (Vec<(String, String, Option)>, Vec) { // (name, text, group) let mut out = Vec::new(); + let mut failed = Vec::new(); + let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); for p in paths { let path = Path::new(p); if path.is_dir() { let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube")); for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files { - if let Ok(t) = std::fs::read_to_string(&ep) { - let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()); - out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group)); + match std::fs::read_to_string(&ep) { + Ok(t) => { + let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()); + out.push((stem(&ep), t, group)); + } + Err(e) => failed.push(json!([stem(&ep), e.to_string()])), } } } else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) { - let Ok(b) = std::fs::read(path) else { continue }; + let b = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + failed.push(json!([stem(path), e.to_string()])); + continue; + } + }; for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() { if inner.to_ascii_lowercase().ends_with(".cube") { let ip = Path::new(&inner); @@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option)> { )); } } - } else if let Ok(t) = std::fs::read_to_string(path) { - out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None)); + } else { + match std::fs::read_to_string(path) { + Ok(t) => out.push((stem(path), t, None)), + Err(e) => failed.push(json!([stem(path), e.to_string()])), + } } } - out + (out, failed) } fn import(s: &mut Session, p: &Value) -> Result { @@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(d) = &dir { std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?; } - let (mut imported, mut failed) = (Vec::new(), Vec::new()); - for (name, text, group) in cube_files(&paths) { + let (files, mut failed) = cube_files(&paths); + let mut imported = Vec::new(); + for (name, text, group) in files { let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) { Ok(l) => l, Err(e) => { diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 24a94cbc8..ae8b862b6 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -690,12 +690,69 @@ pub fn specs() -> Vec { Ok(json!({"spots": out, "added": found.len()})) } ), + cmd!( + "spot.findBlemishes", + "Find Blemishes", + [], + None, + "{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}", + has_active, + |s, p| { + let c = "spot.findBlemishes"; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?; + // the uncropped photo, so positions are the spots' own coordinates + let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?; + let img = job.run().rendered.map_err(|e| bad(c, e))?.image; + let rgb: Vec = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect(); + let faces = + detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?; + let boxes: Vec = faces + .iter() + .map(|f| lightcraft_pipeline::blemish::FaceBox { + x0: f64::from(f.x0), + y0: f64::from(f.y0), + x1: f64::from(f.x1), + y1: f64::from(f.y1), + landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))), + }) + .collect(); + let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20); + let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32); + let mut per_face = std::collections::BTreeMap::new(); + found.retain(|b| { + let n = per_face.entry(b.face).or_insert(0usize); + *n += 1; + *n <= max_per_face + }); + let out: Vec = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect(); + if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() { + return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0})); + } + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let base = Spot::default(); + for b in &found { + let mut spot = Spot { + mode: SpotMode::Heal, + points: vec![Point::new(b.x, b.y)], + size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1), + feather: base.feather, + opacity: 100.0, + source_offset: None, + }; + spot.source_offset = pick_source(s, id, &dd, &spot, None); + dd.spots.push(spot); + } + s.set_develop(id, dd, "Find Blemishes")?; + Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()})) + } + ), cmd!( "spot.add", "Add Remove Spot", [], None, - "{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}", + "{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}", has_active, |s, p| { let mode: SpotMode = @@ -719,7 +776,7 @@ pub fn specs() -> Vec { }; let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); - if spot.source_offset.is_none() { + if spot.source_offset.is_none() && spot.mode != SpotMode::Fill { // resolve the automatic source now: it gets a pin and stays put at every size spot.source_offset = pick_source(s, id, &dd, &spot, None); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1b3fcfd4b..41c57352c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +pub mod geocode; pub mod keywords; pub mod library; pub mod lut_profiles; @@ -101,6 +102,14 @@ pub fn has_active(s: &Session) -> std::result::Result<(), String> { pub fn has_selection(s: &Session) -> std::result::Result<(), String> { if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) } } +/// Albums hold library photos: a selection of only browsed (Local) photos has nothing an album +/// can take (issue #684; Add to My Photos first). +pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } +} +/// Why an album command refused a Local-only selection. +pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } } @@ -152,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(missing::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); + v.extend(geocode::specs()); v.extend(filters::specs()); v.extend(denoise::specs()); v.extend(face_models::specs()); diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 50ccb4bd6..4fc7ababa 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -52,16 +52,17 @@ impl PreviewBuild { } } -/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at -/// `edge` (`None` = 1:1, the photo's full size). -fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option) -> Vec { +/// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge` +/// (`None` = 1:1, the photo's full size), the larger first. +pub(crate) fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option) -> Vec { let Some(p) = s.catalog.photo(id) else { return Vec::new() }; let full = p.width.max(p.height) as usize; let e = edge.unwrap_or(full).min(full.max(1)).max(1); - let mut v = Vec::new(); - v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])); - v.extend(s.loupe_job(id, e, e, true)); - v + let thumb = THUMB_SIZES[THUMB_SIZES.len() - 1]; + let (view, grid) = (s.loupe_job(id, e, e, true), s.thumb_job(id, thumb)); + // the larger render goes first: the one after it renders from the source it loaded (see + // `run_photo`), which must not be smaller than what it needs + if e >= thumb { view.into_iter().chain(grid).collect() } else { grid.into_iter().chain(view).collect() } } /// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]). @@ -76,17 +77,66 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard { task } -fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard) { - for photo_jobs in jobs { - if state.cancel.load(Ordering::Relaxed) { - break; +/// Render one photo's jobs in order. The source the first job decodes serves the ones after it +/// (the grid thumbnail is made from the view's source instead of decoding the file again). +pub(crate) fn run_photo(jobs: Vec) -> bool { + let mut decoded: Option = None; + let mut ok = true; + for mut j in jobs { + if let Some(src) = &decoded + && !matches!(j.source, crate::media::SourceRef::Loaded(_)) + { + j.source = crate::media::SourceRef::Loaded(Box::new(src.clone())); } - let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok()); - if !ok { - state.failed.fetch_add(1, Ordering::Relaxed); + let r = j.run(); + ok &= r.rendered.is_ok(); + if decoded.is_none() { + decoded = r.loaded; } - let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); - task.progress(done as u64, state.total as u64); + } + ok +} + +/// How many photos render side by side: as many as an export batch for standard previews (each +/// render spreads over the cores already; the others fill them while one decodes), two for 1:1 +/// previews (a full-size source each). +fn lanes(n: usize, full: bool) -> usize { + let l = crate::export::export_parallelism(n); + if full { l.min(2) } else { l } +} + +fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) { + let total = jobs.len(); + let queue = std::sync::Mutex::new(jobs.into_iter()); + let work = || { + loop { + if state.cancel.load(Ordering::Relaxed) { + break; + } + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some(photo_jobs) = next else { break }; + let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false); + if !ok { + state.failed.fetch_add(1, Ordering::Relaxed); + } + let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, total as u64); + } + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::scope(|sc| { + for k in 1..lanes.max(1) { + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("previews-{k}")).spawn_scoped(sc, work).is_err() { + break; + } + } + work(); + }); + #[cfg(target_arch = "wasm32")] + { + let _ = lanes; + work(); } state.finished.store(true, Ordering::Relaxed); } @@ -107,9 +157,10 @@ fn build(s: &mut Session, p: &Value) -> Result { let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); let task = task_for(s, &state); + let lanes = lanes(jobs.len(), edge.is_none()); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); if wait { - run_all(jobs, &state, &task); + run_all(jobs, &state, &task, lanes); return Ok(state.json()); } #[cfg(not(target_arch = "wasm32"))] @@ -118,7 +169,7 @@ fn build(s: &mut Session, p: &Value) -> Result { let st = state.clone(); std::thread::Builder::new() .name("lc-build-previews".into()) - .spawn(move || run_all(jobs, &st, &task)) + .spawn(move || run_all(jobs, &st, &task, lanes)) .map_err(|e| bad(C, format!("could not start: {e}")))?; } Ok(state.json()) @@ -126,7 +177,7 @@ fn build(s: &mut Session, p: &Value) -> Result { /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, /// how to load the original at preview size, whether the photo takes the per-file camera look — -/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those +/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those /// proxies go stale when the fit changes). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); @@ -192,6 +243,9 @@ fn smart_run( break; } let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // Read either the new shard or a pre-upgrade flat file, without making new work + // depend on having the original online. + let path = crate::smart::existing_file_path(dir, path); if discard { if std::fs::remove_file(&path).is_ok() { n.removed += 1; @@ -215,7 +269,14 @@ fn smart_run( .ok_or_else(|| "nothing to build from".to_string()) .and_then(|s| s.load_source()) .and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref())) - .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))); + .and_then(|b| { + let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?; + // Only create the leaf shard: do not recreate a missing external drive. + if !parent.is_dir() { + std::fs::create_dir(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())) + }); match r { Ok(()) => { n.built += 1; @@ -270,9 +331,9 @@ fn smart(s: &mut Session, p: &Value) -> Result { if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) { continue; } - let path = dir.join(crate::smart::file_name(&ph)); + let path = crate::smart::sharded_file_path(&dir, &ph); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source, ph.relative_wb())); + jobs.push((id, path, source, ph.fitted_look())); } let custom = s.smart_previews_dir.is_some(); if background { @@ -314,9 +375,9 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); let jobs: Vec = - photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); + photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); std::thread::Builder::new() .name("lc-smart-look".into()) .spawn(move || refresh_stale(&dir, jobs)) @@ -327,7 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option) { - let stale: Vec = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect(); + let stale: Vec = jobs + .into_iter() + .map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look)) + .filter(|(_, path, _, _)| crate::smart::is_stale(path)) + .collect(); if !stale.is_empty() { // `custom`: the folder is there already, and must not be recreated if it went away since match smart_run(dir, true, false, stale, None) { @@ -410,7 +475,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result { /// Whether photo `id` has a smart preview. pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool { match (&s.media.smart_dir, s.catalog.photo(id)) { - (Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))), + (Some(dir), Some(p)) => crate::smart::is_valid(&crate::smart::file_path(dir, p)), _ => false, } } @@ -465,3 +530,37 @@ pub fn specs() -> Vec { }), ] } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{jobs_for, run_photo}; + use crate::Session; + use crate::media::SourceRef; + + // Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not + // by reading the file again (here: a thumbnail job pointing at a file that is not there) + #[test] + fn preview_build_makes_the_thumbnail_from_the_views_source() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let mut jobs = jobs_for(&mut s, id, Some(512)); + assert_eq!(jobs.len(), 2, "view and thumbnail"); + assert!(jobs[0].view_cache.is_some(), "the view renders first"); + jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None }; + assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source"); + } + + // Given many photos, a standard preview build renders them side by side and counts every one + #[test] + fn preview_build_of_many_photos_counts_each_once() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().map(|p| p.id.0).collect(); + assert!(ids.len() > 4); + let r = s.execute("library.buildPreviews", &json!({"size": "standard", "edge": 512, "ids": ids, "wait": true})).unwrap(); + assert_eq!(r["done"], json!(ids.len()), "{r}"); + assert_eq!(r["total"], json!(ids.len())); + assert_eq!(r["failed"], json!(0)); + } +} diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 2a4ccb6b4..091bf5d00 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -3,8 +3,8 @@ //! //! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model //! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no -//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`], -//! [`Session::with_default_denoise_models`]), so tests stay hermetic. +//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`], +//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic. use std::ffi::OsString; use std::path::PathBuf; @@ -58,7 +58,25 @@ pub fn default_denoise_models_dir() -> Option { .or_else(|| config_dir().map(|d| d.join("denoise-models"))) } +/// Where the SAM 3 model is kept: `$LIGHTCRAFT_SAM3_DIR` if set, else `/models/sam3`. +pub fn default_sam3_dir() -> Option { + sam3_dir(std::env::var_os("LIGHTCRAFT_SAM3_DIR"), config_dir()) +} + +/// [`default_sam3_dir`] for a given override and config folder: a non-empty override wins. +fn sam3_dir(var: Option, config: Option) -> Option { + var.filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config.map(|d| d.join("models").join("sam3"))) +} + impl Session { + /// Look for the SAM 3 model in the shared default folder ([`default_sam3_dir`]), and for the user's + /// download mirrors in `/models/sam3-mirrors.txt`. + pub fn with_default_sam3_model(mut self) -> Self { + self.segmenter.dir = default_sam3_dir(); + self.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); + self + } + /// Keep face models in the shared default folder ([`default_face_models_dir`]). pub fn with_default_face_models(mut self) -> Self { self.face_models_dir = default_face_models_dir(); @@ -130,3 +148,19 @@ mod config_tests { assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A non-empty `LIGHTCRAFT_SAM3_DIR` wins; unset or empty falls back to `/models/sam3` (issue #619). + #[test] + fn sam3_dir_prefers_the_override() { + let config = PathBuf::from("cfg"); + let fallback = Some(config.join("models").join("sam3")); + assert_eq!(sam3_dir(Some("elsewhere".into()), Some(config.clone())), Some(PathBuf::from("elsewhere"))); + assert_eq!(sam3_dir(None, Some(config.clone())), fallback); + assert_eq!(sam3_dir(Some(std::ffi::OsString::new()), Some(config)), fallback); + assert_eq!(sam3_dir(None, None), None); + } +} diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index 663d6f826..e83ad8c67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -138,7 +138,8 @@ impl Prepared { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() @@ -305,7 +306,8 @@ mod tests { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() @@ -320,7 +322,8 @@ mod tests { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 86c26c69d..1d9f4c669 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -19,10 +19,10 @@ use serde_json::{Map, Value, json}; pub type Props = BTreeMap>; /// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw -/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its -/// as-shot look (rendered files, and raws whose readers have no illuminant, -/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white -/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510). +/// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature` +/// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without +/// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's +/// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Target { /// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale. @@ -30,8 +30,8 @@ pub enum Target { /// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our /// relative scale. Rendered, - /// A raw developed with an absolute white balance (DNG and other files with a measured - /// illuminant): Kelvin as written. + /// A raw developed with an absolute white balance (DNG and other files with matrices, and raws + /// whose camera has measured matrices): Kelvin as written. RawAbsolute, /// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]). RawRelative, @@ -39,11 +39,12 @@ pub enum Target { impl Target { /// The target for a file of this media kind and format (its extension or Lightroom's - /// `fileFormat` name); `preview_only` for a raw that can't be decoded yet. - pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target { + /// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or + /// empty when unknown); `preview_only` for a raw that can't be decoded yet. + pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool, camera: &str) -> Target { if kind != lightcraft_catalog::MediaKind::Raw || preview_only { Target::Rendered - } else if lightcraft_catalog::relative_wb_format(format) { + } else if lightcraft_catalog::relative_wb_camera(format, camera) { Target::RawRelative } else { Target::RawAbsolute @@ -51,7 +52,7 @@ impl Target { } /// The target for a photo in the catalog. pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target { - Target::for_file(p.kind, &p.format, p.preview_only.is_some()) + Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera) } } @@ -201,6 +202,10 @@ pub fn to_partial(props: &Props, target: Target) -> Value { n(o, "Shadows2012", "light.shadows"); n(o, "Whites2012", "light.whites"); n(o, "Blacks2012", "light.blacks"); + // Lightroom's Light panel "HDR" button + if let Some(on) = boolean(props, "crs:HDREditMode") { + put(o, "hdr.enabled", json!(on)); + } // older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the // earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50) let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some(); @@ -272,6 +277,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value { if let Some(tint) = tint { put(o, "wb.tint", json!(tint)); } + // Kelvin as the other app wrote it: say so, so the value stays Kelvin on a raw with that scale (a + // preset's Kelvin is read as the relative scale on the files that have only that, as before) + if wb == abs && matches!(target, Target::RawAbsolute | Target::Any) { + put(o, "wb.scale", json!("kelvin")); + } } // a custom Kelvin with nothing to shift it from: read on the relative scale it would be a // large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so @@ -462,18 +472,24 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string())); mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}"))); } + let (spots, skipped) = crate::crs_masks::spots(values, aspect); + if !spots.is_empty() { + put(&mut out, "spots", Value::Array(spots)); + } + if values.keys().any(|k| crate::crs_masks::SPOT_CONTAINERS.contains(&k.as_str())) { + read.extend(crate::crs_masks::SPOT_CONTAINERS.iter().map(|c| c.to_string())); + mask_skips.extend(skipped.into_iter().map(|k| format!("Spot: {k}"))); + } } - // fields that only switch a panel on/off or name things: not adjustments by themselves; an - // HDR edit mode that is off, and Point Color slots that are all empty (-1) + // fields that only switch a panel on/off or name things: not adjustments by themselves; and + // Point Color slots that are all empty (-1) let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v)); - let off = |v: &[String]| v.iter().all(|s| s.trim() == "0"); let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::().is_ok_and(|x| x == -1.0) || n.trim().is_empty())); let quiet = |k: &str| { k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix" - || (k == "HDREditMode" && value_is(k, &off)) || (k == "PointColors" && value_is(k, &empty_points)) }; let mut unmapped: Vec = props @@ -593,6 +609,29 @@ mod tests { assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); } + #[test] + fn hdr_edit_mode_turns_hdr_editing_on_or_off() { + let x = |v: &str| { + format!( + r#""# + ) + }; + let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); + assert!(on.hdr.enabled); + assert_eq!(on.light.exposure, 1.5); + assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); + let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); + assert!(true_form.hdr.enabled); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); + // without the field nothing about HDR changes + let plain = r#""#; + assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); + } + #[test] fn element_form_bw_and_incremental_wb() { let x = r#" @@ -643,6 +682,10 @@ mod tests { assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); + assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); + assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); // the as-shot fields are a reference, not an adjustment: nothing to report assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); // no tint next to the as-shot tint: the custom tint is taken as the shift itself @@ -674,14 +717,50 @@ mod tests { #[test] fn target_follows_the_file() { use lightcraft_catalog::MediaKind; - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); // Lightroom's `fileFormat` name for proprietary raws - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); // a raw shown from its embedded preview develops like a rendered file - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered); - assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); + // a camera with measured matrices edits in Kelvin (issue #730), under any of its names + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); + let mut p = + lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); + p.kind = MediaKind::Raw; + assert_eq!(Target::for_photo(&p), Target::RawRelative); + p.meta.camera = "SONY ILCE-7M3".into(); + assert_eq!(Target::for_photo(&p), Target::RawAbsolute); + } + + /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value + /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on + /// a raw without matrices still takes the #510 shift. + #[test] + fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::MediaKind; + let x = r#""#; + let p = props(x); + let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); + assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); + let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); + let shifted = to_partial(&p, a7m2); + assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); + assert_eq!(shifted["wb"]["tint"], 4.0); + assert!(shifted["wb"].get("scale").is_none()); + // without the reference the Kelvin camera still reads it, the other stays As Shot + let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); + assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); + assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); + // a named preset carries no numbers either way + let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); + assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); } #[test] @@ -754,10 +833,10 @@ mod tests { fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); - // an HDR edit, a used Point Color slot and an unknown adjustment still are + // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); - assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]); + assert_eq!(got, ["FutureSlider", "PointColors"]); } #[test] diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index b1c987ab0..483d1778b 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -224,6 +224,120 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) { (out, skipped) } +/// The containers spot removal is stored in: `RetouchAreas` (current, one struct per spot with a +/// mask component) and `RetouchInfo` (older, one `key = value, …` line per spot; still written +/// beside `RetouchAreas` for older readers and read on its own when that is all there is). +pub const SPOT_CONTAINERS: [&str; 2] = ["crs:RetouchAreas", "crs:RetouchInfo"]; + +/// One `RetouchAreas` item → a [`Spot`](lightcraft_develop::Spot) as JSON. `aspect` = width / +/// height of the target: horizontal sizes are stored as fractions of the width, ours of the long +/// edge. Returns the component kind it can't carry over. +fn spot(item: &XmpValue, aspect: f64) -> Result { + let mode = match text(item, "crs:SpotType") { + Some("clone") => "clone", + _ => "heal", + }; + let comps = item.field("crs:Masks").map(XmpValue::items).unwrap_or_default(); + let Some(m) = comps.first() else { return Err("spot without a mask".into()) }; + let width_to_long = aspect.min(1.0); + let (points, size) = match text(m, "crs:What").unwrap_or("") { + // a circle: centre + half-sizes in image fractions + "Mask/Ellipse" => { + let (Some(x), Some(y), Some(sx)) = (num(m, "crs:X"), num(m, "crs:Y"), num(m, "crs:SizeX")) else { + return Err("ellipse spot".into()); + }; + (vec![pt(x, y)], sx * width_to_long) + } + // the shape exiftool's schema also allows for a spot + "Mask/CircularGradient" => { + let (Some(t), Some(l), Some(b), Some(r)) = (num(m, "crs:Top"), num(m, "crs:Left"), num(m, "crs:Bottom"), num(m, "crs:Right")) else { + return Err("circular spot".into()); + }; + (vec![pt((l + r) / 2.0, (t + b) / 2.0)], (r - l).abs() / 2.0 * width_to_long) + } + // a brushed removal: the dabs are the path + "Mask/Paint" => { + let pts: Vec = m + .field("crs:Dabs") + .map(XmpValue::items) + .unwrap_or_default() + .iter() + .filter_map(XmpValue::text) + .filter_map(|d| { + let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::().ok()); + Some(pt(it.next()?, it.next()?)) + }) + .collect(); + if pts.is_empty() { + return Err("empty brushed spot".into()); + } + (pts, num(m, "crs:Radius").unwrap_or(0.02)) + } + other => return Err(if other.is_empty() { "unknown spot".into() } else { other.trim_start_matches("Mask/").to_string() }), + }; + let size = size.clamp(0.001, 0.25); + // the source is stored as its absolute position (`SourceX`, and `OffsetY` despite the name); + // ours is the offset from the target; anything but an explicit source leaves it automatic + let explicit = text(item, "crs:SourceState").is_none_or(|s| s == "sourceSetExplicitly"); + let source_offset = match (explicit, num(item, "crs:SourceX"), num(item, "crs:OffsetY"), points.first()) { + (true, Some(sx), Some(sy), Some(p)) => { + let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return Err("spot centre".into()) }; + let (dx, dy) = (sx - x, sy - y); + (dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0)).then(|| pt(dx, dy)) + } + _ => None, + }; + let feather = (num(item, "crs:Feather").unwrap_or(0.0) * 100.0).clamp(0.0, 100.0); + let opacity = (num(item, "crs:Opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0); + Ok(json!({"mode": mode, "points": points, "size": size, "feather": feather, "opacity": opacity, "source_offset": source_offset})) +} + +/// One `RetouchInfo` line (`centerX = 0.5, centerY = 0.5, radius = 0.01, sourceState = …, +/// sourceX = …, sourceY = …, spotType = heal`) → a spot as JSON. +fn legacy_spot(line: &str, aspect: f64) -> Option { + let mut kv = BTreeMap::new(); + for part in line.split(',') { + if let Some((k, v)) = part.split_once('=') { + kv.insert(k.trim().to_string(), v.trim().to_string()); + } + } + let f = |k: &str| kv.get(k).and_then(|s| s.parse::().ok()).filter(|x| x.is_finite()); + let (x, y, r) = (f("centerX")?, f("centerY")?, f("radius")?); + let mode = if kv.get("spotType").is_some_and(|s| s == "clone") { "clone" } else { "heal" }; + let explicit = kv.get("sourceState").is_none_or(|s| s == "sourceSetExplicitly"); + let source_offset = match (explicit, f("sourceX"), f("sourceY")) { + (true, Some(sx), Some(sy)) if sx != x || sy != y => Some(pt(sx - x, sy - y)), + _ => None, + }; + let opacity = (f("opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0); + Some( + json!({"mode": mode, "points": [pt(x, y)], "size": (r * aspect.min(1.0)).clamp(0.001, 0.25), "feather": 0.0, "opacity": opacity, "source_offset": source_offset}), + ) +} + +/// Spot removal in `values` (JSON for `DevelopSettings::spots`), plus the kinds of spot that +/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2). +pub fn spots(values: &Values, aspect: f64) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut skipped = Vec::new(); + if let Some(list) = values.get("crs:RetouchAreas") { + for item in list.items() { + match spot(item, aspect) { + Ok(v) => out.push(v), + Err(kind) => skipped.push(kind), + } + } + } + if out.is_empty() + && let Some(list) = values.get("crs:RetouchInfo") + { + out.extend(list.items().iter().filter_map(XmpValue::text).filter_map(|l| legacy_spot(l, aspect))); + } + skipped.sort(); + skipped.dedup(); + (out, skipped) +} + /// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix). pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue { use crate::preset_import::Lua; @@ -376,4 +490,91 @@ mod tests { assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); } + + /// Spot removal in the shape Lightroom Classic writes it (values written for this test): + /// a healed circle with an explicit source, a cloned circle with an automatic source, a + /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. + const SPOTS: &str = r#" + + + centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal + + + + + + + + + + + d 0.1 0.8d 0.12 0.82d 0.14 0.84 + + + + + + + + +"#; + + #[test] + fn retouch_areas_become_spots() { + use lightcraft_develop::SpotMode; + let d = lightcraft_meta::parse_xmp(SPOTS).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"); + assert_eq!(s.light.exposure, 0.1, "global settings still map"); + assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); + let heal = &s.spots[0]; + assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); + assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); + assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); + let src = heal.source_offset.expect("explicit source"); + assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); + let clone = &s.spots[1]; + assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); + let brushed = &s.spots[2]; + assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); + let src = brushed.source_offset.expect("explicit source"); + assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); + let circ = &s.spots[3]; + assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); + assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); + } + + #[test] + fn portrait_target_scales_spot_sizes() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); + } + + #[test] + fn legacy_retouch_info_is_read_on_its_own() { + let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); + let d = lightcraft_meta::parse_xmp(&packet).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).unwrap(); + assert_eq!(s.spots.len(), 1, "{:?}", s.spots); + assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); + assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); + assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); + assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); + assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); + } + + #[test] + fn spots_render() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = |d: &DevelopSettings| { + lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data + }; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); + } } diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 30df142ca..8793b3a5b 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -157,6 +157,97 @@ struct Network { fallbacks: AtomicUsize, /// Tiles at once of the photo started last (0: none yet), to say where the work runs. last_threads: AtomicUsize, + /// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see + /// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free + /// slot, on top of this computer's runner. + remote: Option, + /// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last. + local_card: AtomicBool, +} + +#[cfg(feature = "denoise")] +struct RemoteSide { + runner: lightcraft_denoise::remote::Remote, + /// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs). + greeted: OnceLock>, + /// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times. + share: AtomicBool, + slower: Mutex>, +} + +#[cfg(feature = "denoise")] +impl RemoteSide { + /// From the environment, for the model whose file has `sha256` and tiles of `tile` cells. + fn from_env(sha256: &str, tile: usize) -> Option { + let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + let address = var("LIGHTCRAFT_DENOISE_URL")?; + let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default(); + Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile))) + } + + fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide { + RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) } + } + + /// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and + /// handing it tiles a fast local card would finish sooner only makes the photo wait for it. + fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> { + let info = self.runner.hello().map_err(|e| e.to_string())?; + let input = lightcraft_denoise::runtime::check_tile(info.tile); + let mut best = f64::INFINITY; + for _ in 0..2 { + let started = web_time::Instant::now(); + match self.runner.try_run(&input) { + Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0), + Some(Err(e)) => return Err(format!("the check tile failed: {e}")), + None => return Err("no free slot for the check tile".into()), + } + } + Ok((info, best)) + } + + /// The server when it can take tiles now (greeting it the first time), else why not. + fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> { + let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() }; + match greeted { + None => Err("not contacted yet".into()), + Some(Err(why)) => Err(why.clone()), + Some(Ok(_)) if !self.runner.healthy() => { + Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default())) + } + Some(Ok(_)) => Ok(&self.runner), + } + } + + /// The server's time for a tile, once greeted. + fn tile_ms(&self) -> Option { + self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1) + } + + fn describe(&self) -> Value { + match self.greeted.get() { + None => json!({"address": self.runner.address(), "state": "pending"}), + Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}), + Some(Ok((info, ms))) => { + let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner); + let state = match () { + _ if !self.runner.healthy() => "given up", + _ if slower.is_some() => "slower", + _ if self.share.load(Ordering::Relaxed) => "used", + _ => "idle", + }; + let mut v = + json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()}); + if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) { + o.insert("localMs".into(), json!(local)); + } + if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) { + o.insert("lastError".into(), json!(e)); + } + v + } + } + } } #[cfg(feature = "denoise")] @@ -338,10 +429,19 @@ impl Network { } } -/// The card, with the CPU running any tile it gets wrong. Handed out only for photos that run on the card. +/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU +/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share. #[cfg(feature = "denoise")] impl TileRunner for Network { fn run(&self, input: &[f32]) -> Result, RunError> { + if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false)) + && let Some(Ok(out)) = remote.try_run(input) + { + return Ok(out); + } + if !self.local_card.load(Ordering::Relaxed) { + return self.cpu.run(input); + } if let Ok(g) = self.card(false) { match g.runner.run(input) { Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out), @@ -361,10 +461,37 @@ impl Model for Network { fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { let threads = threads.max(1); self.last_threads.store(threads, Ordering::Relaxed); - match self.choose(run_on, threads, true) { - Ok(_) => (self, threads.min(GPU_PARALLEL)), - Err(_) => (&self.cpu, threads), + let (card, local) = match self.choose(run_on, threads, true) { + Ok(_) => (true, threads.min(GPU_PARALLEL)), + Err(_) => (false, threads), + }; + self.local_card.store(card, Ordering::Relaxed); + if let Some(r) = &self.remote { + // this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as + // `card_is_faster` counts them); unknown when the card was never set up (then the server is used) + let local_ms = match self.gpu.get() { + Some(Ok(g)) if card => g.card_ms, + Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64), + _ => f64::INFINITY, + }; + let share = match (r.usable(true), r.tile_ms()) { + (Ok(_), Some(ms)) if ms < local_ms => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None; + true + } + (Ok(_), Some(ms)) => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms)); + false + } + _ => false, + }; + r.share.store(share, Ordering::Relaxed); + if let (true, Ok(remote)) = (share, r.usable(false)) { + // the server's slots come on top of this computer's own tiles at once + return (self, local + remote.slots()); + } } + if card { (self, local) } else { (&self.cpu, local) } } fn self_test(&self, run_on: RunOn) -> Result { @@ -403,6 +530,9 @@ impl Model for Network { o.insert("cpuMs".into(), json!(g.cpu_ms)); o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed))); } + if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) { + o.insert("remote".into(), r.describe()); + } v } } @@ -412,15 +542,14 @@ pub(crate) fn default_loader() -> Loader { #[cfg(feature = "denoise")] { Arc::new(|path, manifest| { - let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; - Ok(Arc::new(Network { - cpu, - path: path.to_path_buf(), - tile: manifest.tile as usize, - gpu: OnceLock::new(), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - }) as Arc) + // the server must run this very file: identified by the manifest's hash, else the file's + let sha256 = match &manifest.sha256 { + Some(h) => Some(h.clone()), + None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(), + None => None, + }; + let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize)); + Ok(Arc::new(network(path, manifest, remote)?) as Arc) }) } #[cfg(not(feature = "denoise"))] @@ -429,6 +558,69 @@ pub(crate) fn default_loader() -> Loader { } } +/// The model at `path` on this computer, sharing tiles with `remote` when there is one. +#[cfg(feature = "denoise")] +fn network(path: &Path, manifest: &DenoiserManifest, remote: Option) -> Result { + let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; + Ok(Network { + remote, + local_card: AtomicBool::new(true), + cpu, + path: path.to_path_buf(), + tile: manifest.tile as usize, + gpu: OnceLock::new(), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + }) +} + +/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`). +#[cfg(feature = "denoise")] +pub struct Serving { + pub model: lightcraft_denoise::remote::ModelId, + pub tile: usize, + /// Where its tiles run here: the graphics card's name, or why the CPU does. + pub device: String, + pub run: lightcraft_denoise::remote::RunTile, +} + +/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's), +/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't. +#[cfg(feature = "denoise")] +pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result { + let dir = match dir { + Some(d) => d.to_path_buf(), + None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?, + }; + let want = match id { + Some(i) => i.to_string(), + None => read_settings(&dir).model.ok_or_else(|| { + format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display()) + })?, + }; + let installed = installed_models(&dir) + .into_iter() + .find(|m| m.manifest.id == want) + .ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?; + let m = &installed.manifest; + // the same identity a client derives (see `default_loader`) + let sha256 = match &m.sha256 { + Some(h) => h.clone(), + None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?, + }; + let net = Arc::new(network(&installed.onnx, m, None)?); + let tile = m.tile as usize; + // set the card up now (and say where the tiles run) rather than with the first client's tile + let _ = net.runner(run_on, GPU_PARALLEL); + let d = net.device(run_on); + let device = match d["kind"].as_str() { + Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")), + _ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")), + }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input)); + Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run }) +} + /// A model that is loaded on first use and kept, shared by the background job and the exports. pub(crate) type ModelSlot = Arc>>>; @@ -1008,16 +1200,18 @@ impl Session { } /// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is - /// opened). Pictures that appeared or vanished drop the decoded sources they affect. + /// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its + /// job's to report (see [`Self::denoise_check_photo`]). pub(crate) fn denoise_reindex(&mut self) { self.denoise.dirty = false; self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now())); + let running = self.denoise.running.as_ref().map(|r| r.photo); let mut now: HashMap = HashMap::new(); if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) { let names: HashSet = products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect(); if !names.is_empty() { - for p in self.catalog.photos().filter(|p| eligible(p)) { + for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) { let key = product_key(&active.fingerprint, p); if names.contains(&key) { now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None }); @@ -1046,7 +1240,14 @@ impl Session { } /// A photo whose file changed (its content key is not the one its index entry was made for) has no picture. + /// + /// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the + /// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session + /// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead. fn denoise_check_photo(&mut self, id: PhotoId) { + if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) { + return; + } let Some(p) = self.catalog.photo(id).cloned() else { return }; let Some((spec, _)) = self.denoise_spec(&p) else { return }; match self.media.denoise.spec(id) { @@ -1399,6 +1600,12 @@ impl Session { self.denoise.made } + /// The running job's product path and key (tests stand in for the worker's save). + #[cfg(test)] + pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> { + self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone())) + } + /// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the /// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should /// have and cannot (no model chosen, nowhere to keep the picture, the model failing). @@ -1502,6 +1709,76 @@ mod gpu_tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + /// A server on this computer running `cpu` for the model with `sha256`. + fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String { + use lightcraft_denoise::remote::{ModelId, ServerOptions, serve}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap().to_string(); + let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input)); + let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() }; + std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {}))); + addr + } + + fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network { + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: String::new(), + domain: Domain::BayerToRgb, + tile: tile as u32, + overlap: 16, + gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, + }; + let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize)); + network(path, &manifest, Some(remote)).unwrap() + } + + #[test] + fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() { + let (_, cpu, path) = model("remote", 64); + let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap(); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + // the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding + let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64); + let net = with_remote(&path, 64, &addr, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads"); + assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3); + let d = net.device(RunOn::Cpu); + assert_eq!(d["remote"]["state"], "used", "{d}"); + assert_eq!(d["remote"]["device"], "CPU (test server)"); + // a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared, + // the photo never waits for the slower server, and the status says why + let mut net = with_remote(&path, 64, &addr, &sha); + net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 })); + let (_, at_once) = net.runner(RunOn::Gpu, 8); + assert_eq!(at_once, GPU_PARALLEL, "no server slots"); + let d = net.device(RunOn::Gpu); + assert_eq!(d["remote"]["state"], "slower", "{d}"); + assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}"); + // a server running another model is refused: everything runs here + let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64); + let net = with_remote(&path, 64, &other, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2); + assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit"); + let d = net.device(RunOn::Cpu); + assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}"); + // nobody listening: the same + let net = with_remote(&path, 64, "127.0.0.1:1", &sha); + assert_eq!(net.runner(RunOn::Cpu, 3).1, 3); + assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn with_the_processor_chosen_everything_runs_on_the_cpu() { let (model, cpu, path) = model("cpu", 64); @@ -1548,6 +1825,8 @@ mod gpu_tests { gpu: OnceLock::from(Ok(card)), fallbacks: AtomicUsize::new(0), last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), }; let (input, _) = lightcraft_denoise::runtime::test_tile(64); let want = cpu.run(&input).unwrap(); @@ -1576,6 +1855,8 @@ mod gpu_tests { gpu: OnceLock::from(Ok(slow)), fallbacks: AtomicUsize::new(0), last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), }; assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it"); let device = model.device(RunOn::Auto); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 4cd4dd50d..716b3e75c 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -18,6 +18,8 @@ pub enum ExportFormat { Jpeg, Png, Tiff, + /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. + Psd, Webp, Avif, /// The original file, unchanged, with an XMP sidecar holding the edits. @@ -32,6 +34,7 @@ impl ExportFormat { "jpeg" | "jpg" => Self::Jpeg, "png" => Self::Png, "tiff" | "tif" => Self::Tiff, + "psd" => Self::Psd, "webp" => Self::Webp, "avif" => Self::Avif, "original" => Self::Original, @@ -44,6 +47,7 @@ impl ExportFormat { Self::Jpeg => "jpg", Self::Png => "png", Self::Tiff => "tif", + Self::Psd => "psd", Self::Webp => "webp", Self::Avif => "avif", // the original keeps its own extension (see `ExportOptions::file_name_for`) @@ -209,8 +213,15 @@ pub struct Watermark { /// 0..1. pub opacity: f32, pub anchor: Anchor, - /// Margin as a fraction of the short edge. - pub inset: f32, + /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor + /// is centred across: top, center, bottom). + pub inset_x: f32, + /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor + /// is centred down: left, center, right). + pub inset_y: f32, + /// Degrees clockwise: 0, 90, 180 or 270 (anything else draws unrotated). The watermark is + /// sized before it is turned, and the insets are measured to the turned watermark. + pub rotation: u16, /// sRGB colour. pub color: [u8; 3], /// Soft dark drop shadow for legibility on bright areas. @@ -232,7 +243,9 @@ impl Default for Watermark { size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, - inset: 0.025, + inset_x: 0.025, + inset_y: 0.025, + rotation: 0, color: [255; 3], shadow: true, image: String::new(), @@ -242,13 +255,48 @@ impl Default for Watermark { } } +impl Watermark { + /// `rotation` in clockwise quarter turns. + fn quarter_turns(&self) -> u8 { + match self.rotation { + 90 => 1, + 180 => 2, + 270 => 3, + _ => 0, + } + } +} + +/// Where the point `(x, y)` of a `w × h` box lands when the box is turned `turns` quarter turns +/// clockwise (for pixel coordinates: the turned box is `h × w` for odd turns). +fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { + match turns { + 1 => (h - 1.0 - y, x), + 2 => (w - 1.0 - x, h - 1.0 - y), + 3 => (y, w - 1.0 - x), + _ => (x, y), + } +} + #[cfg(test)] use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + blend_watermark(img, wm, 1.0); +} + +/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. +/// The smallest text size and shadow offset scale with the preview, so it looks like the file. +pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { + let short = img.width.min(img.height); + let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; + blend_watermark(img, wm, scale); +} + +fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { let width = img.width; - watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { let p = &mut img.data[y * width + x]; for c in 0..3 { p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; @@ -260,7 +308,7 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { /// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { let (width, height) = (img.width, img.height); - watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); if px >= width || py >= height { return; @@ -278,14 +326,14 @@ pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { let (width, trc) = (img.width, img.space.trc()); match &mut img.samples { - DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let p = v[i + c] as f32; v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; } }), - DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let target = trc.decode(col[c] as f32 / 255.0); @@ -297,12 +345,14 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { /// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for /// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter -/// so tests can render without it). +/// so tests can render without it). `floor_scale` is this image's size over the exported file's +/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. fn watermark_coverage( width: usize, height: usize, wm: &Watermark, craft: &'static [crate::fonts::CraftFont], + floor_scale: f32, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { if !wm.image.trim().is_empty() { @@ -314,32 +364,44 @@ fn watermark_coverage( return; } let short = width.min(height) as f32; - let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); + let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; let (tw, th) = (layout.width, layout.height); - let inset = wm.inset.clamp(0.0, 0.4) * short; + // laid out unturned in a tw × th box, placed by the box it becomes + let turns = wm.quarter_turns(); + let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); let (w, h) = (width as f32, height as f32); use Anchor::*; let ox = match wm.anchor { - TopLeft | Left | BottomLeft => inset, - Top | Center | Bottom => (w - tw) / 2.0, - TopRight | Right | BottomRight => w - inset - tw, + TopLeft | Left | BottomLeft => ix, + Top | Center | Bottom => (w - bw) / 2.0, + TopRight | Right | BottomRight => w - ix - bw, }; let oy = match wm.anchor { - TopLeft | Top | TopRight => inset, - Left | Center | Right => (h - th) / 2.0, - BottomLeft | Bottom | BottomRight => h - inset - th, + TopLeft | Top | TopRight => iy, + Left | Center | Right => (h - bh) / 2.0, + BottomLeft | Bottom | BottomRight => h - iy - bh, }; let alpha = wm.opacity.clamp(0.0, 1.0); let passes: &[(f32, [u8; 3], f32)] = - if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; + if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - layout.draw_in(ox + off, oy + off, width as f32, height as f32, |gx, gy, cov| { + let mut put = |gx: i32, gy: i32, cov: f32| { if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { return; } blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); - }); + }; + if turns == 0 { + layout.draw_in(ox + off, oy + off, w, h, &mut put); + } else { + // turned with the box; the shadow still falls down and to the right + layout.draw_points(|x, y, cov| { + let (x, y) = turn(turns, x, y, tw, th); + put((ox + off + x) as i32, (oy + off + y) as i32, cov); + }); + } } } @@ -380,19 +442,22 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] }); let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32; - let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32); + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + let turns = wm.quarter_turns(); + let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; + let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); use Anchor::*; let ox = match wm.anchor { - TopLeft | Left | BottomLeft => inset, + TopLeft | Left | BottomLeft => ix, Top | Center | Bottom => (w - lwf) / 2.0, - TopRight | Right | BottomRight => w - inset - lwf, + TopRight | Right | BottomRight => w - ix - lwf, } .max(0.0) as usize; let oy = match wm.anchor { - TopLeft | Top | TopRight => inset, + TopLeft | Top | TopRight => iy, Left | Center | Right => (h - lhf) / 2.0, - BottomLeft | Bottom | BottomRight => h - inset - lhf, + BottomLeft | Bottom | BottomRight => h - iy - lhf, } .max(0.0) as usize; let space = wm.target.unwrap_or(OutputSpace::Srgb); @@ -401,7 +466,8 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl for x in 0..lw { let [r, g, b, a] = scaled.get(x, y); let a = a.clamp(0.0, 1.0); - let (px, py) = (ox + x, oy + y); + let (tx, ty) = turn(turns, x as f32, y as f32, lw as f32, lh as f32); + let (px, py) = (ox + tx as usize, oy + ty as usize); if a <= 1e-4 || px >= width || py >= height { continue; } @@ -468,8 +534,10 @@ pub struct ExportOptions { pub bit_depth: Option, /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` - /// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR - /// white kept. Other formats, and photos without an HDR edit, are SDR. + /// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers + /// without cICP support show it as sRGB, washed out), 32-bit float + /// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR + /// edit, are SDR. pub hdr: bool, } @@ -533,8 +601,10 @@ pub const OPTION_PARAMS: &[&str] = &[ "hdr", ]; -/// The keys of a `watermark` object ([`Watermark`], camelCase). -pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"]; +/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` +/// together (what versions before the two margins wrote); either of those, when given, wins. +pub const WATERMARK_PARAMS: &[&str] = + &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "rotation", "color", "shadow", "image", "imageWidth"]; /// Text height as a fraction of the short edge: the range the renderer draws without clamping. pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); @@ -576,6 +646,19 @@ fn whole(v: &serde_json::Value) -> Option { v.as_i64() } +/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and +/// `insetY` is missing. +fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() + && let Some(inset) = o.remove("inset") + { + o.entry("insetX").or_insert_with(|| inset.clone()); + o.entry("insetY").or_insert(inset); + } + serde_json::from_value(v) +} + impl ExportOptions { /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or /// newer version may have written and this one doesn't know dropped, also inside `watermark`. @@ -707,7 +790,7 @@ impl ExportOptions { Value::Object(o) => o, _ => { return Err(bad( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(), + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}".into(), )); } }; @@ -730,8 +813,13 @@ impl ExportOptions { } "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, - "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, + "rotation" => { + if !matches!(whole(v), Some(0 | 90 | 180 | 270)) { + return Err(bad("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)".into())); + } + } "anchor" => { serde_json::from_value::(v.clone()).map_err(|_| { bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) @@ -746,7 +834,7 @@ impl ExportOptions { _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), } } - serde_json::from_value::(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) } /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, @@ -789,7 +877,7 @@ impl ExportOptions { remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), - Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(), + Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), _ => None, } .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), @@ -861,12 +949,15 @@ impl ExportOptions { pub fn effective_depth(&self) -> OutputDepth { match (self.format, self.bit_depth) { (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, + (ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, (ExportFormat::Png, _) => OutputDepth::U8, (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Psd, Some(8)) => OutputDepth::U8, + (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, (ExportFormat::Avif, _) => OutputDepth::U8, @@ -879,20 +970,26 @@ impl ExportOptions { ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], } } /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF - /// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. + /// or PNG (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. pub fn hdr_output(&self) -> bool { - self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32))) + self.hdr + && matches!( + (self.format, self.bit_depth), + (ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png, _) | (ExportFormat::Tiff, Some(32)) + ) } - /// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR). + /// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR; HDR + /// PNG: Rec. 2020). pub fn effective_space(&self) -> OutputSpace { match self.format { - ExportFormat::Avif if self.hdr => OutputSpace::Rec2020, + ExportFormat::Avif | ExportFormat::Png if self.hdr => OutputSpace::Rec2020, ExportFormat::Avif => OutputSpace::Srgb, _ => self.color_space, } @@ -1042,6 +1139,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), + ExportFormat::Psd => encode::encode_psd(&e, &meta), ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), @@ -1149,8 +1247,10 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), }; let r = match (o.format, &img.samples) { + (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) } @@ -1160,6 +1260,12 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) r.map_err(|e| e.to_string()) } +/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). +/// (`photo` and `catalog` let library-level privacy rules add to the export option.) +pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { + o.remove_location +} + /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. /// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { @@ -1220,6 +1326,9 @@ pub struct Exported { /// Files written next to it, named like it with another extension: `(extension, bytes)` (the /// XMP sidecar of an `Original` export). pub sidecars: Vec<(&'static str, Vec)>, + /// Set when the render was tried on the GPU and fell back to the CPU: why (see + /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. + pub gpu_fallback: Option, } /// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). @@ -1268,6 +1377,8 @@ enum Work { read: Option, packet: String, dng: Option, + /// The original must not carry a location ([`removes_location`]). + remove_location: bool, label: String, size: (usize, usize), }, @@ -1327,8 +1438,9 @@ fn prepare_guarded( let work = Work::File { path: path.clone(), read: session.media.file_bytes.clone(), - packet: crate::sidecar::sidecar_packet(p, &session.catalog), + packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), }; @@ -1354,7 +1466,9 @@ impl PreparedExport { match self.work { Work::Render(w) => { let RenderWork { job, hdr_job, meta, opts } = *w; - let r = job.run().rendered?; + let result = job.run(); + let r = result.rendered?; + let gpu_fallback = result.gpu_fallback; let bytes = match hdr_job { Some(hj) => { let hr = hj.run().rendered?; @@ -1363,15 +1477,28 @@ impl PreparedExport { } None => encode_rendered(&r, &opts, meta.as_ref())?, }; - Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } - Work::File { path, read, packet, dng, label, size } => { + Work::File { path, read, packet, dng, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, }; let Some(dng) = dng else { - return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] }); + // the file is copied byte for byte: refuse rather than leak a location it embeds + if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } + return Ok(Exported { + file_name, + bytes, + width: size.0, + height: size.1, + sidecars: vec![("xmp", packet.into_bytes())], + gpu_fallback: None, + }); }; if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); @@ -1385,7 +1512,7 @@ impl PreparedExport { }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; - Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) } } } @@ -1734,7 +1861,11 @@ impl<'a> Placer<'a> { match written { Ok((path, files, sidecars)) => { self.taken.extend(files); - self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); + let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); + if let Some(why) = &e.gpu_fallback { + entry["gpuFallback"] = json!(why); + } + self.out.push(entry); } Err(err) if self.stop_on_error => return Err(err), Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), @@ -1862,6 +1993,95 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + /// Horizontal and vertical margins move a text watermark separately; settings saved with one + /// `inset` (before the two margins) read as both, and either new key overrides it. + #[test] + fn watermark_insets_are_horizontal_and_vertical() { + // gaps between the drawn text and the right and bottom edges of a 400 × 300 image + let gaps = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let right = lit.iter().map(|p| p.0).max().unwrap(); + let bottom = lit.iter().map(|p| p.1).max().unwrap(); + (399 - right as i64, 299 - bottom as i64) + }; + let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (r0, b0) = gaps(&base); + // 0.2 and 0.1 of the 300 px short edge + let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); + assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); + let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); + assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); + // the old single margin + let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); + let old = wm(json!({"text": "LC", "inset": 0.1})); + assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); + let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); + assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); + // saved and read back as the two margins + let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; + let saved = o.to_json(); + assert!(saved["watermark"].get("inset").is_none(), "{saved}"); + assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); + } + + /// A turned text watermark is placed by its turned box: taller than wide at 90° and 270°, the + /// same corner and insets, and an unknown angle draws it unturned. + #[test] + fn watermark_rotation_turns_the_text_in_its_corner() { + let lit = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let on: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let (x0, x1) = (on.iter().map(|p| p.0).min().unwrap(), on.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (on.iter().map(|p| p.1).min().unwrap(), on.iter().map(|p| p.1).max().unwrap()); + (x0, x1, y0, y1, img) + }; + let base = Watermark { text: "LightCraft".into(), size: 0.08, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (a0, a1, b0, b1, flat) = lit(&base); + assert!(a1 - a0 > 2 * (b1 - b0), "unturned text is wide"); + for rotation in [90, 270] { + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation, ..base.clone() }); + assert!(y1 - y0 > 2 * (x1 - x0), "{rotation}°: tall"); + assert!(x1 >= 390 && y1 >= 290, "{rotation}°: still in the bottom-right corner ({x1}, {y1})"); + // 0.1 × 300 px further from the right edge + let (_, moved, ..) = lit(&Watermark { rotation, inset_x: 0.1, ..base.clone() }); + assert!((x1 as i64 - moved as i64 - 30).abs() <= 1, "{rotation}°: {x1} → {moved}"); + } + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation: 180, ..base.clone() }); + assert!(((x1 - x0) as i64 - (a1 - a0) as i64).abs() <= 2 && ((y1 - y0) as i64 - (b1 - b0) as i64).abs() <= 2, "180°: same size"); + assert!(x1 >= 390 && y1 >= 290, "180°: same corner"); + let (.., odd) = lit(&Watermark { rotation: 45, ..base.clone() }); + assert_eq!(odd.data, flat.data, "an angle that isn't a quarter turn draws unturned"); + // saved and read back; older settings have none + let o = ExportOptions { watermark: Some(Watermark { rotation: 270, ..base }), ..Default::default() }; + assert_eq!(ExportOptions::from_json(&o.to_json()).watermark.map(|w| w.rotation), Some(270)); + assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); + } + + /// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark + /// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. + #[test] + fn watermark_preview_scales_the_minimum_text_size() { + let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; + let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; + let mut export = Rgba8::new(200, 150); + draw_watermark(&mut export, &wm); + let mut preview = Rgba8::new(800, 600); + draw_watermark_preview(&mut preview, &wm, 150); + let mut unscaled = Rgba8::new(800, 600); + draw_watermark(&mut unscaled, &wm); + let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); + assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); + assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); + // drawn at the export's own size, the preview is the export + let mut same = Rgba8::new(200, 150); + draw_watermark_preview(&mut same, &wm, 150); + assert_eq!(same.data, export.data); + } + fn vertical_japanese() -> Watermark { let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); @@ -1885,7 +2105,7 @@ mod tests { assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { let mut covered = 0usize; - watermark_coverage(400, 300, &wm, &[], |_, _, k, _| covered += usize::from(k > 0.0)); + watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); assert!(covered > 0, "{:?} draws something", wm.text); } } @@ -1916,13 +2136,14 @@ mod tests { vertical: true, size: 0.1, anchor: Anchor::TopLeft, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, shadow: false, opacity: 1.0, ..Default::default() }; let mut pixels = Vec::new(); - watermark_coverage(400, 300, &wm, craft, |x, y, k, _| { + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { if k > 0.5 { pixels.push((x, y)); } @@ -1941,13 +2162,14 @@ mod tests { vertical: true, size: 0.1, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, shadow: false, opacity: 1.0, ..Default::default() }; let mut pixels = vec![0.0f32; 400 * 300]; - watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k); + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); assert!(pixels.iter().any(|&k| k > 0.0)); pixels }; @@ -2003,7 +2225,7 @@ mod tests { let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); assert!(h > w * 2, "vertical Japanese must extend down the column"); - let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm }; + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; img.data.fill([0, 0, 0, 255]); draw_watermark(&mut img, &two); for (text, dx) in [("本", 0), ("日", 30)] { @@ -2029,13 +2251,44 @@ mod tests { std::fs::write(&logo_path, png).unwrap(); let mut img = Rgba8::new(400, 300); img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() }; + let wm = Watermark { + image: logo_path.to_string_lossy().into(), + image_width: 0.25, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; draw_watermark(&mut img, &wm); // 100 px wide × 50 px high in the bottom-right corner assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); assert_eq!(img.get(10, 10), [0, 0, 0, 255]); + // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); + assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); + assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); + assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); + // turned: 180° puts the opaque half on the right; 90° clockwise puts it on top of a 50 × 100 box + let turned = |rotation: u16| { + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { rotation, ..wm.clone() }); + img + }; + let half = turned(180); + assert!(half.get(390, 280)[0] > 200 && half.get(310, 280) == [0, 0, 0, 255]); + let quarter = turned(90); + assert!(quarter.get(375, 220)[0] > 200, "top half of the turned logo: {:?}", quarter.get(375, 220)); + assert_eq!(quarter.get(375, 280), [0, 0, 0, 255], "its transparent half below"); + assert_eq!(quarter.get(340, 220), [0, 0, 0, 255], "left of the 50 px wide box"); + assert!(quarter.get(352, 202)[0] > 200 && quarter.get(398, 248)[0] > 200, "the whole box is covered"); + // three quarter turns: the opaque half at the bottom + let three = turned(270); + assert!(three.get(375, 280)[0] > 200 && three.get(375, 220) == [0, 0, 0, 255]); // an image-only watermark survives the params (no text needed) let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); @@ -2374,14 +2627,12 @@ mod tests { fn watermark_params_are_validated() { use serde_json::json; let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); - assert!( - err(json!(5)) - .contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}") - ); - assert!( - err(json!({"text": "x", "bogus": 1})) - .contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)") - ); + assert!(err(json!(5)).contains( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}" + )); + assert!(err(json!({"text": "x", "bogus": 1})).contains( + "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth)" + )); assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); for size in [json!(3), json!(0.5001), json!(0), json!("big")] { let e = err(json!({"text": "x", "size": size})); @@ -2389,6 +2640,13 @@ mod tests { } assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); + for rotation in [json!(45), json!(-90), json!(360), json!("90"), json!(90.5)] { + let e = err(json!({"text": "x", "rotation": rotation})); + assert!(e.contains("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)"), "{rotation}: {e}"); + } + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "rotation": 270}})).is_ok()); assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); @@ -2440,6 +2698,29 @@ mod tests { files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() } + /// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's + /// entry (`gpuFallback`, the render's own reason) and says nothing on the others. + #[test] + fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { + if !lightcraft_gpu::available() { + eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + return; + } + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); + // one lane: both photos render on this thread, and the fault hits the first render here + lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); + let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 2, "{files:?}"); + let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); + assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); + assert!(why[0].contains("limit"), "{files:?}"); + assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); + assert!(lightcraft_gpu::available(), "a limit is no device failure"); + } + // Issue #496: photos rendered side by side give the same files, names, order and progress as // one at a time, and a cancel stops at the same photo #[test] diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 31253f66a..be1a348ce 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -44,12 +44,13 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } -/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the /// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the /// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; use lightcraft_geom::Point; + use lightcraft_raw::Opcode; let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); if aw < 2.0 || ah < 2.0 { return None; @@ -69,21 +70,54 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); + // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); + // without one the first WarpRectilinear is the lens warp, as it always was. + let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); + let mut stages = Vec::new(); + for op in ops { match op { - lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !planes.is_empty() && lens.warp.is_none() => { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); + } + Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { + // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit + let (center, radius) = centre(*center); + let p = |i: usize| { + let k = planes[i.min(planes.len() - 1)]; + let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + s[0] = k[0]; + s[2] = k[1]; + s[4] = k[2]; + s[6] = k[3]; + s[15] = k[4]; + s[16] = k[5]; + s[18] = 1e6; + s + }; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); + } + Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { let (center, radius) = centre(*center); let p = |i: usize| planes[i.min(planes.len() - 1)]; lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); } - lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { + Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { let (center, radius) = centre(*center); lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); } _ => {} } } - if lens.warp.is_none() && lens.vignette.is_none() { + // (more stages than a lens keeps: the first ones; real files carry two) + for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { + *slot = Some(st); + } + if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { return None; } Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) @@ -131,11 +165,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } - let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); - // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. - let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); - let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); - let embedded_lens = embedded_lens(&raw); + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); + let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); return Ok(ProbeInfo { embedded_lens, width: w, @@ -365,10 +397,11 @@ fn load_bytes_now( // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); - let lens = embedded_lens(&info); + let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - // Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`). - let own_matrix = lightcraft_raw::color::has_matrix(&raw.color); + // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or + // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); drop(bytes); @@ -435,17 +468,17 @@ fn load_bytes_now( eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); } let twin = twin.map(|picture| finish(picture).0); - let (temp, tint) = xy_to_temp_tint(xy); - let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix; - // White balance re-evaluates the file's own colour model (when it has one and no - // file-local look matrix sits on top of it) - let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { + // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits + // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the + // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale + // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); - let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; + let (temp, tint) = scale.as_shot(xy); return Ok(( img, twin, @@ -454,7 +487,8 @@ fn load_bytes_now( as_shot_temp: temp, as_shot_tint: tint, lens, - relative_wb: relative, + relative_wb: scale.relative, + legacy_relative_wb: scale.legacy_relative, camera_color, camera_tone, mattes, @@ -469,6 +503,47 @@ fn load_bytes_now( Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) } +/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from +/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). +/// +/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without +/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured +/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look +/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +struct WbScaleOf { + own_matrix: bool, + relative: bool, + /// Kelvin through the camera's measured matrices: edited on the relative scale before those were used. + legacy_relative: bool, + /// The as-shot white through the measured matrices, when that is where the scale comes from. + spectral_white: Option, +} + +impl WbScaleOf { + fn new( + format: lightcraft_raw::RawFormat, + color: &lightcraft_raw::ColorData, + wb_multipliers: Option<[f32; 3]>, + meta: &lightcraft_meta::Metadata, + ) -> Self { + let own_matrix = lightcraft_raw::color::has_matrix(color); + let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; + let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten(); + let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers)); + WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white } + } + /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through + /// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog + /// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65. + fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { + if self.relative { + return (6500.0, 0.0); + } + let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own)); + (t.round(), tint.round()) + } +} + /// The raw's gain table map with where its developed picture (the default crop, oriented) sits in /// the active area: rendered when the photo's "Camera local tone mapping" option is on. fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { @@ -689,6 +764,45 @@ mod tests { use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + /// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a + /// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot + /// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the + /// format and camera string agrees. + #[test] + fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let mul = Some([2.2f32, 1.0, 1.6]); + let d65 = lightcraft_color::D65; + // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe + let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some()); + let (t, tint) = a7m3.as_shot(d65); + assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); + // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); + assert!(bare.legacy_relative && bare.spectral_white.is_none()); + assert_eq!(bare.as_shot(d65), { + let (t, tint) = xy_to_temp_tint(d65); + (t.round(), tint.round()) + }); + // a camera without measured matrices: relative, whatever the multipliers + let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none()); + assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); + assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); + assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); + // a file with matrices of its own (DNG): absolute from them, no matter whose camera + let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; + let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none()); + let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); + assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); + assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 97a830639..a246c4dca 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -51,11 +51,22 @@ pub fn is_cjk_script(script: &str) -> bool { matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang") } -/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script -/// first (for `style` when asked), then every other CJK face. One CJK face list serves every -/// language, but Han characters are shared between them: a Chinese reader must get the Chinese -/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first. -/// Empty without craft-fonts. +/// The craft-fonts faces to fall back on for `script`, in preference order: the CJK faces, those of +/// that script first (for `style` when asked), then the faces of every other non-Latin script +/// (Thai, Arabic, ...), which file and folder names can contain whatever the interface language. +/// One CJK face list serves every language, but Han characters are shared between them: a Chinese +/// reader must get the Chinese forms and a Japanese reader the Japanese ones, so the active +/// language's own script comes first. The other scripts share no characters, so their order only +/// prefers `style`. Empty without craft-fonts. +pub fn ui_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { + let mut faces = cjk_fallback(fonts, script, style); + let mut others: Vec<&CraftFont> = fonts.iter().filter(|font| !covers_cjk(font) && font.scripts.iter().any(|s| *s != "Latn")).collect(); + others.sort_by_key(|font| font.style != style); + faces.extend(others); + faces +} + +/// The CJK part of [`ui_fallback`], in the same preference order. pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect(); faces.sort_by_key(|font| { @@ -92,6 +103,13 @@ mod tests { assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style); } assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); + // Every script face that ships is a UI fallback, and has the glyphs of its script (#736). + use ab_glyph::Font; + for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] { + let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue }; + let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses"); + assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family); + } } /// Each language's own faces come first, so shared Han keeps the language's forms, and Latin @@ -112,4 +130,25 @@ mod tests { assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3); assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0); } + + /// Faces for other scripts (#736: Thai folder names showed boxes) follow the CJK faces in the + /// UI fallback whatever the language; a Latin-only face is not a fallback (Inter covers Latin). + #[test] + fn other_scripts_follow_the_cjk_faces() { + static FACES: &[CraftFont] = &[ + CraftFont { family: "Noto Sans Thai", style: "Regular", scripts: &["Thai", "Latn"], bytes: &[] }, + CraftFont { family: "Latin Only Sans", style: "Regular", scripts: &["Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans Arabic", style: "Bold", scripts: &["Arab"], bytes: &[] }, + CraftFont { family: "Noto Sans Arabic", style: "Regular", scripts: &["Arab"], bytes: &[] }, + ]; + let families = |script: &str, style: &str| ui_fallback(FACES, script, style).iter().map(|f| (f.family, f.style)).collect::>(); + assert_eq!( + families("Latn", "Regular"), + [("Noto Sans CJK SC", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Arabic", "Regular"), ("Noto Sans Arabic", "Bold")] + ); + assert_eq!(families("Hans", "Bold")[0], ("Noto Sans CJK SC", "Regular")); + assert_eq!(families("Hans", "Bold")[1], ("Noto Sans Arabic", "Bold")); + assert!(ui_fallback(&[], "Thai", "Regular").is_empty()); + } } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bf685c4d0..663d54966 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -6,6 +6,8 @@ //! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel //! on native targets. //! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch). +//! A duplicate (by path or content) of a photo in Recently Deleted is restored instead, with +//! its edits, unless the import says otherwise ([`OnDeleted`]). //! 5. *Add* in place (the photo points at the original file), *copy* into the library's //! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move* //! there: placed like a copy (with its XMP sidecars), and the original removed only once its @@ -53,12 +55,19 @@ pub enum ImportMode { } /// What an import does with a file the library already has in Recently Deleted. +/// +/// Someone who imports such a file wants the photo in the library again, so the default brings +/// it back (issues #298, #706: before, the import skipped it as "already in the library" while +/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, +/// rating and albums come back with the photo and Reset Edits gives a clean start — where +/// importing afresh would silently drop them. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum OnDeleted { /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). - #[default] Skip, - /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]). + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose + /// file is no longer where the library had it is relinked to the imported file. + #[default] Restore, /// Delete the trashed record permanently and import the file as a new photo. Fresh, @@ -78,7 +87,7 @@ impl OnDeleted { /// What an import does besides adding the photos. #[derive(Clone, Debug, Default)] pub struct ImportOptions { - /// A file that is in Recently Deleted: skip (default), restore, or import afresh. + /// A file that is in Recently Deleted: restore (default), skip, or import afresh. pub on_deleted: OnDeleted, pub mode: ImportMode, /// Applied to every imported photo (one History entry). @@ -912,7 +921,7 @@ impl ImportJob { let packet = crate::sidecar::find_sidecar(&path, self.naming) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); - let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some()); + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), Err(e) => { @@ -1170,6 +1179,16 @@ fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> P PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) } +/// A trashed photo's file is not where the library had it (never on the web, where paths are +/// storage keys, not files). +fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { + !cfg!(target_arch = "wasm32") + && match cat.photo(id).map(|p| &p.source) { + Some(Source::File { path }) => !Path::new(path).exists(), + _ => false, + } +} + /// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; /// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are /// taken back. @@ -1210,9 +1229,15 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare }); let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); match trashed { - Some(id) if opts.on_deleted == OnDeleted::Restore => { + // (browsing a folder in Local only looks: it never restores) + Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { if !report.restored.contains(&id.0) { ops.push(Op::SetDeleted { id, deleted: false }); + // matched by content from another path, and the file the record + // points at is gone (moved, renamed): the photo follows the file + if reason == "content" && trashed_file_is_gone(&s.catalog, id) { + ops.push(crate::cmd::missing::relink_op(id, &path)); + } report.restored.push(id.0); } } diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs new file mode 100644 index 000000000..8a3d846fd --- /dev/null +++ b/crates/engine/src/lens_profiles.rs @@ -0,0 +1,160 @@ +//! Lens profile corrections from a lens database the user supplies ([`lightcraft_lensdb`]). +//! +//! When a raw carries no lens correction of its own (DNG opcodes, or a camera correction a raw reader converts, +//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database: the +//! directory in `LIGHTCRAFT_LENSFUN_DIR` (e.g. lensfun's `data/db`, CC-BY-SA, which is never bundled with the app). +//! The profile is converted to the same [`lightcraft_develop::EmbeddedLens`] form, so "Enable Profile Corrections", its +//! amount sliders, the GPU path and DNG export treat both alike. Without the variable nothing changes. +use std::path::PathBuf; +use std::sync::OnceLock; + +use lightcraft_develop::EmbeddedLens; +use lightcraft_lensdb::{LensDb, Shot}; + +/// The environment variable naming the lens database directory. +pub const ENV: &str = "LIGHTCRAFT_LENSFUN_DIR"; + +/// The configured database, read once per process; `None` when none is configured or it holds no lenses. +pub fn database() -> Option<&'static LensDb> { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let dir = PathBuf::from(std::env::var_os(ENV)?); + match LensDb::load_dir(&dir) { + Ok((db, errors)) => { + for (file, e) in errors { + log::warn!("lens database: skipped {file}: {e}"); + } + (!db.lenses.is_empty()).then_some(db) + } + Err(e) => { + log::warn!("{e}"); + None + } + } + }) + .as_ref() +} + +/// The database profile for a photo with metadata `m`, for its `w × h` (unoriented, default-cropped) image, +/// re-expressed after `orientation`. `None` without a database, lens name, focal length or matching profile. +pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option { + let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?; + let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default()); + let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?; + // the crop factor: the camera's, else what the 35 mm equivalent focal length implies + let crop = + camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c))); + let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; + let corr = lens.correction(&shot)?; + Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h)) +} + +/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`. +pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { + crate::files::embedded_lens(raw).or_else(|| { + let db = database()?; + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + lookup(db, &meta(), w as f64, h as f64, raw.orientation) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_geom::Orientation; + + const DB: &str = r#" + TestcamTC-1T1.5 + TestcamTestcam 18-55mm f/3.5-5.6T1.5 + + + + + "#; + + fn meta() -> lightcraft_meta::Metadata { + lightcraft_meta::Metadata { + make: Some("Testcam".into()), + model: Some("TC-1".into()), + lens_model: Some("18-55mm F3.5-5.6".into()), + focal_length: Some(18.0), + f_number: Some(3.5), + ..Default::default() + } + } + + #[test] + fn exif_lens_is_found_and_converted() { + let db = LensDb::parse(DB).unwrap(); + let lens = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap(); + assert!(lens.warp.is_some() && lens.vignette.is_some()); + let rotated = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Rotate90).unwrap(); + assert_eq!(rotated.warp.unwrap().center, lightcraft_geom::Point::new(0.5, 0.5)); + // no lens name, no focal length, an unknown lens: nothing + assert!(lookup(&db, &lightcraft_meta::Metadata { lens_model: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none()); + assert!(lookup(&db, &lightcraft_meta::Metadata { focal_length: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none()); + let other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..meta() }; + assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); + } + + /// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry + /// stage with straight lines. + #[test] + fn pipeline_straightens_a_distorted_grid() { + let db = LensDb::parse(DB).unwrap(); + let profile = &db.lenses[0]; + let (w, h) = (600usize, 400usize); + let lens = lookup(&db, &meta(), w as f64, h as f64, Orientation::Normal).unwrap(); + // record the grid: each sensor pixel sees the scene point whose distorted position it is + let n = h as f64 / 2.0; + let undistort = |x: f64, y: f64| { + let (dx, dy) = ((x - w as f64 / 2.0) / n, (y - h as f64 / 2.0) / n); + let rd = dx.hypot(dy); + let (mut lo, mut hi) = (0.0, 3.0); + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if mid * profile.distortion_ratio(18.0, mid) < rd { + lo = mid; + } else { + hi = mid; + } + } + let s = if rd > 0.0 { lo / rd } else { 1.0 }; + (w as f64 / 2.0 + dx * s * n, h as f64 / 2.0 + dy * s * n) + }; + // horizontal scene lines every 50 px (2 px wide, dark on light) + let line = |v: f64| if v.rem_euclid(50.0) < 2.0 { 0.02f32 } else { 0.8 }; + let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| { + let (_, sy) = undistort(x as f64 + 0.5, y as f64 + 0.5); + [line(sy); 3] + }); + // the dark row nearest y = 51 in each column, sub-pixel (centroid of darkness), as rendered + let line_rows = |img: &lightcraft_raster::Rgba8| -> Vec { + (20..w - 20) + .step_by(10) + .map(|x| { + let (mut sw, mut swy) = (0.0, 0.0); + for y in 35..70 { + let d = 255.0 - img.get(x, y)[1] as f64; + if d > 60.0 { + sw += d; + swy += d * (y as f64 + 0.5); + } + } + swy / sw.max(1e-9) + }) + .collect() + }; + let spread = |rows: &[f64]| rows.iter().cloned().fold(f64::MIN, f64::max) - rows.iter().cloned().fold(f64::MAX, f64::min); + let req = lightcraft_pipeline::RenderRequest::fit(w, h); + let mut s = lightcraft_develop::DevelopSettings::default(); + let info = lightcraft_pipeline::SourceInfo { lens: Some(lens), ..Default::default() }; + let before = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image)); + s.optics.lens_profile = true; + let after = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image)); + assert!(before > 2.0, "the recorded line is curved: {before} px"); + // 8-bit output and a 2 px line: the centroid measurement itself is good to about a pixel + assert!(after < 1.0 && after < before / 8.0, "corrected line bent by {after} px (uncorrected {before})"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5cb09fd87..77c7e364c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,9 +38,11 @@ pub mod fonts; pub mod guard; pub mod import; mod import_move; +pub mod lens_profiles; pub mod library; mod lightroom_archive; pub mod lightroom_catalog; +pub mod lightroom_folder; pub mod lightroom_job; mod lightroom_sqlite; pub mod logging; @@ -52,6 +54,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod proof; pub mod rename; pub mod segment; pub mod sidecar; @@ -94,6 +97,11 @@ pub enum EngineError { /// Another process (the app, `lightcraft-cli`, another computer) has the library open. #[error("{0}")] LibraryInUse(String), + /// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a + /// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat` + /// to import with `library.importLightroom`, when one was found. + #[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))] + LightroomCatalog { path: std::path::PathBuf, catalog: Option }, #[error("{0}")] Other(String), } @@ -106,8 +114,13 @@ pub struct UndoEntry { pub label: String, pub op: Op, /// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder - /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. + /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An + /// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder). pub folder: Option, + /// Inverses of logged path corrections from a partially failed history step. + pub(crate) retry_inverse: Vec, + /// Sidecars still separated from their photo after a failed move or rollback. + pub(crate) retry_sidecars: Vec, } /// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag, @@ -180,6 +193,8 @@ pub struct Session { /// its length can't tell how many steps a command just made; see [`Session::commits`]). commits: u64, pub redo: Vec, + /// Exact filesystem repairs from discarded history; catalog inverses stay discarded. + pub(crate) pending_sidecar_repairs: Vec, pub interaction: Option, /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. @@ -190,6 +205,8 @@ pub struct Session { pub face_models_dir: Option, /// Face model downloads started this session (the staged files wait in `/.downloads`). pub face_downloads: face_download::Downloads, + /// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`). + pub place_names: cmd::geocode::PlaceNames, /// The user's own list of models to download (`catalog.json` in the models folder), as last read. pub(crate) face_catalog: lightcraft_faces::catalog::Catalog, /// The loaded recognition model and the face embeddings made with it. @@ -330,11 +347,13 @@ impl Session { undo: Vec::new(), commits: 0, redo: Vec::new(), + pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), + place_names: Default::default(), face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), @@ -442,7 +461,7 @@ impl Session { if self.depth == 0 { let skip = std::mem::take(&mut self.skip_auto_write); if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { - self.auto_write_sidecars(&self.pending_log[log_start..]); + self.auto_write_sidecars_since(log_start); } } if self.depth == 0 && self.library.is_some() { @@ -471,17 +490,34 @@ impl Session { /// Apply an op as one undoable step. pub fn commit(&mut self, label: &str, op: Op) -> Result<()> { let fwd = op.clone(); - let inv = self.catalog.apply(op)?; + let inv = self.apply_catalog(op)?; self.pending_log.push(fwd); - self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None }); + self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() }); self.commits = self.commits.wrapping_add(1); if self.undo.len() > 1000 { - self.undo.remove(0); + let mut dropped = self.undo.remove(0); + self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars); + } + for mut dropped in self.redo.drain(..) { + self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars); } - self.redo.clear(); Ok(()) } + fn apply_catalog(&mut self, op: Op) -> Result { + let inv = self.catalog.apply(op.clone())?; + let mut pending = vec![&op]; + while let Some(op) = pending.pop() { + match op { + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), + Op::AddPhoto { photo } => self.media.forget(photo.id), + Op::Batch { ops } => pending.extend(ops), + _ => {} + } + } + Ok(inv) + } + /// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo /// step renames it back (`folder` is the undo direction: current place → old place). pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> { @@ -504,8 +540,8 @@ impl Session { } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background - /// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone. - #[cfg(not(target_arch = "wasm32"))] + /// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the + /// redo stack alone. pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; @@ -549,13 +585,20 @@ impl Session { if n < 2 || n > self.undo.len() { return; } - // a step that moves a folder on disk stays on its own - if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) { + let Some(entries) = self.undo.get(self.undo.len() - n..) else { return }; + // Folder moves and partially applied file steps stay on their own. + if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) { return; } let tail = self.undo.split_off(self.undo.len() - n); let ops = tail.into_iter().rev().map(|e| e.op).collect(); - self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None }); + self.undo.push(UndoEntry { + label: label.to_string(), + op: Op::Batch { ops }, + folder: None, + retry_inverse: Vec::new(), + retry_sidecars: Vec::new(), + }); } /// Apply without recording undo (interactive previews). @@ -565,8 +608,12 @@ impl Session { } pub fn undo_step(&mut self) -> Result { - let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; - let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) { + self.undo_step_with(&rename::RealFs) + } + + pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result { + let mut e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; + let redo = match self.apply_with_files(&mut e, fs) { Ok(r) => r, Err(err) => { self.undo.push(e); @@ -575,13 +622,23 @@ impl Session { }; self.show_undone(&e.op); self.pending_log.push(e.op); - self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) }); + self.redo.push(UndoEntry { + label: e.label.clone(), + op: redo, + folder: e.folder.as_ref().map(FolderMove::reversed), + retry_inverse: Vec::new(), + retry_sidecars: Vec::new(), + }); Ok(e.label) } pub fn redo_step(&mut self) -> Result { - let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; - let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) { + self.redo_step_with(&rename::RealFs) + } + + pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result { + let mut e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; + let undo = match self.apply_with_files(&mut e, fs) { Ok(r) => r, Err(err) => { self.redo.push(e); @@ -590,7 +647,13 @@ impl Session { }; self.show_undone(&e.op); self.pending_log.push(e.op); - self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) }); + self.undo.push(UndoEntry { + label: e.label.clone(), + op: undo, + folder: e.folder.as_ref().map(FolderMove::reversed), + retry_inverse: Vec::new(), + retry_sidecars: Vec::new(), + }); Ok(e.label) } @@ -616,37 +679,63 @@ impl Session { /// imply (all or nothing). Files that can't be moved back after a failure are reported and the /// library follows them (a logged change outside the undo history), so none goes missing; the /// folder is only moved back when no file was left behind at its new path. - fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result { - let fs = rename::RealFs; + fn apply_with_files(&mut self, entry: &mut UndoEntry, fs: &dyn rename::MoveFs) -> Result { + // The entry is detached from self.undo/self.redo: include it before any global + // repair can retire, then retry revalidates it before moving its own packets. + rename::validate_sidecars(fs, &entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; + // Repair discarded file steps before another path change can stale their exact paths. + // Unrelated metadata history must remain usable while a sidecar is still unwritable. + if entry.folder.is_some() || !entry.retry_sidecars.is_empty() || !self.file_moves(&entry.op).is_empty() { + self.repair_sidecars_with(fs).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; + } + rename::retry_sidecars(fs, &mut entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; + let op = &entry.op; + let folder = entry.folder.as_ref(); if let Some(f) = folder { - cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; + cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; } let undo_folder = || { if let Some(f) = folder { - let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from); + let _ = cmd::browse::folder_on_disk(&f.to, &f.from); } }; let moves = self.file_moves(op); - if let Err(e) = rename::move_all(&fs, &moves) { + if let Err(mut e) = rename::move_all(fs, &moves) { + entry.retry_sidecars.append(&mut e.pending_sidecars); if e.stuck.is_empty() { undo_folder(); } - return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false)))); + let (message, mut inverses) = self.follow_stuck(op, e, false); + // A later successful retry observes the corrected paths. Retain their old paths + // now, so the opposite history direction still restores the entire original step. + inverses.append(&mut entry.retry_inverse); + entry.retry_inverse = inverses; + return Err(EngineError::Other(format!("can't move the files back: {message}"))); } - match self.catalog.apply(op.clone()) { + match self.apply_catalog(op.clone()) { Ok(inv) => { if let Some(f) = folder { cmd::browse::follow_folder(self, &f.from, &f.to); } - Ok(inv) + if entry.retry_inverse.is_empty() { + Ok(inv) + } else { + let mut ops = match inv { + Op::Batch { ops } => ops, + op => vec![op], + }; + ops.append(&mut entry.retry_inverse); + Ok(Op::Batch { ops }) + } } Err(e) => { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); - if let Err(be) = rename::move_all(&fs, &back) { + if let Err(mut be) = rename::move_all(fs, &back) { + entry.retry_sidecars.append(&mut be.pending_sidecars); return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message))); } undo_folder(); - Err(e.into()) + Err(e) } } } @@ -900,8 +989,12 @@ mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] +mod tests_albums_local; +#[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_crop_angle; +#[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; @@ -911,6 +1004,8 @@ mod tests_face_models; #[cfg(not(target_arch = "wasm32"))] mod tests_face_recognize; #[cfg(test)] +mod tests_filesystem_history; +#[cfg(test)] mod tests_folders; #[cfg(test)] mod tests_forget_local; @@ -925,6 +1020,8 @@ mod tests_libops; #[cfg(test)] mod tests_library; #[cfg(test)] +mod tests_lightroom_folder; +#[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_organize; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4ae3538f8..214b4b1d0 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session}; /// Library directory name inside the user's Pictures folder. pub const DEFAULT_NAME: &str = "LightCraft Library"; +/// The library folder `path` names. A library is a folder; naming one of its own files +/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other +/// existing file is an error that says so, instead of the OS's "File exists" from creating the +/// folder. +pub fn resolve_dir(path: &Path) -> Result { + if !path.is_file() { + return Ok(path.to_path_buf()); + } + let own = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n)); + if !own { + return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display()))); + } + Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) { + Some(dir) => dir.to_path_buf(), + None => PathBuf::from("."), + }) +} + +/// Whether `dir` already holds a library (its catalog files). +pub fn is_library(dir: &Path) -> bool { + dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file() +} + /// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library` /// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows). pub fn default_dir() -> Option { @@ -254,14 +280,49 @@ impl SettingsLoad { } impl Session { + /// Check, without closing the library open now, that [`Session::open_library`] can be expected + /// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not + /// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]). + /// Nothing is created; a library folder that exists gets its lock taken and released again. + /// Opening can still fail afterwards (a damaged catalog, a disk going away). + pub fn check_library(&self, dir: impl AsRef) -> Result { + let dir = dir.as_ref().to_path_buf(); + if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) { + return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog }); + } + let dir = resolve_dir(&dir)?; + if !dir.exists() { + return Ok(dir); + } + if !dir.is_dir() { + return Err(EngineError::Other(format!("{} is not a folder", dir.display()))); + } + std::fs::read_dir(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?; + // the library open in this session keeps its lock when reopened; any other must be free + let current = self.library.as_ref().is_some_and(|l| l.on_disk && same_dir(&l.dir, &dir)); + if !current && is_library(&dir) { + drop(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?); + } + Ok(dir) + } + /// Open (or create) the library at `dir` into this session, replacing its catalog. With /// `seed_demo`, a newly created library starts with the procedural demo photos. /// /// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process /// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed. /// Reopening the library this session already has open keeps its lock. + /// + /// A Lightroom Classic catalog folder, one of its `.lrdata` / `.lrcat-data` folders or an + /// `.lrcat` file is refused with [`EngineError::LightroomCatalog`] before anything is + /// created ([`crate::lightroom_folder`]); a LightCraft library always opens, whatever else + /// its folder holds. pub fn open_library(&mut self, dir: impl AsRef, seed_demo: bool) -> Result<&LoadReport> { let dir = dir.as_ref().to_path_buf(); + if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) { + return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog }); + } + let dir = resolve_dir(&dir)?; let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display()))); let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() }; // the library open in this session (same directory): hand its lock over @@ -320,6 +381,9 @@ impl Session { catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; self.catalog = catalog; self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + self.pending_sidecar_repairs.append(&mut entry.retry_sidecars); + } self.undo.clear(); self.redo.clear(); self.interaction = None; @@ -671,3 +735,63 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option { .to_string() }) } + +#[cfg(test)] +mod resolve_tests { + use super::*; + + /// `--library /catalog.snap` failed with the OS's "File exists" (creating a folder over + /// the file); it opens the library that file belongs to. + #[test] + fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() { + let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(is_library(&dir)); + let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT); + assert_eq!(resolve_dir(&snap).unwrap(), dir); + let mut s = Session::new().with_fs(); + s.open_library(&snap, false).unwrap(); + assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone())); + drop(s); + + let other = dir.join("notes.txt"); + std::fs::write(&other, b"x").unwrap(); + let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string(); + assert!(e.contains("is a file"), "{e}"); + assert!(!e.contains("File exists"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `check_library` refuses what `open_library` would, without touching the open library. + #[test] + fn check_library_refuses_locked_libraries_files_and_lightroom_catalogs() { + let dir = std::env::temp_dir().join(format!("lc-check-lib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::create_dir_all(&b).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&b, false).unwrap(); + s.close_library().unwrap(); + s.open_library(&a, false).unwrap(); + assert_eq!(s.check_library(&a).unwrap(), a, "the open library keeps its lock"); + assert_eq!(s.check_library(&b).unwrap(), b); + assert_eq!(s.check_library(dir.join("new")).unwrap(), dir.join("new")); + assert!(!dir.join("new").exists(), "nothing created"); + let held = LibraryLock::acquire(&b, "other").unwrap(); + assert!(matches!(s.check_library(&b), Err(EngineError::LibraryInUse(_)))); + drop(held); + assert!(s.check_library(&b).is_ok(), "the probe released its lock"); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + assert!(s.check_library(dir.join("notes.txt")).is_err()); + std::fs::write(dir.join("Cat.lrcat"), b"x").unwrap(); + assert!(matches!(s.check_library(dir.join("Cat.lrcat")), Err(EngineError::LightroomCatalog { .. }))); + assert!(s.library.as_ref().is_some_and(|l| l.dir == a)); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 6c1c7724d..2519057a3 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -2,7 +2,7 @@ //! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. //! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -336,6 +336,29 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result { Ok(committed) } +/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from +/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the +/// catalog's volume, resolved against the catalog's folder, if that is a folder. +fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option { + if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() { + return None; + } + let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative); + let mut out = PathBuf::new(); + for part in dir.components() { + match part { + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::CurDir => {} + part => out.push(part), + } + } + out.is_dir().then_some(out) +} + /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. pub fn read(path: &Path) -> Result { let cancel = AtomicBool::new(false); @@ -428,6 +451,21 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } out }; + let mut root_paths = HashMap::new(); + let mut root_ids: Vec<_> = roots.keys().copied().collect(); + root_ids.sort_unstable(); + for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) { + let absolute = text(root, "absolutePath"); + let resolved = match relocated_root(absolute, text(root, "relativePathFromCatalog"), path) { + Some(found) => { + let found = found.to_string_lossy().to_string(); + warnings.push(format!("root folder {}: {absolute} not found; using {found} (relative to the catalog)", text(root, "name"))); + found + } + None => absolute.to_string(), + }; + root_paths.insert(id, resolved); + } let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?); let mut out = CatalogImport { @@ -444,7 +482,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; - let root_path = text(root, "absolutePath"); + let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { @@ -1259,19 +1297,9 @@ mod tests { } page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); } - #[test] - fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { - use SqlValue::{Integer as I, Null as N, Text as T}; - let tables = [ - ("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]), - ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), - ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), - ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", - vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], - ), - ]; + /// A one-row-per-table SQLite file: `(name, columns, row)` each. + fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + use SqlValue::{Integer as I, Text as T}; let mut bytes = vec![0u8; 5 * 2048]; bytes[..16].copy_from_slice(b"SQLite format 3\0"); bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); @@ -1295,6 +1323,30 @@ mod tests { leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); } leaf(&mut bytes[..2048], 100, &schema); + bytes + } + + fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { + use SqlValue::{Integer as I, Null as N, Text as T}; + vec![ + ( + "AgLibraryRootFolder", + "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", + vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], + ), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ] + } + + #[test] + fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + let bytes = native_catalog(&photo_tables(("/catalog/", ""))); let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); std::fs::write(&path, &bytes).unwrap(); let catalog = read(&path).unwrap(); @@ -1307,6 +1359,25 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn a_root_missing_here_is_found_relative_to_the_catalog() { + let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); + std::fs::create_dir_all(dir.join("RAW")).unwrap(); + let path = dir.join("Lightroom").join("Lightroom.lrcat"); + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); + assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); + // a relative path that leads nowhere keeps the recorded one + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn oversized_optional_table_warns_and_is_skipped() { let mut main = vec![0u8; 2 * 512]; diff --git a/crates/engine/src/lightroom_folder.rs b/crates/engine/src/lightroom_folder.rs new file mode 100644 index 000000000..0b5a85659 --- /dev/null +++ b/crates/engine/src/lightroom_folder.rs @@ -0,0 +1,138 @@ +//! Recognise a Lightroom Classic catalog where a LightCraft library was asked for (issue #891). +//! +//! A Lightroom catalog folder holds `Name.lrcat` (SQLite) next to `Name Previews.lrdata`, +//! `Name Smart Previews.lrdata`, `Name Helper.lrdata`, `Name.lrcat-data`, the `-wal` / `-shm` / +//! `.lock` files of an open catalog and `Backups/`. Opening any of these as a library would create +//! an empty LightCraft library inside Lightroom's folder; [`Session::open_library`] refuses with +//! [`EngineError::LightroomCatalog`] instead, and the app offers File ▸ Import Lightroom Catalog…. +//! +//! What counts (only names are looked at; no catalog is opened or read): +//! - a LightCraft library (`catalog.snap` or `catalog.log` in the folder) is never a Lightroom +//! catalog, whatever else the folder holds: it opens as before; +//! - a path named `*.lrcat` or one of its companions (`*.lrcat-wal`, `*.lrcat-shm`, +//! `*.lrcat-journal`, `*.lrcat.lock`); +//! - a folder named `*.lrdata` or `*.lrcat-data` (Lightroom's previews and data); +//! - a folder holding a `*.lrcat` at its top level (the catalog folder itself), or `Backups/` +//! next to one. +//! +//! The folder scan is bounded ([`SCAN_LIMIT`] entries, top level only) and every I/O error just +//! means "not found": this never fails and never panics. +//! +//! [`Session::open_library`]: crate::Session::open_library +//! [`EngineError::LightroomCatalog`]: crate::EngineError::LightroomCatalog + +use std::path::{Path, PathBuf}; + +/// The most directory entries looked at in one folder. +pub const SCAN_LIMIT: usize = 10_000; + +/// A Lightroom Classic catalog (or part of one) found where a library was asked for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LightroomPlace { + /// The `.lrcat` file to import, when one was found. + pub catalog: Option, +} + +/// Companion files of an open catalog, after the `.lrcat` (lower case). +const COMPANIONS: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"]; +/// Lightroom's own `.lrdata` folders: `Name.lrdata` belongs to `Name.lrcat`. +const LRDATA_SUFFIXES: [&str; 4] = [" Smart Previews", " Previews", " Helper", " Sync"]; + +/// `path`'s file name, lower-cased (lossy: only used to compare with ASCII suffixes). +fn lower_name(path: &Path) -> Option { + path.file_name().map(|n| n.to_string_lossy().to_lowercase()) +} + +/// `name` ends with `suffix`, ignoring ASCII case; returns the part before it (`None` for a +/// non-UTF-8 name, or when the cut would not be on a character boundary). +fn strip_suffix_nocase<'a>(name: &'a str, suffix: &str) -> Option<&'a str> { + let cut = name.len().checked_sub(suffix.len())?; + let tail = name.get(cut..)?; + tail.eq_ignore_ascii_case(suffix).then(|| name.get(..cut)).flatten() +} + +/// `name` is `*.lrcat` (any case). +fn is_lrcat_name(name: &str) -> bool { + name.to_lowercase().ends_with(".lrcat") +} + +/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than +/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped. +pub fn find_lrcat(dir: &Path) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + let mut found: Option = None; + for entry in entries.take(SCAN_LIMIT).flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !is_lrcat_name(name) { + continue; + } + let path = entry.path(); + // (follows a symlink; a folder named `*.lrcat` is not a catalog) + if !path.is_file() { + continue; + } + if found.as_ref().is_none_or(|f| path < *f) { + found = Some(path); + } + } + found +} + +/// `candidate` if it is a file, else the first catalog in `dir`. +fn catalog_or_scan(candidate: Option, dir: Option<&Path>) -> Option { + candidate.filter(|c| c.is_file()).or_else(|| dir.and_then(find_lrcat)) +} + +/// Whether `path`, given as a library folder, is a Lightroom Classic catalog or part of one (see +/// the module docs). `None`: not Lightroom's, open it as a library. +pub fn lightroom_catalog_at(path: &Path) -> Option { + let is_dir = path.is_dir(); + if is_dir && crate::library::is_library(path) { + return None; + } + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + let name = path.file_name().and_then(|n| n.to_str()); + let lower = lower_name(path).unwrap_or_default(); + // the catalog file itself (a folder named `*.lrcat` is looked into below) + if lower.ends_with(".lrcat") && !is_dir { + return Some(LightroomPlace { catalog: path.is_file().then(|| path.to_path_buf()) }); + } + // an open catalog's companion files: `Name.lrcat-wal` → `Name.lrcat` + for c in COMPANIONS { + if lower.ends_with(&format!(".lrcat{c}")) && !is_dir { + let candidate = name.and_then(|n| strip_suffix_nocase(n, c)).and_then(|n| parent.map(|p| p.join(n))); + return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) }); + } + } + // `Name.lrcat-data` → `Name.lrcat` + if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, "-data")) + && is_lrcat_name(stem) + { + let candidate = parent.map(|p| p.join(stem)); + return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) }); + } + // `Name Previews.lrdata` (and Smart Previews, Helper, Sync) → `Name.lrcat` + if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, ".lrdata")) { + let base = LRDATA_SUFFIXES.iter().find_map(|s| strip_suffix_nocase(stem, s)).unwrap_or(stem); + let candidate = parent.map(|p| p.join(format!("{base}.lrcat"))); + return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) }); + } + if lower.ends_with(".lrdata") || lower.ends_with(".lrcat-data") { + // (a non-UTF-8 name) + return Some(LightroomPlace { catalog: parent.and_then(find_lrcat) }); + } + if is_dir { + // the catalog folder + if let Some(catalog) = find_lrcat(path) { + return Some(LightroomPlace { catalog: Some(catalog) }); + } + // `Backups/` next to a catalog + if lower == "backups" + && let Some(catalog) = parent.and_then(find_lrcat) + { + return Some(LightroomPlace { catalog: Some(catalog) }); + } + } + None +} diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index fd939341d..33ed64a2d 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -141,7 +141,8 @@ pub struct LightroomJob { impl LightroomJob { /// Snapshot session state only. This constructor performs no filesystem work. pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result { - let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + // a photo deleted here stays deleted: the catalog's record is matched, not restored + let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let import = ImportJob::new(s, opts)?; let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) @@ -242,7 +243,7 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) let undo0 = s.undo.len(); prepared.normal.revalidate_add(s); - let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let options = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let now = prepared.now.clone(); let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?; let applied = lightroom_catalog::apply_prepared( diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c1556623e..f98e4eb2c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -19,7 +19,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Weak}; -use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; +use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 50; +pub const RENDER_CACHE_VERSION: u64 = 60; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -309,8 +309,11 @@ pub struct MediaCache { thumbs: Lru, /// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]). previews: Vec<(PhotoId, DecodedSource, u64)>, - /// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP). - full: Option<(PhotoId, DecodedSource, u64)>, + /// The last full-resolution originals (exports, zoomed views; ~300 MB each at 24 MP), with + /// their last use. Normally one; see [`Self::set_full_capacity`]. + full: Vec<(PhotoId, DecodedSource, u64)>, + /// How many full-resolution originals to keep (LRU, at least one). + full_capacity: usize, /// How many previews to keep (LRU). pub preview_capacity: usize, /// Bytes all decoded sources and rendered previews in memory may take together (the cache @@ -386,7 +389,8 @@ impl Default for MediaCache { settings_hashes: SettingsHashes::default(), thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), previews: Vec::new(), - full: None, + full: Vec::new(), + full_capacity: 1, preview_capacity: 0, budget, file_loader: None, @@ -431,7 +435,23 @@ impl MediaCache { self.settings_hashes = SettingsHashes::default(); self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); self.previews.clear(); - self.full = None; + self.full.clear(); + } + + /// How many full-resolution originals to keep at once (at least one). Compare and Reference + /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window + /// render of one side would evict the other side's original and decode its own again. Lowering + /// it drops the least recently used originals at once. + pub fn set_full_capacity(&mut self, n: usize) { + self.full_capacity = n.max(1); + self.trim_full(); + } + + fn trim_full(&mut self) { + while self.full.len() > self.full_capacity { + let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.full.remove(oldest); + } } /// Bytes the caches may hold together. @@ -463,14 +483,14 @@ impl MediaCache { let candidates = [ self.thumbs.oldest_tick().map(|t| (t, 0)), self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), - self.full.as_ref().map(|e| (e.2, 2)), + self.full.iter().map(|e| e.2).min().map(|t| (t, 2)), self.rendered.oldest_tick().map(|t| (t, 3)), ]; let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; let freed = match which { 0 => self.thumbs.pop_oldest(), 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), - 2 => self.full.take().map(|e| e.1.bytes()), + 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), _ => self.rendered.evict_oldest(), }; match freed { @@ -491,7 +511,7 @@ impl MediaCache { e.2 = lightcraft_preview::next_tick(); e.1.clone() }), - SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| { + SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { e.2 = lightcraft_preview::next_tick(); e.1.clone() }), @@ -534,7 +554,11 @@ impl MediaCache { self.previews.remove(oldest); } } - SourceLevel::Full => self.full = Some((id, img, tick)), + SourceLevel::Full => { + self.full.retain(|e| e.0 != id); + self.full.push((id, img, tick)); + self.trim_full(); + } } self.enforce_budget(); } @@ -543,9 +567,7 @@ impl MediaCache { pub fn forget(&mut self, id: PhotoId) { self.thumbs.remove(&id); self.previews.retain(|e| e.0 != id); - if self.full.as_ref().is_some_and(|e| e.0 == id) { - self.full = None; - } + self.full.retain(|e| e.0 != id); } /// (decoded thumbnail sources, bytes). @@ -558,7 +580,7 @@ impl MediaCache { match level { SourceLevel::Thumb => self.thumbs.contains(&id), SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), - SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id), + SourceLevel::Full => self.full.iter().any(|e| e.0 == id), } } @@ -566,7 +588,7 @@ impl MediaCache { pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { use crate::memory::Usage; let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); - let full = self.full.as_ref().map(|e| Usage::new(1, e.1.bytes())).unwrap_or_default(); + let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) } @@ -613,7 +635,9 @@ impl MediaCache { // be offline is tried first and its smart preview is the render worker's fallback. #[cfg(not(target_arch = "wasm32"))] if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { - let sp = dir.join(crate::smart::file_name(p)); + let sharded = crate::smart::sharded_file_path(dir, p); + let legacy = dir.join(crate::smart::file_name(p)); + let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; // (a proxy cut short fails to load, like the offline original; the worker's fallback // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { @@ -687,6 +711,9 @@ pub struct RenderResult { pub level: SourceLevel, pub key: u64, pub rendered: Result, + /// Set when this job's render was tried on the GPU and fell back to the CPU: why + /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. + pub gpu_fallback: Option, /// A source that was loaded by this job (to be inserted into the cache). pub loaded: Option, /// Set for a [`QuickJob`]'s stand-in: where the image came from. @@ -735,6 +762,28 @@ impl RenderJob { self } + /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for + /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result + /// gets its own key and is never cached as the photo's preview or thumbnail. + pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { + if !hidden.iter().any(|h| *h) { + return self; + } + let mut s = (*self.settings).clone(); + let g = &mut s.grading; + for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { + if off { + *wheel = lightcraft_develop::Wheel::default(); + } + } + let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); + self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); + self.settings = Arc::new(s); + self.cache = None; + self.view_cache = None; + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -793,6 +842,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -814,7 +864,7 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; @@ -846,6 +896,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Ok(rendered), + gpu_fallback, loaded: (!was_loaded).then_some(source), quick: None, display, @@ -858,6 +909,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Err(e), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -870,19 +922,39 @@ impl RenderJob { /// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see /// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { + develop_reporting(src, info, s, req, stages, gpu).0 +} + +/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: +/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the +/// process-wide [`lightcraft_gpu::last_fallback`]. +pub fn develop_reporting( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { // LUT profiles have no GPU stage: they render on the CPU - if gpu + let fallback = if gpu && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) && req.proof.is_none() - && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) { - return r; - } - match stages { + match lightcraft_gpu::try_render(src, info, s, req, stages) { + Ok(r) => return (r, None), + Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), + Err(lightcraft_gpu::Fallback::NotTried) => None, + } + } else { + None + }; + let rendered = match stages { Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), None => lightcraft_pipeline::render(src, info, s, req), - } + }; + (rendered, fallback) } /// Where a [`QuickJob`]'s stand-in image came from. @@ -923,6 +995,7 @@ impl QuickJob { level: SourceLevel::Thumb, key, rendered, + gpu_fallback: None, loaded: None, quick, display: None, @@ -966,8 +1039,18 @@ pub fn source_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { - let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() } + let relative = p.relative_wb(); + let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens: p.embedded_lens, + relative_wb: relative, + // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) + legacy_relative_wb: p.fitted_look() && !relative, + ..Default::default() + } } else { SourceInfo::default() } @@ -1283,15 +1366,38 @@ impl crate::Session { }) } - /// Accept a finished job's loaded source into the cache. + /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog + /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). pub fn accept(&mut self, r: &RenderResult) { if let Some(src) = &r.loaded && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) { + if let Some(info) = &src.info { + self.learn_as_shot_wb(r.photo, info); + } self.media.insert_source(r.photo, r.level, src.clone()); } } + /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: + /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's + /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not + /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot + /// says and what a smart preview renders with. Only for raws edited in Kelvin. + fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { + let Some(p) = self.catalog.photo(id) else { return }; + if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) { + return; + } + let read = (info.as_shot_temp, info.as_shot_tint); + if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { + return; + } + if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { + log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); + } + } + /// Synchronous render (CLI, MCP, tests). pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; @@ -1351,6 +1457,9 @@ impl crate::Session { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); let r = self.media.source_ref(&p, level).load_source()?; let image = r.image.clone(); + if let Some(info) = &r.info { + self.learn_as_shot_wb(id, info); + } self.media.insert_source(id, level, r); Ok(image) } @@ -1385,7 +1494,6 @@ pub type FileProbe = Arc Result + Send + Sync #[cfg(test)] mod thumbnail_hash_tests { use super::*; - use lightcraft_catalog::Op; fn session() -> crate::Session { let mut s = crate::Session::new(); @@ -1495,6 +1603,23 @@ mod tests { assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); } + #[test] + fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); + let job = s.loupe_job(id, 64, 64, true).unwrap(); + let same = job.clone().with_hidden_grading([false; 3]); + assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); + assert!(same.view_cache.is_some()); + let hidden = job.clone().with_hidden_grading([true, false, false]); + assert_eq!(hidden.settings.grading.shadows.sat, 0.0); + assert_eq!(hidden.settings.grading.highlights.sat, 40.0); + assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); + assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); + assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); + } + #[test] fn decoder_info_survives_render_jobs_cache_and_eviction() { let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { @@ -1533,6 +1658,81 @@ mod tests { assert!(s.media.get_source(id, r.level).is_none()); } + /// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as + /// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices + /// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), + /// and the panel then reads As Shot in Kelvin while the stored settings keep their look. + #[test] + fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { + use lightcraft_develop::{WbMode, WbScale}; + let mut s = crate::Session::new(); + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = "SONY ILCE-7M3".into(); + p.as_shot_wb = Some((6500.0, 0.0)); + let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); + (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + assert_eq!(d.wb.scale, WbScale::Legacy); + p.develop = Arc::new(d.clone()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection.ids = vec![id]; + s.selection.active = Some(id); + // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known + let header = s.source_info(id); + assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); + assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); + // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) + let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; + let mut pixels = Rgb32f::new(64, 64); + for (i, px) in pixels.data.iter_mut().enumerate() { + let v = (i % 64) as f32 / 64.0; + *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; + } + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); + job.source = SourceRef::File { + path: "/photos/DSC00001.ARW".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), + fallback: None, + denoise: None, + }; + let undo_before = s.undo.len(); + let request = job.request; + let r = job.run(); + let new = r.rendered.as_ref().unwrap().image.data.clone(); + // the old pipeline: the same settings against the relative-scale source facts of an older build + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; + assert_eq!(new, old, "the look of an existing edit changed"); + assert_ne!( + new, + lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data + ); + // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched + s.accept(&r); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); + assert_eq!(*p.develop, d); + assert_eq!(s.undo.len(), undo_before); + assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); + assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); + // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as + let info = s.source_info(id); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); + let shown = lightcraft_pipeline::local::effective_wb(&info, &d); + assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); + assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); + // accepting again changes nothing more + let log = s.pending_log.len(); + s.accept(&r); + assert_eq!(s.pending_log.len(), log); + // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too + let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; + assert_eq!(proxy, old, "a smart preview of an existing edit changed"); + } + #[test] fn budget_evicts_least_recently_used_across_caches() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); @@ -1558,6 +1758,36 @@ mod tests { assert_eq!(m.rendered.mem_usage().0, 0); } + // Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, + // the least recently used goes, and the budget still evicts them like any source + #[test] + fn full_size_originals_are_kept_up_to_the_capacity() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(100 * mb); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); + m.set_full_capacity(2); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + assert_eq!(m.usage().2.count, 2); + // re-inserting a held photo replaces it instead of taking a second place + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert_eq!(m.usage().2.count, 2); + // 1 is the least recently used now + m.set_full_capacity(1); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + m.set_full_capacity(0); + assert_eq!(m.usage().2.count, 1, "never less than one"); + m.set_full_capacity(2); + m.insert(PhotoId(3), SourceLevel::Full, img(1000)); + m.set_budget(15 * mb); + assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); + assert!(m.has_source(PhotoId(3), SourceLevel::Full)); + } + #[test] fn work_gate_waits_for_room() { use crate::memory::WorkGate; diff --git a/crates/engine/src/proof.rs b/crates/engine/src/proof.rs new file mode 100644 index 000000000..8c974ada3 --- /dev/null +++ b/crates/engine/src/proof.rs @@ -0,0 +1,62 @@ +//! Soft proofing against an output-device ICC profile (a printer and paper): loads the profile, +//! builds its proof table through the CMS and registers it with the pipeline, which a +//! [`Proof`](lightcraft_pipeline::Proof) then names by id. + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +pub use lightcraft_codecs::proof::ProofIntent; + +/// A device profile ready to proof against. +#[derive(Clone, Debug, PartialEq)] +pub struct ProofProfile { + /// Pass as [`Proof::profile`](lightcraft_pipeline::Proof::profile). + pub id: u64, + /// The profile's description, else the file name. + pub name: String, +} + +/// Load the profile at `path` for proofing with `intent`, simulating paper and ink or not. +pub fn load(path: &str, intent: ProofIntent, paper_ink: bool) -> Result { + let path = path.trim(); + let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + if meta.len() > MAX_PROFILE_BYTES as u64 { + return Err(format!("{path}: too large for an ICC profile")); + } + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let fallback = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + from_bytes(&bytes, intent, paper_ink, &fallback).map_err(|e| format!("{path}: {e}")) +} + +/// [`load`] from the profile's bytes; `fallback_name` names a profile without a description. +pub fn from_bytes(bytes: &[u8], intent: ProofIntent, paper_ink: bool, fallback_name: &str) -> Result { + let table = lightcraft_codecs::proof::proof_table(bytes, intent, paper_ink)?; + let lut = lightcraft_pipeline::ProofLut::new(table.n, table.data).ok_or("the profile's tables give no usable colours")?; + let id = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (bytes, intent, paper_ink).hash(&mut h); + h.finish() + }; + let id = lightcraft_pipeline::register_proof_profile(id, lut); + Ok(ProofProfile { id, name: table.description.unwrap_or_else(|| fallback_name.to_string()) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::space::NamedSpace; + + #[test] + fn profiles_register_by_content_and_options() { + let p3 = lightcraft_codecs::icc::write_named(NamedSpace::DisplayP3); + let a = from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap(); + let b = from_bytes(&p3, ProofIntent::Perceptual, true, "x").unwrap(); + assert_ne!(a.id, b.id); + assert_eq!(a, from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap()); + assert!(lightcraft_pipeline::proof_profile(a.id).is_some()); + assert!(!a.name.is_empty()); + assert!(from_bytes(b"nope", ProofIntent::Relative, false, "x").is_err()); + assert!(load("/nonexistent/printer.icc", ProofIntent::Relative, false).is_err()); + assert_eq!(ProofIntent::parse("Relative"), Some(ProofIntent::Relative)); + assert_eq!(ProofIntent::parse("saturation"), None); + } +} diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 31a2696e0..2828eb486 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -336,6 +336,27 @@ pub(crate) trait MoveFs { /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. fn stem_sibling(&self, sidecar: &Path) -> Option; + /// Content observed in a packet, independent of its pathname or inode. Model volumes + /// override this; disk implementations stream a bounded buffer without retaining the XML. + fn packet_version(&self, path: &Path) -> std::io::Result { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hash = lightcraft_preview::Hasher128::new(); + let mut len = 0u64; + let mut buffer = [0u8; 16 * 1024]; + loop { + let n = match file.read(&mut buffer) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Ok(PacketVersion { len, hash: hash.finish() }); + } + let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; + len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; + hash.update(bytes); + } + } } /// The real file system. @@ -387,7 +408,8 @@ impl MoveFs for RealFs { let _ = std::fs::remove_file(b); }), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), } } @@ -409,6 +431,56 @@ impl MoveFs for RealFs { } } +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + if !same_contents(a, b).unwrap_or(false) { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + +/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so +/// a large raw or video is never read into memory whole. +fn same_contents(a: &Path, b: &Path) -> std::io::Result { + const CHUNK: usize = 1 << 20; + let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + if fa.metadata()?.len() != fb.metadata()?.len() { + return Ok(false); + } + let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); + loop { + let n = read_full(&mut fa, &mut ba)?; + let m = read_full(&mut fb, &mut bb)?; + if n != m || ba.get(..n) != bb.get(..m) { + return Ok(false); + } + if n == 0 { + return Ok(true); + } + } +} + +/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer +/// only at the end of the file). +fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { + match r.read(rest) { + Ok(0) => break, + Ok(k) => filled = filled.saturating_add(k), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + /// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive /// volume): the rename must go through a temporary name. fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { @@ -437,6 +509,178 @@ fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { }) } +/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may +/// already have followed its new path, so a retry cannot infer this work from the catalog. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SidecarMove { + pub from: PathBuf, + pub to: PathBuf, + copied: bool, + /// Observed packets in the partial move, including the separated source. Absent planned + /// destinations are never owned; a completed repair advances these observations. + companions: Vec, +} + +/// A content version, not an assertion that a pathname still names the same packet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PacketVersion { + len: u64, + hash: lightcraft_preview::Hash128, +} + +impl PacketVersion { + fn of(bytes: &[u8]) -> Self { + Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct RetainedPacket { + path: PathBuf, + // An unreadable observation remains untrusted even if a later read succeeds. + version: Option, +} + +impl RetainedPacket { + fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + if self.version.is_none() { + return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); + } + let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; + if self.version != Some(current) { + return Err(format!( + "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", + self.path.display() + )); + } + Ok(()) + } +} + +/// Advance only packets whose operation actually succeeded. A copy retains its original; +/// a move retires vanished source aliases and adds the observed destination. +fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { + let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; + if !copied { + packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); + } + packet.path = to.to_path_buf(); + if !packets.iter().any(|p| p.path == packet.path) { + packets.push(packet); + } +} + +fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { + packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() +} + +impl SidecarMove { + fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + for packet in &self.companions { + packet.validate(fs)?; + } + Ok(()) + } + + /// Validate a queued retry and return the copy decision for this physical operation. + fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { + self.validate_packets(fs)?; + let respelling = is_respelling(fs, &self.from, &self.to); + // Batch rollback may have restored another photo using this source packet. + let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); + if fs.exists(&self.to) && (copied || !respelling) { + return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); + } + Ok(copied) + } + + fn needed(&self, fs: &dyn MoveFs) -> bool { + fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) + } + + fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { + let same_path = |endpoint: &Path| { + endpoint == path + || fs.same_file(endpoint, path) + || (endpoint.file_name() == path.file_name() + && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) + }; + same_path(&self.from) || same_path(&self.to) + } + + fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { + if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } + } +} + +/// Complete known unfinished sidecars before moving any more photos. Completed operations +/// leave the queue; a failure retains that operation and the rest, without replacing a file. +pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { + validate_sidecars(fs, pending)?; + while !pending.is_empty() { + apply_repair(fs, pending, 0)?; + } + Ok(()) +} + +/// Preflight a detached history entry before global queues are allowed to retire. The +/// caller can validate without mutating it; retry revalidates before each physical operation. +pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { + for repair in pending { + repair.validate_repair(fs)?; + } + Ok(()) +} + +/// Finish one operation and transfer its observed content to the remaining repairs. Do not +/// infer ownership by reading an occupied destination or by retaining a planned pathname. +fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { + let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; + repair.copied = repair.validate_repair(fs)?; + let aliases = packet_aliases(&repair.companions, fs, &repair.from); + repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; + for remaining in pending.iter_mut() { + advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); + } + pending.remove(i); + Ok(()) +} + +/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals +/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. +pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { + for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { + repair.validate_repair(fs)?; + } + let mut i = 0; + while let Some(m) = pending.get(i) { + if paths.iter().any(|path| m.touches(fs, path)) { + // Path/alias equality only locates an observation; validation above proves its + // content. A newly occupied, previously absent peer destination is a collision. + if let Some(path) = paths.iter().find(|path| { + fs.exists(path) + && !fs.same_file(path, &m.from) + && !fs.same_file(path, &m.to) + && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) + }) { + return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); + } + apply_repair(fs, pending, i)?; + } else { + i += 1; + } + } + Ok(()) +} + +/// Every record came from an actual packet observed before this move. Successful physical +/// operations have already advanced its path; missing planned destinations are not recorded. +fn note_companions(pending: &mut [SidecarMove], packets: Vec) { + for repair in pending { + repair.companions.clone_from(&packets); + } +} + /// Why [`move_file`] failed, and what it left behind. #[derive(Clone, Debug, PartialEq)] pub struct MoveError { @@ -445,6 +689,7 @@ pub struct MoveError { /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file /// back failed too. Its sidecars that did move stay with it. pub moved: bool, + pub(crate) pending_sidecars: Vec, } impl std::fmt::Display for MoveError { @@ -464,53 +709,82 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu if Path::new(from) == Path::new(to) { return Ok(()); } - let fail = |message: String| MoveError { message, moved: false }; + let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; let (f, t) = (Path::new(from), Path::new(to)); // `to` may be this very file spelled differently (case-insensitive volume); any other file // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced if fs.exists(t) && !is_respelling(fs, f, t) { return Err(fail(format!("{to} already exists"))); } + let mut packets: Vec = sidecars(from) + .into_iter() + .map(|(p, _, _)| p) + .filter(|p| fs.is_file(p)) + .map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path }) + .collect(); move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; - // sidecars moved (or copied: `true`) - let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new(); - for (sc, naming, upper) in sidecars(from) { - if !fs.is_file(&sc) { - continue; - } + let mut done: Vec = Vec::new(); + let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| { let mut dst = crate::sidecar::sidecar_path(to, naming); if upper { dst = dst.with_extension("XMP"); } - // a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`) - // is copied, not taken away from it (issue #92) - let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); - if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) { + // A stem sidecar another photo still uses is copied, not taken away (issue #92). + let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); + SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } + }); + while let Some(m) = remaining.next() { + if !m.needed(fs) { continue; // never overwrite: the old sidecar stays where it was } - let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) }; - if let Err(e) = r { - let mut message = format!("rename {}: {e}", sc.display()); - // the file first: if it can't go back, the sidecars that moved stay with it + let aliases = packet_aliases(&packets, fs, &m.from); + if let Err(e) = m.apply(fs) { + let mut message = format!("rename {}: {e}", m.from.display()); + // The file first: if it can't go back, sidecars that moved stay with it. if let Err(e) = move_one(fs, t, f) { message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); if !done.is_empty() { - let names: Vec = done.iter().map(|(_, b, _)| b.display().to_string()).collect(); + let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); message.push_str(&format!(" with its sidecar {}", names.join(", "))); } - return Err(MoveError { message, moved: true }); + let mut pending_sidecars = vec![m]; + for m in remaining.filter(|m| m.needed(fs)) { + // The two case spellings can name one file. Keep one operation for + // those aliases, while retaining distinct sidecars on sensitive volumes. + let alias = pending_sidecars.iter().any(|p| { + folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) + && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) + && fs.same_file(&p.from, &m.from) + }); + if !alias { + pending_sidecars.push(m); + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: true, pending_sidecars }); } - for (a, b, copied) in done.iter().rev() { - if *copied { - // the original is still there: the copy just goes (a leftover copy loses nothing) - let _ = fs.remove_file(b); - } else if let Err(e) = move_one(fs, b, a) { - message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display())); + let mut pending_sidecars = Vec::new(); + for m in done.iter().rev() { + if m.copied { + // The original is still there: a leftover copy loses nothing. + if fs.remove_file(&m.to).is_ok() { + packets.retain(|p| p.path != m.to); + } + } else { + let aliases = packet_aliases(&packets, fs, &m.to); + if let Err(e) = move_one(fs, &m.to, &m.from) { + message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); + pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); + } else { + advance_packets(&mut packets, &aliases, &m.from, false, fs); + } } } - return Err(fail(message)); + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: false, pending_sidecars }); } - done.push((sc, dst, shared)); + advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); + done.push(m); } Ok(()) } @@ -522,6 +796,8 @@ pub(crate) struct BatchMoveError { pub message: String, /// Moves that stayed done, (from, to): those files are at `to` now. pub stuck: Vec<(String, String)>, + /// Sidecars still separated from the path where their photo ended up, in either direction. + pub pending_sidecars: Vec, } /// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. @@ -534,30 +810,41 @@ pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::resu continue; }; let mut message = e.message; + let mut pending_sidecars = e.pending_sidecars; let mut stuck = Vec::new(); if e.moved { stuck.push(m.clone()); } for d in done.iter().rev() { - if let Err(e) = move_file_with(fs, &d.1, &d.0) { + if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); if !e.moved { stuck.push((*d).clone()); } + pending_sidecars.append(&mut e.pending_sidecars); } } - return Err(BatchMoveError { message, stuck }); + return Err(BatchMoveError { message, stuck, pending_sidecars }); } Ok(()) } -/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`. +/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { - match op { - Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)), - Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()), - _ => {} + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut final_ops = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), + _ => {} + } } + out.extend(order.into_iter().filter_map(|id| { + let op = final_ops.get(&id).copied()?; + matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) + })); } /// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) @@ -667,8 +954,69 @@ impl Session { self.apply_rename_with(&RealFs, plans) } + /// Complete retained repairs before a file or folder change can stale their exact paths. + pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } + retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; + } + Ok(()) + } + + pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + self.validate_sidecar_packets_for_paths(paths, fs)?; + retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; + } + Ok(()) + } + + /// A healthy shared packet may be saved while another photo's repair is unavailable. + /// Check its retained content too, without forcing that unrelated physical operation. + pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + if paths.iter().any(|path| repair.touches(fs, path)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } else { + for packet in &repair.companions { + if paths.iter().any(|path| fs.same_file(path, &packet.path)) { + packet.validate(fs).map_err(EngineError::Other)?; + } + } + } + } + Ok(()) + } + + /// Only a successful application write updates a retained content version. Atomic writes + /// replace inodes; aliases that now resolve to the written path observe the new content. + pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { + let version = PacketVersion::of(bytes); + for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { + for packet in &mut repair.companions { + if RealFs.same_file(path, &packet.path) { + packet.version = Some(version); + } + } + } + } + /// [`Session::apply_rename`] on the file system `fs`. pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { + // A partly completed rename can already have the requested photo name. Repair + // its sidecars before treating that empty move list as a successful retry. + self.repair_sidecars_with(fs)?; let mut ops = Vec::new(); let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); for p in self.catalog.photos() { @@ -696,10 +1044,10 @@ impl Session { }) .collect(); if let Err(e) = move_all(fs, &moves) { - return Err(EngineError::Other(self.follow_stuck(&op, e, true))); + return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); } if n > 0 - && let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op) + && let Err(err) = self.commit(&label(n), op) { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); if let Err(e) = move_all(fs, &back) { @@ -712,11 +1060,13 @@ impl Session { /// After a failed batch of moves for `op`: point the photos whose files stayed moved at /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged - /// change), so the library matches the disk. Returns the message for the user. - pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String { + /// change), so the library matches the disk. Returns the message and, for a history retry, + /// the correction's inverse file ops (so a retry doesn't lose the original paths). + pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { let mut msg = e.message; - if e.stuck.is_empty() { - return msg; + let pending_sidecars = e.pending_sidecars; + if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { + return (msg, Vec::new()); } let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); let mut keep = Vec::new(); @@ -725,56 +1075,97 @@ impl Session { let n = e.stuck.len(); let s = if n == 1 { "" } else { "s" }; let fix = Op::Batch { ops: keep }; + let mut inverses = Vec::new(); let applied = if undoable { - self.commit(&format!("Rename {n} Photo{s} (partly)"), fix) + let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; + self.commit(&label, fix).map(|()| { + if let Some(entry) = self.undo.last_mut() { + entry.retry_sidecars.extend(pending_sidecars); + } + }) } else { - match self.catalog.apply(fix.clone()) { - Ok(_) => { + match self.apply_catalog(fix.clone()) { + Ok(inv) => { + inverses = match inv { + Op::Batch { ops } => ops, + op => vec![op], + }; self.pending_log.push(fix); Ok(()) } - Err(e) => Err(e.into()), + Err(e) => Err(e), } }; match applied { - Ok(()) => msg + Ok(()) if n > 0 => msg .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), + Ok(()) => {} Err(err) => msg.push_str(&format!( ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", list.join(", ") )), } - msg + (msg, inverses) } - /// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's - /// current one. + /// Physical moves to the final `SetFile` destination of each photo. A merged history + /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { - fn rec(s: &Session, op: &Op, v: &mut Vec<(String, String)>) { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut targets = HashMap::new(); + while let Some(op) = pending.pop() { match op { - Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)), - Op::SetFile { id, source: Source::File { path: to }, .. } => { - if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source) - && Path::new(from) != Path::new(to) - && !v.iter().any(|(f, _)| f == from) - { - v.push((from.clone(), to.clone())); - } + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { + order.push(*id); } _ => {} } } - let mut v = Vec::new(); - rec(self, op, &mut v); - v + // Virtual copies share one physical move, keeping the original batch order. + let mut seen = HashSet::new(); + order + .into_iter() + .filter_map(|id| { + let Source::File { path: to } = targets.get(&id).copied()? else { return None }; + let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; + (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) + }) + .collect() } } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use std::cell::RefCell; + /// The cross-volume move's check compares files chunk by chunk: equal files across several + /// chunks, a difference in the last chunk, different lengths, a missing file. + #[test] + fn same_contents_compares_in_chunks() { + let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::write(&a, &big).unwrap(); + std::fs::write(&b, &big).unwrap(); + assert!(same_contents(&a, &b).unwrap()); + let mut other = big.clone(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&b, &other).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&b, &big[..big.len() - 1]).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&a, b"").unwrap(); + std::fs::write(&b, b"").unwrap(); + assert!(same_contents(&a, &b).unwrap()); + assert!(same_contents(&a, &dir.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + /// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or /// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. pub(crate) struct FakeFs { @@ -808,6 +1199,10 @@ mod tests { } impl MoveFs for FakeFs { + fn packet_version(&self, path: &Path) -> std::io::Result { + let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?; + self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared")) + } fn exists(&self, p: &Path) -> bool { self.idx(p).is_some() } @@ -829,6 +1224,9 @@ mod tests { Ok(()) } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) { + return Err(std::io::Error::other("injected failure")); + } if self.exists(b) { return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); } @@ -858,6 +1256,101 @@ mod tests { Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") } + /// Compare the unfinished operation and every observed packet after partial progress. + /// Companion order is incidental; paths and recorded content versions are exact. + pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) { + let mut expected = unfinished.clone(); + expected.companions = + packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect(); + let mut actual = actual.clone(); + actual.companions.sort_by(|a, b| a.path.cmp(&b.path)); + expected.companions.sort_by(|a, b| a.path.cmp(&b.path)); + assert_eq!(actual, expected); + } + + #[test] + fn folder_moves_repair_retained_sidecars_before_changing_paths() { + for command in ["folder.rename", "folder.move"] { + for queue in ["pending", "undo", "redo"] { + for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] { + let root = crate::tests_xmp::temp_dir("folder-sidecar-repair"); + let from = root.join("Trip"); + std::fs::create_dir_all(&from).unwrap(); + let photo = from.join("b.jpg"); + std::fs::write(&photo, b"Photo bytes").unwrap(); + let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming); + let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming); + std::fs::write(&old_sidecar, b"Retained edits").unwrap(); + // An ordinary occupied destination must block repair without replacing its bytes. + std::fs::write(&sidecar, b"Other edits").unwrap(); + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap(); + if copied { + let sibling = from.join("a.raw"); + std::fs::write(&sibling, b"Shared original").unwrap(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap(); + } + s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); + if queue == "redo" { + s.undo_step().unwrap(); + } + let repair = SidecarMove { + from: old_sidecar.clone(), + to: sidecar.clone(), + copied, + companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }], + }; + match queue { + "undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair), + "redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair), + _ => s.pending_sidecar_repairs.push(repair), + } + let params = if command == "folder.rename" { + serde_json::json!({"path": from, "name": "Holiday"}) + } else { + serde_json::json!({"path": from, "into": root.join("Archive")}) + }; + let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") }; + let before = s.catalog.to_snapshot(); + let history = (s.undo.len(), s.redo.len()); + assert!(s.execute(command, ¶ms).is_err()); + assert!(from.is_dir() && !to.exists()); + assert_eq!(s.catalog.to_snapshot(), before); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits"); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits"); + std::fs::remove_file(&sidecar).unwrap(); + s.execute(command, ¶ms).unwrap(); + assert!(s.pending_sidecar_repairs.is_empty()); + assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty())); + let moved_sidecar = to.join(sidecar.file_name().unwrap()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied); + for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] { + s.execute(step, &serde_json::json!({})).unwrap(); + assert_eq!( + s.catalog.photo(PhotoId(1)).unwrap().source, + Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() } + ); + assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits"); + assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied); + } + // Explicit IDs choose targets after the command's selection gate. + s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap(); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert!(to.join("b.jpg").is_file()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + s.execute("edit.redo", &serde_json::json!({})).unwrap(); + assert!(to.join("Latest.jpg").is_file()); + let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming); + assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits"); + std::fs::remove_dir_all(root).unwrap(); + } + } + } + } + #[cfg(windows)] #[test] fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() { @@ -998,6 +1491,33 @@ mod tests { assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d."))); } + #[test] + fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() { + // These are synthetic volume semantics; no host driver or OS runtime is involved. + for ci in [false, true] { + let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]); + fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]); + let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err(); + assert!(error.moved); + assert_eq!(error.pending_sidecars.len(), 2); + assert!(error.pending_sidecars[0].copied); + assert!(!error.pending_sidecars[1].copied); + fs.fail.borrow_mut().clear(); + retry_sidecars(&fs, &mut error.pending_sidecars).unwrap(); + assert!(error.pending_sidecars.is_empty()); + assert_eq!( + fs.listing(), + vec![ + ("/p/a.JPG".into(), "Jpeg".into()), + ("/p/a.XMP".into(), "Shared edits".into()), + ("/p/b.CR3".into(), "Raw".into()), + ("/p/b.CR3.XMP".into(), "Raw edits".into()), + ("/p/b.XMP".into(), "Shared edits".into()), + ] + ); + } + } + /// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared /// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it. #[test] diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index e92d193a7..5257a0187 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -248,6 +248,12 @@ impl SidecarData { /// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom /// label names included, as other editors do). pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { + sidecar_packet_with(p, cat, false) +} + +/// [`sidecar_packet`], without any location (GPS, sublocation, city, state, country) when +/// `remove_location` is set (an export with "Remove location info"). +pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_location: bool) -> String { let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); let meta = lightcraft_meta::Metadata { software: Some("LightCraft".into()), @@ -271,6 +277,10 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }), ..Default::default() }; + let mut meta = meta; + if remove_location { + (meta.sublocation, meta.city, meta.state, meta.country, meta.gps) = (None, None, None, None, None); + } let settings = serde_json::to_string(&*p.develop).unwrap_or_default(); let flag = match p.flag { Flag::None => "none", @@ -278,7 +288,7 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { Flag::Reject => "reject", }; let mut lc = vec![("settings", settings.as_str()), ("flag", flag)]; - if !p.meta.location.trim().is_empty() { + if !remove_location && !p.meta.location.trim().is_empty() { lc.push(("location", p.meta.location.as_str())); } lightcraft_meta::write_xmp_lc(&meta, &lc) @@ -468,20 +478,40 @@ impl Session { } /// Write the photo's XMP sidecar, merged into an existing one (see the module docs). - pub fn save_sidecar(&self, id: PhotoId) -> Result { + pub fn save_sidecar(&mut self, id: PhotoId) -> Result { self.save_sidecar_with(id, &StemOwners::of(&self.catalog)) } - pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result { + pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; if p.copy_of.is_some() { return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; - let path = sidecar_path(orig, owners.naming(p, self.xmp.naming)); + let naming = owners.naming(p, self.xmp.naming); + let preferred = sidecar_path(orig, naming); + let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; + let candidates: Vec = [naming, other] + .into_iter() + .filter(|n| *n != SidecarNaming::Stem || owners.naming(p, SidecarNaming::Stem) == SidecarNaming::Stem) + .flat_map(|n| { + let path = sidecar_path(orig, n); + [path.clone(), path.with_extension("XMP")] + }) + .collect(); + // A separated packet is still the photo's metadata. Repair it before a fresh write + // occupies its destination; a failure preserves the queue and both existing packets. + self.repair_sidecars_for_paths(&candidates, &crate::rename::RealFs) + .map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?; + // Keep an existing packet's spelling/style so foreign properties survive this save and + // subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above. + let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred); + self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?; + let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display())); let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?; write_atomic(&path, data.as_bytes()).map_err(err)?; + self.note_sidecar_write(&path, data.as_bytes()); Ok(SidecarSaved { path, merged, backup }) } @@ -517,9 +547,22 @@ impl Session { } /// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned). - pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) { + pub(crate) fn auto_write_sidecars(&mut self, ops: &[Op]) { let mut ids = Vec::new(); ops.iter().for_each(|o| op_photos(o, &mut ids)); + self.auto_write_sidecar_ids(ids); + } + + /// Gather changed IDs before the saves mutate retained physical repair queues. + pub(crate) fn auto_write_sidecars_since(&mut self, log_start: usize) { + let mut ids = Vec::new(); + if let Some(ops) = self.pending_log.get(log_start..) { + ops.iter().for_each(|o| op_photos(o, &mut ids)); + } + self.auto_write_sidecar_ids(ids); + } + + fn auto_write_sidecar_ids(&mut self, ids: Vec) { if ids.is_empty() { return; } @@ -633,3 +676,15 @@ mod tests { assert_eq!(v, vec![PhotoId(1), PhotoId(3)]); } } + +#[cfg(test)] +#[path = "sidecar/tests_repair_saves.rs"] +mod tests_repair_saves; + +#[cfg(test)] +#[path = "sidecar/tests_repair_companions.rs"] +mod tests_repair_companions; + +#[cfg(test)] +#[path = "sidecar/tests_repair_provenance.rs"] +mod tests_repair_provenance; diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs new file mode 100644 index 000000000..cefec46e8 --- /dev/null +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -0,0 +1,287 @@ +//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history. +use super::*; +use crate::rename::{MoveFs, RealFs}; +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::json; + +struct Faults(Vec); +impl MoveFs for Faults { + fn exists(&self, p: &Path) -> bool { + RealFs.exists(p) + } + fn is_file(&self, p: &Path) -> bool { + RealFs.is_file(p) + } + fn same_file(&self, a: &Path, b: &Path) -> bool { + RealFs.same_file(a, b) + } + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.0.iter().any(|p| p == a) { + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move")) + } else { + RealFs.rename_no_replace(a, b) + } + } + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + RealFs.copy_no_replace(a, b) + } + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + RealFs.remove_file(p) + } + fn stem_sibling(&self, p: &Path) -> Option { + RealFs.stem_sibling(p) + } +} +fn packet(marker: &str) -> String { + format!( + r#""# + ) +} +fn queues(s: &Session) -> Vec { + s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect() +} +fn session(path: &Path) -> Session { + let mut s = Session::new(); + let photo = Photo::new( + PhotoId(1), + Source::File { path: path.to_string_lossy().into_owned() }, + path.file_name().unwrap().to_str().unwrap(), + "JPEG", + 4, + 3, + "", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.selection = crate::Selection::single(PhotoId(1)); + s +} +fn save(s: &mut Session) -> serde_json::Value { + s.execute("photo.saveMetadataToFile", &json!({})).unwrap() +} +fn assert_fresh(path: &Path) { + let xml = std::fs::read_to_string(path).unwrap(); + assert!(xml.contains("fixture:Marker="), "foreign property must survive real serialization/merge"); + let data = parse_sidecar(&xml, crate::crs::Target::Rendered).unwrap(); + assert_eq!(data.title.as_deref(), Some("Fresh")); + assert!(data.keywords.unwrap().contains(&"café".to_string())); +} + +#[test] +fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions() { + for upper_stem in [false, true] { + for upper_full in [false, true] { + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + for auto in [false, true] { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); + let path = |photo: &Path, style, upper| { + let path = sidecar_path(&photo.to_string_lossy(), style); + if upper { path.with_extension("XMP") } else { path } + }; + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + let stem_bytes = packet("stem-keep"); + let full_bytes = packet("full-keep"); + std::fs::write(&stem_a, &stem_bytes).unwrap(); + std::fs::write(&full_a, &full_bytes).unwrap(); + let mut s = session(&a); + s.xmp.naming = naming; + s.xmp.auto_write = auto; + let fs = Faults(vec![full_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + + // Missing packet and an unrelated packet at either case spelling remain protected. + let held = dir.join("held.xmp"); + std::fs::rename(&full_a, &held).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::rename(&held, &full_a).unwrap(); + for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { + std::fs::write(&collision, packet("unrelated-packet")).unwrap(); + let occupied = std::fs::read(&collision).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read(&collision).unwrap(), occupied); + assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::remove_file(collision).unwrap(); + } + // AutoWrite is the first successful repair when enabled; explicit save otherwise. + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + if !auto { + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); + } + assert!(queues(&s).is_empty()); + assert!(!full_a.exists()); + let (written, untouched, expected) = + if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; + assert_fresh(written); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); + for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { + s.execute(command, &json!({})).unwrap(); + if !auto { + save(&mut s); + } + assert_fresh(written); + assert_eq!( + parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, + Some(rating) + ); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + } + assert_eq!(s.xmp.naming, naming); + assert_eq!(s.xmp.auto_write, auto); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); + } + } + } + } +} + +#[test] +fn rollback_repair_and_shared_copy_keep_known_companions() { + for rollback in [false, true] { + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companion-rollback-{rollback}-{naming:?}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture").unwrap(); + let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem); + let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full); + let stem_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem); + std::fs::write(&stem_a, packet("stem-keep")).unwrap(); + std::fs::write(&full_a, packet("full-keep")).unwrap(); + // When the photo cannot roll back, the stem is copied for the physical sibling. + if !rollback { + std::fs::write(dir.join("a.CR3"), b"physical stem sibling").unwrap(); + } + let mut s = session(&a); + let fs = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queues(&s).len(), 1); + let actual = if rollback { &a } else { &b }; + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: actual.to_string_lossy().into_owned() }); + s.xmp.naming = naming; + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + let result = save(&mut s); + assert!(result["failed"].as_array().unwrap().is_empty(), "{result}"); + assert!(queues(&s).is_empty()); + assert_fresh(&sidecar_path(&actual.to_string_lossy(), naming)); + if !rollback { + assert_eq!(std::fs::read_to_string(&stem_a).unwrap(), packet("stem-keep")); + } + drop(s); + std::fs::remove_dir_all(dir).unwrap(); + } + } +} + +#[test] +fn a_later_save_recognizes_companions_after_an_earlier_repair_retires() { + let dir = crate::tests_xmp::temp_dir("r3-successive-companions"); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem); + let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full); + let upper_a = full_a.with_extension("XMP"); + std::fs::write(&a, b"physical fixture").unwrap(); + std::fs::write(&stem_a, packet("stem-keep")).unwrap(); + std::fs::write(&full_a, packet("lower-keep")).unwrap(); + std::fs::write(&upper_a, packet("upper-keep")).unwrap(); + if RealFs.same_file(&full_a, &upper_a) { + std::fs::remove_dir_all(dir).unwrap(); + return; // This case requires two physically distinct full packets. + } + let mut s = session(&a); + s.xmp.naming = SidecarNaming::Full; + let fs = Faults(vec![full_a.clone(), upper_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queues(&s).len(), 2); + assert!(s.repair_sidecars_with(&Faults(vec![full_a.clone()])).is_err()); + assert_eq!(queues(&s).len(), 1); + let upper_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Full).with_extension("XMP"); + assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep")); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert!(save(&mut s)["failed"].as_array().unwrap().is_empty()); + assert!(queues(&s).is_empty()); + assert_fresh(&sidecar_path(&b.to_string_lossy(), SidecarNaming::Full)); + assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep")); + assert_eq!(std::fs::read_to_string(sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem)).unwrap(), packet("stem-keep")); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() { + let dir = crate::tests_xmp::temp_dir("queued-stem-batch-rollback"); + let a = dir.join("a.jpg"); + let b = dir.join("a.png"); + let first = dir.join("Trip_1.jpg"); + let second = dir.join("Trip_2.png"); + let shared = dir.join("a.xmp"); + let first_packet = dir.join("Trip_1.xmp"); + let second_packet = dir.join("Trip_2.xmp"); + let jpeg = b"first physical photo; rename does not decode".to_vec(); + let png = b"second physical photo; rename does not decode".to_vec(); + let bytes = packet("shared-after-batch-rollback").into_bytes(); + std::fs::write(&a, &jpeg).unwrap(); + std::fs::write(&b, &png).unwrap(); + std::fs::write(&shared, &bytes).unwrap(); + let mut s = session(&a); + let photo = Photo::new(PhotoId(2), Source::File { path: b.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.xmp.auto_write = false; + + // Existing Faults refuses renames only: the first shared-packet copy must succeed. + let faults = Faults(vec![shared.clone(), second.clone()]); + let plans = s.plan_rename_with(&faults, &[PhotoId(1), PhotoId(2)], "Trip_{seq}", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: a.to_string_lossy().into_owned() }); + assert_eq!(s.catalog.photo(PhotoId(2)).unwrap().source, Source::File { path: second.to_string_lossy().into_owned() }); + assert_eq!(std::fs::read(&a).unwrap(), jpeg); + assert_eq!(std::fs::read(&second).unwrap(), png); + assert!(!b.exists() && !first.exists()); + assert_eq!(std::fs::read(&shared).unwrap(), bytes); + assert_eq!(std::fs::read(&first_packet).unwrap(), bytes); + assert!(!second_packet.exists()); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].from, shared); + assert_eq!(pending[0].to, second_packet); + let catalog_before = s.catalog.to_snapshot(); + let history_before = (s.undo.len(), s.redo.len(), s.commits()); + + // Public command, real filesystem: its no-op path drains the naturally retained repair. + let retry = s.execute("photo.rename", &json!({"ids": [2], "template": "{name}"})); + let catalog_after = s.catalog.to_snapshot(); + let history_after = (s.undo.len(), s.redo.len(), s.commits()); + let repaired = queues(&s).is_empty(); + let old_paths_absent = !b.exists() && !first.exists(); + let files = [&a, &second, &first_packet, &second_packet].map(|p| std::fs::read(p).map_err(|e| e.kind())); + let retained = std::fs::read(&shared).map_err(|e| e.kind()); + drop(s); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(!dir.exists(), "fixture cleanup must precede the qualifying assertion"); + + assert_eq!(retry.unwrap()["renamed"], 0); + assert_eq!(catalog_after, catalog_before, "repair must preserve both catalog sources"); + assert_eq!(history_after, history_before); + assert!(repaired && old_paths_absent); + assert_eq!(files, [Ok(jpeg), Ok(png), Ok(bytes.clone()), Ok(bytes.clone())]); + assert_eq!(retained, Ok(bytes), "queued repair took shared XMP from the photo restored by batch rollback"); +} diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs new file mode 100644 index 000000000..63ff42002 --- /dev/null +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -0,0 +1,464 @@ +//! Native files and genuine Session entry points: packet ownership survives partial renames. +use super::*; +use crate::rename::{MoveFs, RealFs}; +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::json; + +struct Faults(Vec); +impl MoveFs for Faults { + fn exists(&self, p: &Path) -> bool { + RealFs.exists(p) + } + fn is_file(&self, p: &Path) -> bool { + RealFs.is_file(p) + } + fn same_file(&self, a: &Path, b: &Path) -> bool { + RealFs.same_file(a, b) + } + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.0.iter().any(|p| p == a) { + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move")) + } else { + RealFs.rename_no_replace(a, b) + } + } + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + RealFs.copy_no_replace(a, b) + } + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + RealFs.remove_file(p) + } + fn stem_sibling(&self, p: &Path) -> Option { + RealFs.stem_sibling(p) + } +} +fn packet(marker: &str) -> String { + format!( + r#""# + ) +} +fn path(photo: &Path, style: SidecarNaming, upper: bool) -> PathBuf { + let p = sidecar_path(&photo.to_string_lossy(), style); + if upper { p.with_extension("XMP") } else { p } +} +fn queued(s: &Session) -> Vec { + s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect() +} +fn session(photo: &Path) -> Session { + let mut s = Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: photo.to_string_lossy().into_owned() }, "a.jpg", "JPEG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection = crate::Selection::single(PhotoId(1)); + s +} +fn save(s: &mut Session) -> serde_json::Value { + s.execute("photo.saveMetadataToFile", &json!({})).unwrap() +} +struct Partial { + s: Session, + dir: PathBuf, + a: PathBuf, + b: PathBuf, + companion: PathBuf, + source: PathBuf, + target: PathBuf, +} +fn partial(style: SidecarNaming, upper_stem: bool, upper_full: bool, shared: bool) -> Partial { + // one folder per fixture: tests run in parallel, and each removes its folder at the end + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = crate::tests_xmp::temp_dir(&format!("sidecar-provenance-{n}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"tiny physical photo; rename does not decode").unwrap(); + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + std::fs::write(&stem_a, packet("observed-stem")).unwrap(); + std::fs::write(&full_a, packet("observed-full")).unwrap(); + if shared { + std::fs::write(dir.join("a.CR3"), b"shared physical sibling").unwrap(); + } + let mut s = session(&a); + s.xmp.naming = style; + let rollback = style == SidecarNaming::Full; + let faults = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]); + let plans = s.plan_rename_with(&faults, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + let (companion, source, target) = if rollback { (full_a, stem_b, stem_a) } else { (stem_b, full_a, full_b) }; + Partial { s, dir, a, b, companion, source, target } +} +fn replace(p: &Path, held: &Path, in_place: bool) -> (Vec, Vec) { + let original = std::fs::read(p).unwrap(); + #[cfg(unix)] + let before = { + use std::os::unix::fs::MetadataExt; + let m = std::fs::metadata(p).unwrap(); + (m.dev(), m.ino()) + }; + if in_place { + std::fs::write(held, &original).unwrap(); + } else { + std::fs::rename(p, held).unwrap(); + } + let foreign = packet(&"f".repeat(original.len() - packet("").len())).into_bytes(); + assert_eq!(original.len(), foreign.len(), "content changes must be detected even at the same length"); + assert_ne!(original, foreign); + assert!(parse_sidecar(std::str::from_utf8(&foreign).unwrap(), crate::crs::Target::Rendered).is_ok()); + std::fs::write(p, &foreign).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let m = std::fs::metadata(p).unwrap(); + assert_eq!(before == (m.dev(), m.ino()), in_place, "exercise replacement and unchanged-inode edits separately"); + } + (original, foreign) +} + +#[test] +fn replaced_and_in_place_companions_block_save_autowrite_retry_and_file_history() { + for style in [SidecarNaming::Stem, SidecarNaming::Full] { + for upper_stem in [false, true] { + for upper_full in [false, true] { + for in_place in [false, true] { + let mut f = partial(style, upper_stem, upper_full, false); + let held = f.dir.join("original-held.xmp"); + let (original, foreign) = replace(&f.companion, &held, in_place); + let queue = queued(&f.s); + let source = std::fs::read(&f.source).unwrap(); + let before = f.s.catalog.photo(PhotoId(1)).unwrap().source.clone(); + f.s.xmp.auto_write = true; + f.s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(queued(&f.s), queue, "AutoWrite must retain the exact repair"); + assert_eq!(std::fs::read(&f.companion).unwrap(), foreign); + let saved = save(&mut f.s); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("changed since")); + assert_eq!(queued(&f.s), queue); + assert_eq!(std::fs::read(&f.source).unwrap(), source); + assert!(!f.target.exists()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + let plans = f.s.plan_rename(&[PhotoId(1)], if style == SidecarNaming::Stem { "Trip" } else { "a" }, 1); + assert!(f.s.apply_rename(&plans).is_err()); + // Metadata history remains available while physical ownership is unresolved. + f.s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(std::fs::read(&f.companion).unwrap(), foreign); + assert!(f.s.undo_step().is_err(), "the real rename Undo must reject the foreign companion"); + assert_eq!(queued(&f.s), queue); + assert_eq!(f.s.catalog.photo(PhotoId(1)).unwrap().source, before); + assert_eq!(std::fs::read(&f.companion).unwrap(), foreign); + assert_eq!(std::fs::read(&held).unwrap(), original); + // Restoring the observed content resolves ownership and keeps the positive path. + std::fs::write(&f.companion, &original).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty()); + assert_eq!(std::fs::read(&f.target).unwrap(), source); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); + } + } + } + } +} + +#[test] +fn failed_redo_and_discarded_or_pruned_history_keep_companion_provenance() { + for owner in ["redo", "discarded", "pruned"] { + let mut f = partial(SidecarNaming::Stem, false, false, false); + if owner == "pruned" { + for _ in 0..1000 { + f.s.commit("Unrelated retention step", Op::Batch { ops: Vec::new() }).unwrap(); + } + assert_eq!(f.s.pending_sidecar_repairs.len(), 1); + } else { + f.s.undo_step().unwrap(); + let full_a = path(&f.a, SidecarNaming::Full, false); + assert!(f.s.redo_step_with(&Faults(vec![full_a, f.b.clone()])).is_err()); + assert_eq!(f.s.redo.iter().map(|e| e.retry_sidecars.len()).sum::(), 1); + if owner == "discarded" { + f.s.commit("Discard failed redo", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); + assert_eq!(f.s.pending_sidecar_repairs.len(), 1); + assert!(f.s.redo.is_empty()); + } + } + let held = f.dir.join("original-held.xmp"); + let (original, foreign) = replace(&f.companion, &held, false); + let queue = queued(&f.s); + assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + if owner == "redo" { + assert!(f.s.redo_step().is_err()); + } + assert_eq!(queued(&f.s), queue); + assert_eq!(std::fs::read(&f.companion).unwrap(), foreign); + std::fs::write(&f.companion, &original).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty()); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); + } +} + +fn two_repairs() -> Option { + let mut f = partial(SidecarNaming::Stem, false, false, false); + // Resolve the initial fixture, then create a new genuine partial rename with two packets. + f.s.undo_step().unwrap(); + let upper_a = path(&f.a, SidecarNaming::Full, true); + std::fs::write(&upper_a, packet("observed-upper-full")).unwrap(); + let full_a = path(&f.a, SidecarNaming::Full, false); + if RealFs.same_file(&upper_a, &full_a) { + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); + return None; // This case requires two physically distinct case spellings. + } + let fs = Faults(vec![upper_a, full_a.clone(), f.b.clone()]); + let plans = f.s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(f.s.apply_rename_with(&fs, &plans).is_err()); + assert_eq!(queued(&f.s).len(), 2); + f.source = full_a; + Some(f) +} + +#[test] +fn absent_peer_destinations_are_not_owned_and_retirement_records_only_completed_packets() { + let Some(mut f) = two_repairs() else { return }; + let future = path(&f.b, SidecarNaming::Full, false); + let upper = path(&f.b, SidecarNaming::Full, true); + let foreign = packet("foreign-at-never-created-destination").into_bytes(); + std::fs::write(&future, &foreign).unwrap(); + let queue = queued(&f.s); + let original_companion = std::fs::read(&f.companion).unwrap(); + assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + assert_eq!(queued(&f.s), queue); + assert!(!upper.exists(), "a foreign peer must be rejected before the first repair retires"); + assert_eq!(std::fs::read(&future).unwrap(), foreign); + assert_eq!(std::fs::read(&f.companion).unwrap(), original_companion); + std::fs::remove_file(&future).unwrap(); + assert!(f.s.repair_sidecars_with(&Faults(vec![f.source.clone()])).is_err()); + assert_eq!(queued(&f.s).len(), 1); + let held = f.dir.join("retired-original-held.xmp"); + let (observed_upper, replaced) = replace(&upper, &held, false); + let queue = queued(&f.s); + assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + assert_eq!(queued(&f.s), queue); + assert_eq!(std::fs::read(&upper).unwrap(), replaced); + assert!(!future.exists()); + std::fs::write(&upper, &observed_upper).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty()); + assert_eq!(std::fs::read(&upper).unwrap(), observed_upper); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); +} + +#[test] +fn own_atomic_shared_packet_writes_advance_retained_versions_and_failures_do_not() { + let mut f = partial(SidecarNaming::Stem, false, false, true); + let shared = path(&f.a, SidecarNaming::Stem, false); + let shared_before = std::fs::read(&shared).unwrap(); + let copied_before = std::fs::read(&f.companion).unwrap(); + let raw = f.dir.join("a.CR3"); + let mut photo = Photo::new(PhotoId(2), Source::File { path: raw.to_string_lossy().into_owned() }, "a.CR3", "CR3", 4, 3, ""); + photo.kind = MediaKind::Raw; + photo.meta.title = "Legitimate shared packet write".into(); + f.s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + // A missing repair for the moved JPEG must not block a healthy save by its raw sibling. + let held = f.dir.join("unavailable-source.xmp"); + std::fs::rename(&f.source, &held).unwrap(); + let before = queued(&f.s); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(0); + assert!(f.s.save_sidecar(PhotoId(2)).is_err()); + } + assert_eq!(queued(&f.s), before, "failed atomic writes must not advance provenance"); + assert_eq!(std::fs::read(&shared).unwrap(), shared_before); + f.s.save_sidecar(PhotoId(2)).unwrap(); + assert_ne!(std::fs::read(&shared).unwrap(), shared_before); + assert_eq!(std::fs::read(&f.companion).unwrap(), copied_before); + assert_eq!(queued(&f.s).len(), 1); + // External edits are still refused after the application's own atomic replacement. + let shared_written = std::fs::read(&shared).unwrap(); + std::fs::write(&shared, packet("foreign-after-our-write")).unwrap(); + assert!(f.s.save_sidecar(PhotoId(2)).is_err()); + assert_eq!(std::fs::read_to_string(&shared).unwrap(), packet("foreign-after-our-write")); + std::fs::write(&shared, &shared_written).unwrap(); + f.s.save_sidecar(PhotoId(2)).unwrap(); + std::fs::rename(&held, &f.source).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty(), "own shared writes must not stale the retained repair"); + assert_eq!(std::fs::read(&shared).unwrap(), shared_written); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); +} + +#[test] +fn replaced_copy_keeps_its_shared_original_and_repair_source_byte_identical() { + let mut f = partial(SidecarNaming::Stem, true, true, true); + let shared = path(&f.a, SidecarNaming::Stem, true); + let shared_before = std::fs::read(&shared).unwrap(); + let source = std::fs::read(&f.source).unwrap(); + let held = f.dir.join("copied-original-held.xmp"); + let (original, foreign) = replace(&f.companion, &held, false); + let queue = queued(&f.s); + f.s.xmp.auto_write = true; + f.s.execute("photo.setMeta", &json!({"title": "Fresh"})).unwrap(); + assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + assert_eq!(queued(&f.s), queue); + assert_eq!(std::fs::read(&f.companion).unwrap(), foreign); + assert_eq!(std::fs::read(&shared).unwrap(), shared_before); + assert_eq!(std::fs::read(&f.source).unwrap(), source); + std::fs::write(&f.companion, &original).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty()); + assert_eq!(std::fs::read(&shared).unwrap(), shared_before); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); +} + +#[test] +fn changed_separated_sources_are_not_moved_or_retired() { + for style in [SidecarNaming::Stem, SidecarNaming::Full] { + let mut f = partial(style, false, true, false); + let held = f.dir.join("source-original-held.xmp"); + let (original, foreign) = replace(&f.source, &held, true); + let queue = queued(&f.s); + let companion = std::fs::read(&f.companion).unwrap(); + assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(f.s.repair_sidecars_with(&RealFs).is_err()); + assert_eq!(queued(&f.s), queue); + assert_eq!(std::fs::read(&f.source).unwrap(), foreign); + assert_eq!(std::fs::read(&f.companion).unwrap(), companion); + assert!(!f.target.exists()); + std::fs::write(&f.source, &original).unwrap(); + assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty()); + assert!(queued(&f.s).is_empty()); + drop(f.s); + std::fs::remove_dir_all(f.dir).unwrap(); + } +} + +#[test] +fn detached_history_entry_is_checked_before_unrelated_global_retirement() { + // A private-method contract, not a UI reproduction: application reachability of two + // simultaneous repair owners is unproven. Native move failures and follow_stuck seed + // both records; the second owner's normal apply_rename preflight is deliberately bypassed. + struct NativePartial { + id: PhotoId, + original: PathBuf, + moved: PathBuf, + stem: PathBuf, + full: PathBuf, + companion: PathBuf, + target: PathBuf, + } + let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract"); + let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); + let mut s = Session::new(); + s.xmp.naming = SidecarNaming::Stem; + let mut native = Vec::new(); + for (i, name) in ["first", "second"].into_iter().enumerate() { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("a.png"); + let moved = dir.join("Trip.png"); + std::fs::write(&original, &png).unwrap(); + let stem = path(&original, SidecarNaming::Stem, false); + let full = path(&original, SidecarNaming::Full, true); + for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { + std::fs::write(p, packet(&marker)).unwrap(); + assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); + } + let id = PhotoId(i as u64 + 1); + let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let fs = Faults(vec![full.clone(), moved.clone()]); + if i == 0 { + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + } else { + // Register the genuine second partial move without draining the first repair. + let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; + let err = crate::rename::move_all(&fs, &moves).unwrap_err(); + assert_eq!(err.stuck, moves); + assert_eq!(err.pending_sidecars.len(), 1); + let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; + s.follow_stuck(&op, err, true); + } + assert_eq!(s.undo.len(), i + 1); + assert_eq!(queued(&s).len(), i + 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); + let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); + assert_eq!((decoded.width, decoded.height), (4, 3)); + native.push(NativePartial { + id, + original, + companion: path(&moved, SidecarNaming::Stem, false), + target: path(&moved, SidecarNaming::Full, true), + moved, + stem, + full, + }); + } + let first = &native[0]; + let second = &native[1]; + let first_stem = std::fs::read(&first.companion).unwrap(); + let full_packets: Vec<_> = native.iter().map(|f| std::fs::read(&f.full).unwrap()).collect(); + let held = root.join("observed-second-companion.xmp"); + let (observed, foreign) = replace(&second.companion, &held, false); + let mut entry = s.undo.pop().unwrap(); // Undo/Redo call this method with their entry detached. + let retained = queued(&s); + let detached = entry.retry_sidecars.clone(); + assert_eq!((retained.len(), detached.len()), (1, 1)); + let sources: Vec<_> = native.iter().map(|f| s.catalog.photo(f.id).unwrap().source.clone()).collect(); + let log_len = s.pending_log.len(); + let mut packets: Vec<_> = native.iter().flat_map(|f| [&f.moved, &f.full, &f.companion]).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect(); + packets.push((held.clone(), std::fs::read(&held).unwrap())); + assert!(!first.target.exists() && !second.target.exists()); + + let err = s.apply_with_files(&mut entry, &RealFs).unwrap_err(); + assert!(err.to_string().contains("changed since"), "{err}"); + assert_eq!(std::fs::read(&second.companion).unwrap(), foreign); + println!( + "detached history preflight contract: retained_before={}, retained_after={}, detached_before={}, detached_after={}, first_target_exists={}", + retained.len(), + queued(&s).len(), + detached.len(), + entry.retry_sidecars.len(), + first.target.exists() + ); + assert_eq!(queued(&s), retained, "reject the detached owner before any unrelated global repair retires"); + assert_eq!(entry.retry_sidecars, detached); + assert_eq!(s.pending_log.len(), log_len); + for (f, source) in native.iter().zip(&sources) { + assert_eq!(&s.catalog.photo(f.id).unwrap().source, source); + assert!(!f.original.exists() && !f.target.exists()); + } + for (p, bytes) in &packets { + assert_eq!(std::fs::read(p).unwrap(), *bytes, "rejection must preserve {} byte for byte", p.display()); + } + + // Resolving the changed content permits the same real method to repair and undo the move. + std::fs::write(&second.companion, &observed).unwrap(); + let inverse = s.apply_with_files(&mut entry, &RealFs).unwrap(); + assert!(queued(&s).is_empty() && entry.retry_sidecars.is_empty()); + assert_eq!(s.catalog.photo(first.id).unwrap().source, sources[0]); + assert_eq!(s.catalog.photo(second.id).unwrap().source, Source::File { path: second.original.to_string_lossy().into_owned() }); + assert_eq!(s.file_moves(&inverse), vec![(second.original.to_string_lossy().into_owned(), second.moved.to_string_lossy().into_owned())]); + assert_eq!(std::fs::read(&first.moved).unwrap(), png); + assert_eq!(std::fs::read(&second.original).unwrap(), png); + assert_eq!(std::fs::read(&first.companion).unwrap(), first_stem); + assert_eq!(std::fs::read(&first.target).unwrap(), full_packets[0]); + assert_eq!(std::fs::read(&second.stem).unwrap(), observed); + assert_eq!(std::fs::read(&second.full).unwrap(), full_packets[1]); + assert_eq!(std::fs::read(&held).unwrap(), observed); + assert!(!first.original.exists() && !first.full.exists()); + assert!(!second.moved.exists() && !second.companion.exists() && !second.target.exists()); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs new file mode 100644 index 000000000..6f9fa4aa4 --- /dev/null +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -0,0 +1,210 @@ +//! Real Session/serializer regressions for saves that overlap retained physical repairs. +use super::*; +use crate::rename::{MoveFs, RealFs}; +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::json; + +struct PartialRename { + sidecar: PathBuf, + photo: PathBuf, +} +impl MoveFs for PartialRename { + fn exists(&self, path: &Path) -> bool { + RealFs.exists(path) + } + fn is_file(&self, path: &Path) -> bool { + RealFs.is_file(path) + } + fn same_file(&self, a: &Path, b: &Path) -> bool { + RealFs.same_file(a, b) + } + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if a == self.sidecar || a == self.photo { + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault")) + } else { + RealFs.rename_no_replace(a, b) + } + } + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if a == self.sidecar { + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault")) + } else { + RealFs.copy_no_replace(a, b) + } + } + fn remove_file(&self, path: &Path) -> std::io::Result<()> { + RealFs.remove_file(path) + } + fn stem_sibling(&self, path: &Path) -> Option { + RealFs.stem_sibling(path) + } +} + +const FOREIGN: &[u8] = br#""#; + +fn png(path: &Path) { + let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} +fn queued(s: &Session) -> Vec { + s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|entry| entry.retry_sidecars.iter())).cloned().collect() +} + +#[test] +fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_switch() { + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] { + for upper in [false, true] { + for switch in [false, true] { + let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); + let a = root.join("a.png"); + let b = root.join("Trip.png"); + png(&a); + let mut old = sidecar_path(&a.to_string_lossy(), naming); + if upper { + old = old.with_extension("XMP"); + } + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(root.join("library-a"), false).unwrap(); + s.execute("library.import", &json!({"paths": [a]})).unwrap(); + let mut id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + s.undo.clear(); + let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queued(&s); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + if switch { + s.close_library().unwrap(); + s.open_library(root.join("library-b"), false).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + s.execute("library.import", &json!({"paths": [b]})).unwrap(); + id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + } + s.xmp.naming = save_naming; + s.xmp.auto_write = true; + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let preferred = sidecar_path(&b.to_string_lossy(), save_naming); + assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); + let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + assert_eq!(queued(&s), pending); + + // A missing sidecar for another photo does not prevent this healthy save. + let healthy = root.join("healthy.png"); + png(&healthy); + let healthy_id = PhotoId(999); + s.commit( + "Healthy Photo", + Op::AddPhoto { + photo: Box::new(Photo::new( + healthy_id, + Source::File { path: healthy.to_string_lossy().into_owned() }, + "healthy.png", + "PNG", + 4, + 3, + "", + )), + }, + ) + .unwrap(); + s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); + let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); + assert_eq!(queued(&s), pending); + + // An existing destination is never overwritten and the original packet remains. + std::fs::rename(&held, &old).unwrap(); + let destination = pending[0].to.clone(); + let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); + let collision_path = if upper { preferred.clone() } else { destination.clone() }; + std::fs::write(&collision_path, &collision).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + assert_eq!(queued(&s), pending); + s.selection = crate::Selection::single(id); + assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision + + // The next successful AutoWrite runs the real repair and production XMP merge. + s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); + assert!(queued(&s).is_empty()); + assert!(!old.exists()); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.title.as_deref(), Some("Final title")); + assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); + if upper { + assert!(!preferred.exists(), "merge uses the repaired uppercase packet"); + } + for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { + s.execute(step, &json!({})).unwrap(); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); + } + drop(s); + std::fs::remove_dir_all(root).unwrap(); + } + } + } + } +} + +#[test] +fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() { + let root = crate::tests_xmp::temp_dir("repair-save-shared-stem"); + let raw = root.join("old.CR3"); + let moved = root.join("Trip.CR3"); + let jpeg = root.join("Trip.jpg"); + std::fs::write(&raw, b"physical fixture; catalogued without decoding").unwrap(); + png(&jpeg); + let old = sidecar_path(&raw.to_string_lossy(), SidecarNaming::Stem); + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new(); + let raw_id = PhotoId(1); + let jpeg_id = PhotoId(2); + let mut photo = Photo::new(raw_id, Source::File { path: raw.to_string_lossy().into_owned() }, "old.CR3", "CR3", 4, 3, ""); + photo.kind = MediaKind::Raw; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.catalog + .apply(Op::AddPhoto { + photo: Box::new(Photo::new(jpeg_id, Source::File { path: jpeg.to_string_lossy().into_owned() }, "Trip.jpg", "JPEG", 4, 3, "")), + }) + .unwrap(); + let fs = PartialRename { sidecar: old.clone(), photo: moved }; + let plans = s.plan_rename_with(&fs, &[raw_id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + let pending = queued(&s); + assert_eq!(pending.len(), 1); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + assert_eq!(s.sidecar_naming(jpeg_id), SidecarNaming::Full); + s.selection = crate::Selection::single(jpeg_id); + s.xmp.auto_write = true; + s.execute("photo.setMeta", &json!({"title": "Healthy JPEG"})).unwrap(); + let full = sidecar_path(&jpeg.to_string_lossy(), SidecarNaming::Full); + let packet = std::fs::read_to_string(&full).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy JPEG")); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index 8dc450c8b..409739335 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -500,7 +500,11 @@ impl OverlaySettings { let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb }; w.size = f(w.size, 0.005, 0.5, d.size); w.opacity = f(w.opacity, 0.0, 1.0, d.opacity); - w.inset = f(w.inset, 0.0, 0.4, d.inset); + w.inset_x = f(w.inset_x, 0.0, 0.4, d.inset_x); + w.inset_y = f(w.inset_y, 0.0, 0.4, d.inset_y); + if !matches!(w.rotation, 0 | 90 | 180 | 270) { + w.rotation = 0; + } w.image_width = f(w.image_width, 0.01, 1.0, d.image_width); cut(&mut w.text); w.target = None; @@ -574,7 +578,16 @@ impl OverlaySettings { let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?; if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) { for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { - c.insert(k.clone(), v.clone()); + if k == "inset" { + // the legacy single margin sets whichever of the two the patch leaves out + for axis in ["insetX", "insetY"] { + if !o.contains_key(axis) { + c.insert(axis.into(), v.clone()); + } + } + } else { + c.insert(k.clone(), v.clone()); + } } } serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))? @@ -1216,6 +1229,15 @@ mod tests { } } + #[test] + fn watermark_takes_the_export_insets_and_rotation() { + let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); + let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); + assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); + } + #[test] fn hostile_settings_are_refused_or_clamped_and_never_panic() { let d = OverlaySettings::default(); diff --git a/crates/engine/src/slide_title.rs b/crates/engine/src/slide_title.rs index 79ac3489c..5885463b1 100644 --- a/crates/engine/src/slide_title.rs +++ b/crates/engine/src/slide_title.rs @@ -362,6 +362,7 @@ impl TitleCompose { loaded: None, quick: None, display: None, + gpu_fallback: None, } } } diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index cda0baa62..3628f188c 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -21,6 +21,33 @@ pub fn file_name(p: &Photo) -> String { format!("{:032x}.lcsp", h.0) } +/// The default sharded location of a proxy. A two-character hex prefix bounds each +/// folder to about 1/256 of the library's smart previews. +pub fn sharded_file_path(dir: &Path, p: &Photo) -> PathBuf { + let name = file_name(p); + match name.get(..2) { + Some(shard) => dir.join(shard).join(&name), + None => dir.join(&name), + } +} + +/// Read an existing proxy in either layout. New proxies use the sharded location; previews +/// made by older releases still load and can be moved with smartPreviewsLocation. +pub fn file_path(dir: &Path, p: &Photo) -> PathBuf { + existing_file_path(dir, sharded_file_path(dir, p)) +} + +/// Resolve a sharded path to an older flat file when present. Call only off the UI thread: +/// checking a removable drive synchronously could stall drawing. +pub fn existing_file_path(dir: &Path, sharded: PathBuf) -> PathBuf { + if sharded.exists() { + return sharded; + } + let Some(name) = sharded.file_name() else { return sharded }; + let legacy = dir.join(name); + if legacy.exists() { legacy } else { sharded } +} + /// The most header [`is_valid`] reads (the JSON line before the JPEG). const HEADER_MAX: u64 = 4096; @@ -104,12 +131,45 @@ pub fn check_writable(dir: &Path) -> Result<(), String> { Ok(()) } +/// The owned proxy files in both layouts. Do not follow symlinked directories or +/// traverse unrelated folders inside a user-selected smart preview directory. +fn files(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut found = Vec::new(); + for e in entries.flatten() { + let Ok(kind) = e.file_type() else { continue }; + if kind.is_file() && e.path().extension().is_some_and(|ext| ext == "lcsp") { + found.push(e.path()); // legacy layout + } else if kind.is_dir() { + let shard_name = e.file_name(); + let Some(shard) = shard_name.to_str() else { continue }; + if shard.len() != 2 || !shard.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + continue; + } + let Ok(children) = std::fs::read_dir(e.path()) else { continue }; + for child in children.flatten() { + let name = child.file_name(); + let Some(name) = name.to_str() else { continue }; + let Some(stem) = name.strip_suffix(".lcsp") else { continue }; + if stem.len() == 32 + && stem.starts_with(shard) + && stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && child.file_type().is_ok_and(|kind| kind.is_file()) + { + found.push(child.path()); + } + } + } + } + found +} + /// The number of smart previews in `dir` and their total size in bytes. pub fn stats(dir: &Path) -> (usize, u64) { - let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) }; - rd.flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) - .fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len()))) + files(dir).into_iter().fold((0usize, 0u64), |(count, bytes), path| { + let size = std::fs::metadata(path).map_or(0, |m| m.len()); + (count.saturating_add(1), bytes.saturating_add(size)) + }) } /// What to do with the smart previews in the old folder when the location changes. @@ -140,24 +200,32 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { if what == Existing::Leave { return (done, failed); } - let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) }; - for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) { - let src = e.path(); + for src in files(from) { + let Some(name) = src.file_name() else { continue }; + let dst = name + .to_str() + .and_then(|n| n.strip_suffix(".lcsp")) + .filter(|stem| stem.len() == 32 && stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())) + .map_or_else(|| to.join(name), |stem| to.join(&stem[..2]).join(name)); let r = match what { Existing::Discard => std::fs::remove_file(&src), _ => { - let dst = to.join(e.file_name()); - if is_valid(&dst) { - // named by content: the complete copy already there is the same preview - std::fs::remove_file(&src) - } else { - // across drives: copied atomically (never a partial proxy at the new place) - std::fs::rename(&src, &dst).or_else(|_| { - std::fs::read(&src) - .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) - .and_then(|()| std::fs::remove_file(&src)) - }) - } + let parent = dst.parent().unwrap_or(to); + // Create only the shard, never missing parent directories on an unplugged drive. + let prepare = if parent.is_dir() { Ok(()) } else { std::fs::create_dir(parent) }; + prepare.and_then(|()| { + if is_valid(&dst) { + // named by content: the complete copy already there is the same preview + std::fs::remove_file(&src) + } else { + // across drives: copied atomically (never a partial proxy at the new place) + std::fs::rename(&src, &dst).or_else(|_| { + std::fs::read(&src) + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) + .and_then(|()| std::fs::remove_file(&src)) + }) + } + }) } }; match r { @@ -288,6 +356,30 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn sharded_and_legacy_proxies_are_counted_and_moved() { + let base = temp("shards"); + let from = base.join("from"); + let to = base.join("to"); + std::fs::create_dir_all(from.join("a1")).unwrap(); + std::fs::create_dir(&to).unwrap(); + let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; + let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; + std::fs::write(from.join(old), b"legacy").unwrap(); + std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); + std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); + std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); + assert_eq!(stats(&from), (2, 13)); + let (moved, failed) = migrate(&from, &to, Existing::Move); + assert_eq!((moved, failed), (2, Vec::::new())); + assert_eq!(stats(&from), (0, 0)); + assert_eq!(stats(&to), (2, 13)); + assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); + assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); + assert!(from.join("a1").join("notes.txt").exists()); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { let base = temp("valid"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 489b92cb3..ee3d17380 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -557,6 +557,36 @@ fn crop_drag_keeps_a_locked_ratio_on_every_handle() { } } +// Feature: Shift while dragging keeps the current proportions (issue 614) +#[test] +fn crop_drag_keep_ratio_holds_the_shape_with_the_lock_off() { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap(); + assert!(active_dev(&s).crop.aspect.is_none(), "lock is off"); + let r = active_dev(&s).crop.geometry.rect; + let want = r.aspect(); + for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] { + s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": true})) + .unwrap(); + let g = active_dev(&s).crop.geometry.rect; + assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect()); + assert!(active_dev(&s).crop.aspect.is_none(), "the lock toggle is untouched"); + } +} + +#[test] +fn crop_drag_without_keep_ratio_and_lock_off_is_free() { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap(); + let r = active_dev(&s).crop.geometry.rect; + let want = r.aspect(); + s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap(); + assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "ratio changes freely"); + s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false})) + .unwrap(); + assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "keepRatio false is the same"); +} + #[test] fn crop_drag_rejects_unknown_handles_and_bad_points() { let mut s = demo(); diff --git a/crates/engine/src/tests_albums_local.rs b/crates/engine/src/tests_albums_local.rs new file mode 100644 index 000000000..641fdbd6a --- /dev/null +++ b/crates/engine/src/tests_albums_local.rs @@ -0,0 +1,99 @@ +//! Albums hold library photos (issue #684): browsed (Local) photos are skipped by +//! `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B), which report how +//! many; a Local-only selection is refused and leaves Photo ▸ Add to Album off. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::{AlbumId, PhotoId}; +use serde_json::json; + +use crate::{Selection, Session}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-albums-local-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png(p: &Path, seed: u8) { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = + crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap(); + std::fs::write(p, b).unwrap(); +} + +/// A session browsing a folder of two files, one of them added to My Photos: (session, local, library). +fn browsed(tag: &str) -> (Session, PhotoId, PhotoId) { + let dir = temp_dir(tag); + png(&dir.join("a.png"), 1); + png(&dir.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + let mut ids: Vec = s.catalog.photos().filter(|p| p.local).map(|p| p.id).collect(); + ids.sort(); + assert_eq!(ids.len(), 2, "both files are browsed"); + let (local, library) = (ids[0], ids[1]); + s.execute("photo.addToLibrary", &json!({"ids": [library.0]})).unwrap(); + assert!(!s.catalog.photo(library).unwrap().local && s.catalog.photo(local).unwrap().local); + (s, local, library) +} + +fn enabled(s: &Session, id: &str) -> bool { + s.commands().into_iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")).enabled +} + +#[test] +fn a_local_only_selection_is_refused_and_leaves_add_to_album_off() { + let (mut s, local, library) = browsed("refused"); + let album = AlbumId(s.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap()); + s.selection = Selection::single(local); + assert!(!enabled(&s, "album.addPhotos"), "Photo ▸ Add to Album is off for a Local photo"); + for (command, params) in [ + ("album.addPhotos", json!({"id": album.0})), + ("album.addPhotos", json!({"id": album.0, "ids": [local.0]})), + ("album.toggleTarget", json!({})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("Local") && e.contains("Add to My Photos"), "{command}: {e}"); + } + assert!(s.catalog.album(album).unwrap().photos.is_empty(), "nothing invisible was added"); + assert!(s.catalog.quick_collection().is_none(), "no Quick Collection was made for nothing"); + // the album is still created from a Local-only selection, just without it + let r = s.execute("album.create", &json!({"name": "Empty", "addSelected": true})).unwrap(); + assert_eq!(r["skipped"], 1, "{r}"); + assert!(s.catalog.album(AlbumId(r["id"].as_u64().unwrap())).unwrap().photos.is_empty()); + // once in My Photos the same photo goes in + s.execute("photo.addToLibrary", &json!({"ids": [local.0]})).unwrap(); + assert!(enabled(&s, "album.addPhotos")); + let r = s.execute("album.addPhotos", &json!({"id": album.0})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(s.catalog.album(album).unwrap().photos, vec![local]); + let _ = library; +} + +#[test] +fn a_mixed_selection_skips_the_local_photos_and_says_how_many() { + let (mut s, local, library) = browsed("mixed"); + s.selection = Selection { ids: vec![local, library], active: Some(library) }; + assert!(enabled(&s, "album.addPhotos"), "one library photo is enough"); + let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap(); + let album = AlbumId(r["id"].as_u64().unwrap()); + assert_eq!(r["skipped"], 1, "{r}"); + assert_eq!(s.catalog.album(album).unwrap().photos, vec![library]); + assert_eq!(s.catalog.album(album).unwrap().cover, Some(library), "the cover is a photo the album shows"); + + let other = AlbumId(s.execute("album.create", &json!({"name": "Other", "addSelected": false})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("album.addPhotos", &json!({"id": other.0})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!(s.catalog.album(other).unwrap().photos, vec![library]); + + let r = s.execute("album.toggleTarget", &json!({})).unwrap(); + assert_eq!((r["added"].as_bool(), r["skipped"].as_u64()), (Some(true), Some(1)), "{r}"); + let quick = s.catalog.quick_collection().expect("B made the Quick Collection"); + assert_eq!(s.catalog.album(quick).unwrap().photos, vec![library]); + // B again: the library photo is the whole addable selection and it is in, so it comes out + let r = s.execute("album.toggleTarget", &json!({})).unwrap(); + assert_eq!(r["added"], false, "{r}"); + assert!(s.catalog.album(quick).unwrap().photos.is_empty()); +} diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs index e765e0671..e526d4c47 100644 --- a/crates/engine/src/tests_color.rs +++ b/crates/engine/src/tests_color.rs @@ -121,3 +121,102 @@ fn targeted_adjustment_on_curve_and_mixer() { assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0)); assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err()); } + +/// Issue #730: on a raw whose camera has measured colour matrices the white-balance commands write Kelvin, mark +/// the scale, resolve the presets to their real temperatures, and turn a value still on the old relative scale into +/// the Custom Kelvin value that renders the same before applying an edit on top of it. +#[test] +fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + use lightcraft_develop::{DevelopSettings, WbMode, WbScale}; + let mut s = Session::new(); + let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some(as_shot); + p.develop = std::sync::Arc::new(p.camera_defaults()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", (4986.0, -2.0)); + let relative = add(&mut s, "b.arw", "SONY ILCE-7M2", (6500.0, 0.0)); + let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone(); + assert_eq!((dev(&s, kelvin).wb.scale, dev(&s, relative).wb.scale), (WbScale::Kelvin, WbScale::Legacy)); + + // presets are real temperatures on the Kelvin raw, scaled around 6500 K = as shot on the other + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap(); + assert_eq!((r["temp"].as_f64(), r["tint"].as_f64()), (Some(6500.0), Some(10.0))); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Cloudy, 6500.0, 10.0, WbScale::Kelvin)); + s.execute("develop.wb", &json!({"mode": "tungsten"})).unwrap(); + assert_eq!(dev(&s, kelvin).wb.temp, 2850.0); + s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap(); + let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap(); + assert!((r["temp"].as_f64().unwrap() - 6500.0 * 6500.0 / 5500.0).abs() < 1e-6, "{r}"); + assert_eq!(dev(&s, relative).wb.scale, WbScale::Legacy); + + // a slider edit on the Kelvin raw is Kelvin; the stored value says so + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.temp", "value": 5200})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5200.0, -2.0, WbScale::Kelvin)); + let info = s.source_info(kelvin); + assert_eq!(lightcraft_engine_effective(&info, &d), (5200.0, -2.0)); + + // settings from an older catalog on the Kelvin raw: a relative Custom value (6500 = as shot) with no scale + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + s.set_develop(kelvin, old.clone(), "old").unwrap(); + let shown = lightcraft_engine_effective(&info, &old); + let expect = lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)); + assert_eq!(shown, expect); + assert!(shown.0 < 4986.0, "a warm relative shift is a lower Kelvin than as shot: {shown:?}"); + // nudging the tint converts the temperature to the Kelvin it rendered as, then applies the nudge + s.execute("develop.adjust", &json!({"control": "wb.tint", "delta": 5})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin)); + assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb); + // a relative preset value from an older catalog becomes a Custom value with its old look, not the preset's Kelvin + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0); + s.set_develop(kelvin, old.clone(), "old preset").unwrap(); + assert_eq!(lightcraft_engine_effective(&info, &old), lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0))); + s.execute("develop.set", &json!({"control": "wb.tint", "value": 0})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.tint, d.wb.scale), (WbMode::Custom, 0.0, WbScale::Kelvin)); + assert!(d.wb.temp != 6500.0 && (d.wb.temp - lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)).0).abs() < 1e-6); + // a partial without a scale (an old preset, `develop.merge`) keeps the relative meaning on the Kelvin raw + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!(d.wb.scale, WbScale::Legacy); + assert_eq!(lightcraft_engine_effective(&info, &d), (4986.0, -2.0), "6500 / 0 without a scale is as shot"); + // Quick Develop on old settings: As Shot from an older catalog (no scale) nudges the Kelvin as-shot white, a + // relative Custom value is converted to the Kelvin it rendered as first (never a Kelvin number read as relative) + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + old.wb.mode = WbMode::AsShot; + s.set_develop(kelvin, old, "old as shot").unwrap(); + s.execute("develop.quickAdjust", &json!({"control": "wb.temp", "delta": 100})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5086.0, -2.0, WbScale::Kelvin)); + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + s.set_develop(kelvin, old, "old custom").unwrap(); + s.execute("develop.quickAdjust", &json!({"control": "wb.tint", "delta": 5})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin)); + assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb); + + // reset: As Shot, on the Kelvin scale + s.execute("develop.reset", &json!({})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::AsShot, WbScale::Kelvin)); + assert!(!s.catalog.photo(kelvin).unwrap().is_edited()); +} + +fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) { + lightcraft_pipeline::local::effective_wb(info, d) +} diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs new file mode 100644 index 000000000..47759d60c --- /dev/null +++ b/crates/engine/src/tests_crop_angle.rs @@ -0,0 +1,108 @@ +//! Every way of setting the crop angle keeps the crop inside the photo (issue #742): the generic +//! control path (`develop.set` on `crop.angle`, as MCP, the control channel and `lightcraft-cli +//! render --set` use it, plus `develop.adjust`, `develop.quickAdjust`, `develop.resetControl`) gives +//! the same crop as `crop.straighten` (the rotate handle, the Straighten slider, the Angle field) +//! and `crop.set {angle}`. + +use lightcraft_geom::{CropGeometry, Rect}; +use serde_json::{Value, json}; + +use crate::Session; + +fn geometry(s: &Session) -> CropGeometry { + s.develop_of(s.active().unwrap()).unwrap().crop.geometry +} + +fn dims(s: &Session) -> (f64, f64) { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) } +} + +/// Run `cmd` from the current state, return the crop it leaves, and undo it. +fn try_cmd(s: &mut Session, cmd: &str, p: Value) -> CropGeometry { + s.execute(cmd, &p).unwrap(); + let g = geometry(s); + s.execute("edit.undo", &json!({})).unwrap(); + g +} + +fn close(a: CropGeometry, b: CropGeometry) -> bool { + let r = |g: CropGeometry| [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1, g.angle]; + r(a).iter().zip(r(b)).all(|(x, y)| (x - y).abs() < 1e-9) +} + +#[test] +fn every_way_of_setting_the_angle_gives_the_straighten_crop() { + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + for aspect in [json!("original"), json!("free"), json!("1x1"), json!("16x9"), json!("4x5")] { + s.execute("crop.aspect", &json!({"aspect": aspect})).unwrap(); + let start = geometry(&s); + for angle in [-45.0, -30.0, -7.5, 0.5, 2.0, 15.0, 33.3] { + let want = try_cmd(&mut s, "crop.straighten", json!({"angle": angle})); + assert!(want.is_within_image(w, h), "{aspect} {angle}°: straighten fits"); + assert_eq!(want.angle, angle); + for (cmd, p) in [ + ("develop.set", json!({"control": "crop.angle", "value": angle})), + ("develop.set", json!({"values": {"crop.angle": angle, "light.exposure": 0.3}})), + ("crop.set", json!({"angle": angle})), + ("develop.adjust", json!({"control": "crop.angle", "delta": angle - start.angle})), + ] { + let got = try_cmd(&mut s, cmd, p.clone()); + assert!(close(got, want), "{aspect} {angle}°: {cmd} {p} gave {got:?}, crop.straighten {want:?}"); + } + assert!(close(geometry(&s), start), "undo restored the crop"); + } + } +} + +#[test] +fn a_crop_that_still_fits_is_kept() { + let mut s = Session::with_demo(); + s.execute("crop.set", &json!({"rect": [0.3, 0.3, 0.7, 0.7]})).unwrap(); + for angle in [-4.0, 1.0, 3.0, 6.0] { + s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap(); + let g = geometry(&s); + assert_eq!(g.rect, Rect::new(0.3, 0.3, 0.7, 0.7), "no needless shrink at {angle}°"); + assert_eq!(g.angle, angle); + } +} + +#[test] +fn quick_develop_reset_and_batch_paths_fit_too() { + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + let id = s.active().unwrap(); + let want = try_cmd(&mut s, "crop.straighten", json!({"angle": 12.0})); + + let got = try_cmd(&mut s, "develop.quickAdjust", json!({"control": "crop.angle", "delta": 12.0, "ids": [id.0]})); + assert!(close(got, want), "quickAdjust {got:?} vs {want:?}"); + let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]})); + assert!(close(got, want), "develop.set ids {got:?} vs {want:?}"); + + // back to 0°: the crop that fitted at 12° fits at 0° too and is kept + s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap(); + s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap(); + let g = geometry(&s); + assert_eq!((g.rect, g.angle), (want.rect, 0.0)); + assert!(g.is_within_image(w, h)); +} + +#[test] +fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() { + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + let start = geometry(&s); + let undo = s.undo.len(); + s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap(); + for angle in [1.0, 4.0, 9.0, 15.0] { + s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap(); + assert!(geometry(&s).is_within_image(w, h), "fitted at every step ({angle}°)"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + assert_eq!(s.undo.len(), undo + 1, "the drag is one undo step"); + assert_eq!(geometry(&s).angle, 15.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(close(geometry(&s), start)); +} diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index a7d1c7c77..ae202f192 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -720,6 +720,52 @@ fn a_photo_waits_for_its_turn_unless_pictures_are_not_made_on_their_own_or_have_ let _ = std::fs::remove_dir_all(&x.dir); } +#[test] +fn a_picture_that_appears_while_its_job_runs_is_ready_only_when_the_job_reports() { + // #719: the worker saves the picture, then tears down its thread pool before it reports. A pump in that window + // used to find the file and mark the photo Ready while the session still counted the job as running: Ready and + // busy at once, and the activity row stayed up for a photo that said it was done. + use std::sync::{Mutex, mpsc}; + use std::time::Duration; + let mut x = setup("ready-while-running", true); + let id = photo(&x.s); + set_amount(&mut x.s, 80.0); + // the job loads the model afresh and waits in the loader until it is released: a worker that is at work + *x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None; + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = Mutex::new(release_rx); + let runs = x.runs.clone(); + x.s.denoise.loader = Arc::new(move |_, _| { + entered_tx.send(()).map_err(|e| e.to_string())?; + release_rx.lock().unwrap().recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?; + Ok(Arc::new(Dim { runs: runs.clone() }) as Arc) + }); + x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. })); + assert!(x.s.denoise_busy()); + // the picture lands on disk before the worker says so (as it does between its save and its report) + let (product, key) = x.s.denoise_running_product().unwrap(); + let mut rgb = lightcraft_raster::Rgb32f::new(192, 160); + rgb.data.fill([0.25, 0.25, 0.25]); + lightcraft_denoise::product::write(&product, &rgb, &key).unwrap(); + assert!(lightcraft_denoise::product::is_current(&product, &key)); + x.s.denoise.touch(); // the index is also rebuilt from the folder in this window + let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }), "the job reports its own picture"); + assert_eq!(r["ready"], 0, "{r}"); + assert!(x.s.denoise_busy()); + // the worker reports: ready and idle in the same pump + release_tx.send(()).unwrap(); + let r = pump_until_ready(&mut x.s, 1); + assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready); + assert_eq!(r["running"], Value::Null, "{r}"); + assert!(!x.s.denoise_busy()); + drop(x.s); + let _ = std::fs::remove_dir_all(x.dir); +} + #[test] fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_selection() { let mut x = setup("toggle", false); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b8636c018..aaecc0b39 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -177,7 +177,13 @@ fn prepared_exports_run_on_another_thread() { /// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an /// error) gave an all-black export. The export must come from the CPU instead — the same -/// image — with the reason recorded. +/// image — with the reason recorded on the export itself. +/// +/// Issue #675: this test runs beside every other engine test, many of them rendering on the GPU. +/// It asks only about its own export (`Exported::gpu_fallback`), never the process-wide +/// `lightcraft_gpu::last_fallback` another thread's render overwrites, and the simulated failure +/// switches the GPU off for this thread alone, so neither the fault nor `reset_failures` reaches +/// the other tests. #[test] fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { let mut s = Session::with_demo(); @@ -192,18 +198,26 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { let gpu = lightcraft_gpu::available(); let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes); lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork); - let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes; + let faulted = export_photo(&mut s, id, &o, 1).unwrap(); if gpu { - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + let why = faulted.gpu_fallback.clone().unwrap_or_default(); assert!(why.contains("incomplete"), "{why}"); - // the GPU is off for the process now: this export renders on the CPU + // the GPU is off for this thread now: the next export is not even tried on it assert!(!lightcraft_gpu::available()); + assert!(lightcraft_gpu::unavailable_reason().unwrap_or_default().contains("incomplete"), "{:?}", lightcraft_gpu::unavailable_reason()); + // the thread's own `inject_fault` stopped only its own GPU use: the others still have it + let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join().unwrap(); + assert_eq!(elsewhere, (true, None), "a simulated failure must not switch the GPU off for the other threads"); + } else { + assert_eq!(faulted.gpu_fallback, None); } - let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes; + let cpu = export_photo(&mut s, id, &o, 1).unwrap(); + assert_eq!(cpu.gpu_fallback, None, "not tried on the GPU: no fallback to report"); lightcraft_gpu::reset_failures(); + assert_eq!(lightcraft_gpu::available(), gpu, "reset_failures gives this thread the GPU back"); let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data; - assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render"); - let m = mean(&faulted); + assert!(px(&faulted.bytes) == px(&cpu.bytes), "the fallback is the CPU render"); + let m = mean(&faulted.bytes); assert!(m > 0.02, "not black: mean {m}"); assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}"); } @@ -440,3 +454,91 @@ fn hdr_avif_export_is_pq_rec2020() { // a photo without an HDR edit exports as an ordinary (sRGB) AVIF assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); } + +/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's +/// packet carry no location, and an original that embeds GPS is refused rather than copied. +#[test] +fn remove_location_holds_for_original_and_dng_exports() { + let dir = temp_dir("export-noloc"); + let gps = + lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None }), ..Default::default() }; + // a raw with no embedded GPS, located in the catalog + let raw = dir.join("Raw.dng"); + std::fs::write(&raw, synthetic_dng_with(None, Default::default())).unwrap(); + // a JPEG with GPS in its EXIF + let jpg = dir.join("Geo.jpg"); + let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]); + let exif = lightcraft_meta::write_exif(&gps); + let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; + std::fs::write(&jpg, lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap()) + .unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy(), jpg.to_string_lossy()]})).unwrap(); + let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (rid, jid) = (id(&s, "Raw.dng"), id(&s, "Geo.jpg")); + s.execute("photo.setMeta", &json!({"ids": [rid.0, jid.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let opts = |f: &str, remove: bool| ExportOptions::from_json(&json!({"format": f, "removeLocation": remove})); + + // original raw: the sidecar keeps the location unless it is removed + let side = |e: &crate::export::Exported| lightcraft_meta::parse_xmp(std::str::from_utf8(&e.sidecars[0].1).unwrap()).unwrap().metadata; + let kept = side(&export_photo(&mut s, rid, &opts("original", false), 1).unwrap()); + assert!(kept.gps.is_some() && kept.city.is_some()); + let gone = side(&export_photo(&mut s, rid, &opts("original", true), 1).unwrap()); + assert!(gone.gps.is_none() && gone.city.is_none(), "{gone:?}"); + + // dng: the embedded packet + let e = export_photo(&mut s, rid, &opts("dng", false), 1).unwrap(); + assert!(lightcraft_meta::extract(&e.bytes).gps.is_some()); + let e = export_photo(&mut s, rid, &opts("dng", true), 1).unwrap(); + let m = lightcraft_meta::extract(&e.bytes); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); + + // original JPEG embedding GPS: copied as is normally, refused when the location must go + let e = export_photo(&mut s, jid, &opts("original", false), 1).unwrap(); + assert!(lightcraft_meta::extract(&e.bytes).gps.is_some()); + let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap(); + assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An original raw whose own metadata holds GPS is refused under "Remove location info". +#[test] +fn remove_location_refuses_a_raw_that_embeds_gps() { + let dir = temp_dir("export-noloc-raw"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 10.0, longitude: 20.0, altitude: None }), ..Default::default() }; + let raw = dir.join("Geo.dng"); + let bytes = synthetic_dng_with(Some(&lightcraft_meta::write_xmp(&gps, None)), Default::default()); + assert!(lightcraft_meta::extract(&bytes).gps.is_some(), "fixture embeds GPS"); + std::fs::write(&raw, bytes).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't remove the location"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn hdr_png_export_is_16_bit_pq_rec2020() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "png", "hdr": true})); + assert!(o.hdr_output()); + assert_eq!(o.effective_space(), lightcraft_pipeline::OutputSpace::Rec2020); + // a photo without an HDR edit exports as an ordinary (sRGB, 8-bit) PNG + let sdr = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(!sdr.bytes.windows(4).any(|w| w == b"cICP")); + assert_eq!(sdr.bytes[24], 8, "8-bit"); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + let at = hdr.bytes.windows(4).position(|w| w == b"cICP").expect("cICP chunk"); + assert_eq!(&hdr.bytes[at + 4..at + 8], &[9, 16, 0, 1], "BT.2020, PQ, RGB, full range"); + assert_eq!(hdr.bytes[24], 16, "16-bit"); + // the highlights above SDR white are there: MaxCLL beyond the 203 cd/m² of SDR white + let at = hdr.bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI chunk"); + let max_cll = u32::from_be_bytes(hdr.bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0; + assert!(max_cll > 250.0 && max_cll <= 4.0 * 203.0 + 1.0, "MaxCLL {max_cll}"); + // HDR output off: the HDR edit exports its SDR rendition + let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap(); + assert!(!plain.bytes.windows(4).any(|w| w == b"cICP")); +} diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index c71f7afeb..f1c27c849 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -584,3 +584,32 @@ fn the_users_own_catalog_adds_models_with_the_same_download_and_install() { } let _ = std::fs::remove_dir_all(&d); } + +/// Issue #678: on means something only with a model to run. Turning recognition on with no chosen +/// (installed) recogniser is refused; installing one chooses it and switches recognition on, after +/// which the switch works both ways; removing the last recogniser switches it off again. +#[test] +fn recognition_cannot_be_turned_on_without_a_chosen_model() { + let d = temp("enable-needs-model"); + let mut s = session(&d); + for p in [json!({"enabled": true}), json!({})] { + let e = s.execute("faces.enable", &p).unwrap_err().to_string(); + assert!(e.contains("recognition model"), "{p}: {e}"); + } + assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false, "off is always allowed"); + assert_eq!(s.execute("faces.models.list", &json!({})).unwrap()["enabled"], false); + let f = model_file(&d, "m.onnx", 64); + let r = s.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).unwrap(); + let id = r["installed"]["id"].as_str().unwrap().to_string(); + assert_eq!(r["active"]["enabled"], true, "installing chooses the model and switches recognition on: {r}"); + assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false); + assert_eq!(s.execute("faces.enable", &json!({"enabled": true})).unwrap()["enabled"], true); + assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], false, "toggles when omitted"); + assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], true); + // the last recogniser goes: nothing is chosen, so recognition is off and stays off + assert_eq!(s.execute("faces.models.remove", &json!({"id": id})).unwrap()["embedder"], Value::Null); + let l = s.execute("faces.models.list", &json!({})).unwrap(); + assert_eq!((l["enabled"].clone(), l["embedder"].clone()), (json!(false), Value::Null), "{l}"); + assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/tests_filesystem_history.rs b/crates/engine/src/tests_filesystem_history.rs new file mode 100644 index 000000000..c071d995b --- /dev/null +++ b/crates/engine/src/tests_filesystem_history.rs @@ -0,0 +1,537 @@ +//! Filesystem history and retained XMP repair regressions. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Selection, Session}; + +fn select(s: &mut Session, ids: &[u64], active: u64) { + s.execute("library.select", &json!({"ids": ids, "active": active})).unwrap(); +} + +fn memory_library(store: &lightcraft_catalog::MemStore) -> Session { + let mut s = Session::new(); + s.open_library_in( + crate::library::LibraryStores { + dir: "history-regression".into(), + catalog: Box::new(store.clone()), + files: Box::new(lightcraft_catalog::MemStore::new()), + on_disk: false, + }, + false, + ) + .unwrap(); + s +} + +fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool) -> crate::Result { + s.execute_fn(if redo { "edit.redo" } else { "edit.undo" }, |s| { + let label = if redo { s.redo_step_with(fs)? } else { s.undo_step_with(fs)? }; + Ok(json!({"label": label})) + }) +} + +fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) { + let files = fs.listing(); + for photo in s.catalog.photos() { + let Source::File { path } = &photo.source else { panic!("synthetic file fixture") }; + assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0); + assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str()); + } + let replayed = memory_library(store); + assert_eq!(replayed.catalog.to_snapshot(), s.catalog.to_snapshot()); +} + +fn sidecar_history_fixture(redo: bool) -> (Session, crate::rename::tests::FakeFs, lightcraft_catalog::MemStore) { + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = crate::rename::tests::FakeFs::new( + false, + &[ + ("/p/a.jpg", "Photo"), + ("/p/a.XMP", "Upper stem edits"), + ("/p/a.xmp", "Lower stem edits"), + ("/p/a.jpg.XMP", "Upper full-name edits"), + ("/p/a.jpg.xmp", "Lower full-name edits"), + ("/p/other.jpg", "Other"), + ], + ); + for (i, path) in ["/p/a.jpg", "/p/other.jpg"].iter().enumerate() { + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy(); + let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, ""); + s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + s.persist().unwrap(); + s.undo.clear(); + select(&mut s, &[1], 1); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap(); + if redo { + file_history_step(&mut s, &fs, false).unwrap(); + } + select(&mut s, &[2], 2); + (s, fs, store) +} + +fn sidecar_history_entry(s: &Session, redo: bool) -> &crate::UndoEntry { + if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() } +} + +#[test] +fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal() { + use crate::rename::tests::FakeFs; + use crate::sidecar::{SidecarNaming, sidecar_path}; + + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Edits"), ("/p/other.jpg", "Other")]); + for (id, path) in [(1, "/p/a.jpg"), (2, "/p/other.jpg")] { + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy(); + let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, ""); + s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + select(&mut s, &[1], 1); + let copy = s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.undo.clear(); + let before = fs.listing(); + for name in ["middle", "final"] { + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], name, 1); + s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap(); + assert_file_catalog_and_journal(&s, &fs, &store); + } + let renamed = fs.listing(); + s.merge_undo(2, "Rename Twice"); + assert_eq!(s.undo.len(), 1); + assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]); + // First exercise the ordinary merged step, before any corrective paths exist. + for redo in [false, true] { + file_history_step(&mut s, &fs, redo).unwrap(); + assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() }); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source); + assert_file_catalog_and_journal(&s, &fs, &store); + } + for redo in [false, true, false] { + select(&mut s, &[2], 2); + let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() }; + let moves = s.file_moves(&entry.op); + assert_eq!(moves.len(), 1, "virtual copies must share the final physical move"); + let (from, to) = &moves[0]; + let sidecar = sidecar_path(from, SidecarNaming::Stem).to_string_lossy().into_owned(); + *fs.fail.borrow_mut() = vec![sidecar, to.clone()]; + let history = (s.undo.len(), s.redo.len()); + assert!(file_history_step(&mut s, &fs, redo).is_err()); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(2))); + assert_file_catalog_and_journal(&s, &fs, &store); + fs.fail.borrow_mut().clear(); + file_history_step(&mut s, &fs, redo).unwrap(); + assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() }); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source); + assert_file_catalog_and_journal(&s, &fs, &store); + } +} + +#[test] +fn partial_file_history_failures_retry_without_losing_either_inverse_or_journal() { + use crate::rename::tests::FakeFs; + + for redo in [false, true] { + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = FakeFs::new( + false, + &[ + ("/p/a.jpg", "A"), + ("/p/a.xmp", "A edits"), + ("/p/b.jpg", "B"), + ("/p/b.xmp", "B edits"), + ("/p/c.jpg", "C"), + ("/p/c.xmp", "C edits"), + ("/p/other.jpg", "Other"), + ], + ); + let ops = ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg", "/p/other.jpg"] + .iter() + .enumerate() + .map(|(i, path)| { + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy(); + let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, ""); + Op::AddPhoto { photo: Box::new(photo) } + }) + .collect(); + s.commit("Synthetic Files", Op::Batch { ops }).unwrap(); + // A virtual copy shares B's physical file and must follow every partial correction too. + let mut copy = (**s.catalog.photo(PhotoId(2)).unwrap()).clone(); + copy.id = PhotoId(5); + copy.copy_of = Some(PhotoId(2)); + s.commit("Synthetic Copy", Op::AddPhoto { photo: Box::new(copy) }).unwrap(); + s.persist().unwrap(); + s.undo.clear(); + select(&mut s, &[1, 2, 3], 1); + let before = fs.listing(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1), PhotoId(2), PhotoId(3)], "Trip-{seq}", 1); + s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap(); + let renamed = fs.listing(); + if redo { + file_history_step(&mut s, &fs, false).unwrap(); + } + select(&mut s, &[4], 4); + // Two bounded failures leave a different file moved on each attempt. A subsequent + // failure with no moves must keep those corrections; a final retry round-trips them. + for attempt in 0..2 { + let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() }; + let moves = s.file_moves(&entry.op); + assert_eq!(moves.len(), 3 - attempt); + *fs.fail.borrow_mut() = vec![moves[1].0.clone(), moves[0].1.clone()]; + let history = (s.undo.len(), s.redo.len()); + let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string(); + assert!(error.contains("could not be moved back"), "{error}"); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(4))); + assert_file_catalog_and_journal(&s, &fs, &store); + } + let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() }; + let moves = s.file_moves(&entry.op); + assert_eq!(moves.len(), 1); + *fs.fail.borrow_mut() = vec![moves[0].0.clone()]; + let unchanged = fs.listing(); + let history = (s.undo.len(), s.redo.len()); + assert!(file_history_step(&mut s, &fs, redo).is_err()); + assert_eq!(fs.listing(), unchanged); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(4))); + assert_file_catalog_and_journal(&s, &fs, &store); + fs.fail.borrow_mut().clear(); + file_history_step(&mut s, &fs, redo).unwrap(); + assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() }); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, !redo).unwrap(); + assert_eq!(fs.listing(), if redo { before.clone() } else { renamed.clone() }); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, redo).unwrap(); + assert_eq!(fs.listing(), if redo { renamed } else { before }); + assert_file_catalog_and_journal(&s, &fs, &store); + } +} + +#[test] +fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollback() { + use crate::rename::MoveFs; + use crate::sidecar::{SidecarNaming, sidecar_path}; + + for redo in [false, true] { + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let (mut s, fs, store) = sidecar_history_fixture(redo); + let moves = s.file_moves(&sidecar_history_entry(&s, redo).op); + assert_eq!(moves.len(), 1); + let (from, to) = &moves[0]; + let from = from.clone(); + let to = to.clone(); + let before = fs.listing(); + let failing = sidecar_path(&from, naming).with_extension("XMP"); + *fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()]; + let history = (s.undo.len(), s.redo.len()); + let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string(); + assert!(error.contains("could not be moved back"), "{error}"); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() }); + assert!(s.file_moves(&sidecar_history_entry(&s, redo).op).is_empty(), "the photo already followed its destination"); + let expected_pending = if naming == SidecarNaming::Stem { 4 } else { 2 }; + assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending); + assert_file_catalog_and_journal(&s, &fs, &store); + + // Repeating the same XMP failure must not turn the empty photo move list into success. + *fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned()]; + let partial = fs.listing(); + let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string(); + assert!(error.contains("injected failure"), "{error}"); + assert_eq!(fs.listing(), partial); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(2))); + assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending); + assert_file_catalog_and_journal(&s, &fs, &store); + + // Make progress, then fail at the other full-name spelling. Completed sidecars + // must not be retried (their old names no longer exist). + let last = sidecar_history_entry(&s, redo).retry_sidecars.last().unwrap().clone(); + *fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()]; + assert!(file_history_step(&mut s, &fs, redo).is_err()); + assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(2))); + assert_file_catalog_and_journal(&s, &fs, &store); + + // A new destination collision stays an error and never replaces unrelated bytes. + fs.fail.borrow_mut().clear(); + fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Unrelated edits".to_vec())); + let occupied = fs.listing(); + assert!(file_history_step(&mut s, &fs, redo).is_err()); + assert_eq!(fs.listing(), occupied); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1); + assert_file_catalog_and_journal(&s, &fs, &store); + fs.remove_file(&last.to).unwrap(); + + file_history_step(&mut s, &fs, redo).unwrap(); + let mut after = before + .iter() + .map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone())) + .collect::>(); + after.sort(); + assert_eq!(fs.listing(), after); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, !redo).unwrap(); + assert_eq!(fs.listing(), before); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, redo).unwrap(); + assert_eq!(fs.listing(), after); + assert_file_catalog_and_journal(&s, &fs, &store); + } + } +} + +#[test] +fn history_repairs_a_sidecar_whose_rollback_failed_before_retrying_the_photo() { + use crate::sidecar::{SidecarNaming, sidecar_path}; + + for redo in [false, true] { + let (mut s, fs, store) = sidecar_history_fixture(redo); + let moves = s.file_moves(&sidecar_history_entry(&s, redo).op); + let (from, to) = &moves[0]; + let from = from.clone(); + let to = to.clone(); + let before = fs.listing(); + // The stem sidecars move first; full-name failure rolls the photo back, but the + // first stem sidecar cannot return. The catalog has not changed this time. + let failed_full = sidecar_path(&from, SidecarNaming::Full).with_extension("XMP"); + let stranded = sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP"); + *fs.fail.borrow_mut() = vec![failed_full.to_string_lossy().into_owned(), stranded.to_string_lossy().into_owned()]; + let history = (s.undo.len(), s.redo.len()); + let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string(); + assert!(error.contains("sidecar") && error.contains("could not be moved back"), "{error}"); + let entry = sidecar_history_entry(&s, redo); + assert_eq!(entry.retry_sidecars.len(), 1); + assert!(entry.retry_inverse.is_empty()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: from }); + assert_file_catalog_and_journal(&s, &fs, &store); + let partial = fs.listing(); + *fs.fail.borrow_mut() = vec![stranded.to_string_lossy().into_owned()]; + assert!(file_history_step(&mut s, &fs, redo).is_err()); + assert_eq!(fs.listing(), partial); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.selection, Selection::single(PhotoId(2))); + fs.fail.borrow_mut().clear(); + file_history_step(&mut s, &fs, redo).unwrap(); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, !redo).unwrap(); + assert_eq!(fs.listing(), before); + assert_file_catalog_and_journal(&s, &fs, &store); + } +} + +#[test] +fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() { + use crate::rename::tests::FakeFs; + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Stem edits"), ("/p/a.jpg.xmp", "Full edits")]); + let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, ""); + s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.persist().unwrap(); + s.undo.clear(); + select(&mut s, &[1], 1); + let before = fs.listing(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + *fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()]; + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() }); + assert_eq!(s.undo.len(), 1); + assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2); + assert_file_catalog_and_journal(&s, &fs, &store); + let retry = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert_eq!(retry[0].from_path, retry[0].to_path, "the physical photo already has the requested name"); + *fs.fail.borrow_mut() = vec!["/p/a.xmp".into()]; + let partial = fs.listing(); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).is_err()); + assert_eq!(fs.listing(), partial); + assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2, "a failed no-op retry must keep its repair queue"); + fs.fail.borrow_mut().clear(); + assert_eq!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).unwrap()["renamed"], 0); + assert!(s.undo.last().unwrap().retry_sidecars.is_empty()); + assert_eq!(s.undo.len(), 1, "a repaired no-op retry adds no empty rename step"); + let after = fs.listing(); + assert_eq!( + after, + vec![("/p/Trip.jpg".into(), "Photo".into()), ("/p/Trip.jpg.xmp".into(), "Full edits".into()), ("/p/Trip.xmp".into(), "Stem edits".into())] + ); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, false).unwrap(); + assert_eq!(fs.listing(), before); + file_history_step(&mut s, &fs, true).unwrap(); + assert_eq!(fs.listing(), after); + assert_file_catalog_and_journal(&s, &fs, &store); +} + +#[test] +fn initial_sidecar_rollback_failure_keeps_a_repair_only_history_step() { + use crate::rename::tests::FakeFs; + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.XMP", "Stem edits"), ("/p/a.jpg.XMP", "Full edits")]); + let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, ""); + s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.persist().unwrap(); + s.undo.clear(); + select(&mut s, &[1], 1); + let before = fs.listing(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + *fs.fail.borrow_mut() = vec!["/p/a.jpg.XMP".into(), "/p/Trip.XMP".into()]; + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() }); + assert_eq!(s.undo.len(), 1); + assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1); + assert_file_catalog_and_journal(&s, &fs, &store); + let partial = fs.listing(); + *fs.fail.borrow_mut() = vec!["/p/Trip.XMP".into()]; + assert!(file_history_step(&mut s, &fs, false).is_err()); + assert_eq!(fs.listing(), partial); + assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1); + fs.fail.borrow_mut().clear(); + file_history_step(&mut s, &fs, false).unwrap(); + assert_eq!(fs.listing(), before); + assert_file_catalog_and_journal(&s, &fs, &store); + file_history_step(&mut s, &fs, true).unwrap(); + assert_eq!(fs.listing(), before, "redo of the repair-only step must not rename healthy files"); + assert_file_catalog_and_journal(&s, &fs, &store); +} + +#[test] +fn discarded_file_history_keeps_sidecar_repairs_without_stale_catalog_inverses() { + use crate::rename::MoveFs; + use crate::sidecar::{SidecarNaming, sidecar_path}; + + for redo in [false, true] { + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let (mut s, fs, store) = sidecar_history_fixture(redo); + let moves = s.file_moves(&sidecar_history_entry(&s, redo).op); + let (from, to) = moves[0].clone(); + let failing = sidecar_path(&from, naming).with_extension("XMP"); + *fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()]; + assert!(file_history_step(&mut s, &fs, redo).is_err()); + let pending = sidecar_history_entry(&s, redo).retry_sidecars.clone(); + assert!(!sidecar_history_entry(&s, redo).retry_inverse.is_empty()); + // A new edit discards Redo. Enough edits also evict an unresolved Undo entry. + for i in 0..if redo { 1 } else { 1000 } { + s.execute("photo.rate", &json!({"ids": [2], "rating": i % 5 + 1})).unwrap(); + } + assert!(s.redo.is_empty()); + assert_eq!(s.pending_sidecar_repairs, pending); + assert!(s.undo.iter().all(|e| e.retry_inverse.is_empty() && e.retry_sidecars.is_empty())); + let partial = fs.listing(); + // Metadata history remains usable without write access to the separated XMP. + file_history_step(&mut s, &fs, false).unwrap(); + file_history_step(&mut s, &fs, true).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + assert_eq!(fs.listing(), partial); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() }); + assert_file_catalog_and_journal(&s, &fs, &store); + + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Next", 1); + let history = (s.undo.len(), s.redo.len()); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + assert_eq!(fs.listing(), partial); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(s.pending_sidecar_repairs, pending); + // Retain only unfinished work after partial progress; never overwrite a collision. + let last = pending.last().unwrap().clone(); + *fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()]; + assert!(s.apply_rename_with(&fs, &plans).is_err()); + assert_eq!(s.pending_sidecar_repairs.len(), 1); + crate::rename::tests::assert_repair_packets( + &s.pending_sidecar_repairs[0], + &last, + &[ + (sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP"), b"Upper stem edits"), + (sidecar_path(&to, SidecarNaming::Stem), b"Lower stem edits"), + (sidecar_path(&to, SidecarNaming::Full).with_extension("XMP"), b"Upper full-name edits"), + (last.from.clone(), b"Lower full-name edits"), + ], + ); + // Successful repairs advance companion paths while retaining their content versions. + let last = s.pending_sidecar_repairs[0].clone(); + fs.fail.borrow_mut().clear(); + fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Other edits".to_vec())); + let occupied = fs.listing(); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + assert_eq!(fs.listing(), occupied); + assert_eq!(s.pending_sidecar_repairs, vec![last.clone()]); + fs.remove_file(&last.to).unwrap(); + + // A no-op Rename still repairs the detached sidecars, without a new undo step. + let noop = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1); + assert_eq!(s.apply_rename_with(&fs, &noop).unwrap(), 0); + assert!(s.pending_sidecar_repairs.is_empty()); + assert_eq!((s.undo.len(), s.redo.len()), history); + let repaired = fs.listing(); + assert_file_catalog_and_journal(&s, &fs, &store); + s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap(); + let renamed = fs.listing(); + file_history_step(&mut s, &fs, false).unwrap(); + assert_eq!(fs.listing(), repaired, "new history restores the current name, not the discarded inverse"); + file_history_step(&mut s, &fs, true).unwrap(); + assert_eq!(fs.listing(), renamed); + assert_file_catalog_and_journal(&s, &fs, &store); + } + } +} + +#[test] +fn merged_rename_failure_keeps_rolled_back_photo_identity() { + use crate::rename::tests::FakeFs; + + let store = lightcraft_catalog::MemStore::new(); + let mut s = memory_library(&store); + let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo 1"), ("/p/a.xmp", "Photo 1 edits"), ("/p/b.jpg", "Photo 2")]); + for (id, path) in [(1, "/p/a.jpg"), (2, "/p/b.jpg")] { + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy(); + let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, ""); + s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + s.persist().unwrap(); + s.undo.clear(); + for (id, name) in [(1, "c"), (2, "a"), (2, "d")] { + select(&mut s, &[id], id); + let plans = s.plan_rename_with(&fs, &[PhotoId(id)], name, 1); + s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap(); + } + assert_eq!( + fs.listing(), + vec![ + ("/p/c.jpg".to_string(), "Photo 1".to_string()), + ("/p/c.xmp".to_string(), "Photo 1 edits".to_string()), + ("/p/d.jpg".to_string(), "Photo 2".to_string()), + ] + ); + s.merge_undo(3, "Rename Three Times"); + assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); + assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]); + // The second photo move sticks; the preceding d -> b move must roll back to d. + *fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()]; + let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string(); + assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}"); + assert!(error.contains("/p/c.jpg could not be moved back"), "{error}"); + let files = fs.listing(); + assert_eq!( + files, + vec![ + ("/p/a.jpg".to_string(), "Photo 1".to_string()), + ("/p/c.xmp".to_string(), "Photo 1 edits".to_string()), + ("/p/d.jpg".to_string(), "Photo 2".to_string()), + ] + ); + assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() }); + let photo2 = s.catalog.photo(PhotoId(2)).unwrap(); + assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg"); + let Source::File { path } = &photo2.source else { panic!("file fixture") }; + assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2")); +} diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 9f51ab408..b55e88c8a 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -564,3 +564,124 @@ fn a_label_comes_off_a_folder_whose_photos_are_gone() { assert_eq!(s.catalog.folder_record("/pics/trip"), None); assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos"); } + +// ---- folder.create and photo.moveToFolder (issue #765) + +/// A library photo of the real file `path` (written with `body`); returns its id. +fn add_file(s: &mut Session, path: &str, body: &str) -> lightcraft_catalog::PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: lightcraft_catalog::PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +#[test] +fn a_folder_is_created_on_disk_and_undo_removes_it() { + let dir = Scratch::new("create"); + let mut s = Session::new(); + let r = s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap(); + assert_eq!(r["path"], dir.path("picks")); + assert!(dir.0.join("picks").is_dir()); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap_err().to_string().contains("already exists")); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "../x"})).is_err(), "one name, no slashes"); + assert!(s.execute("folder.create", &json!({"parent": dir.path("nope"), "name": "x"})).unwrap_err().to_string().contains("not a folder")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!dir.0.join("picks").exists(), "undo removes it"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("picks").is_dir(), "redo makes it again"); + std::fs::write(dir.0.join("picks/note.txt"), "x").unwrap(); + assert!(s.execute("edit.undo", &json!({})).is_err(), "a folder with something in it is never removed"); + assert!(dir.0.join("picks/note.txt").is_file()); +} + +#[test] +fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() { + let dir = Scratch::new("move-photos"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::write(dir.path("in/a.xmp"), "").unwrap(); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + // a virtual copy shares its master's file and goes along + let vc = s.catalog.alloc_photo_id(); + let p = Photo::new(vc, Source::File { path: dir.path("in/a.jpg") }, "a.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap(); + assert_eq!(r["moved"], 2); + assert_eq!(std::fs::read_to_string(dir.path("out/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("out/a.xmp").is_file(), "the sidecar goes along"); + assert!(!dir.0.join("in/a.jpg").exists() && !dir.0.join("in/a.xmp").exists()); + assert_eq!(file_of(&s, a), dir.path("out/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); + assert_eq!(file_of(&s, vc), dir.path("out/a.jpg"), "the virtual copy follows"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert_eq!(file_of(&s, vc), dir.path("in/a.jpg")); + assert!(dir.0.join("in/a.jpg").is_file() && dir.0.join("in/a.xmp").is_file() && dir.0.join("in/b.jpg").is_file()); + assert!(!dir.0.join("out/a.jpg").exists()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("out/b.jpg").is_file()); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); +} + +#[test] +fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() { + let dir = Scratch::new("move-clash"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + std::fs::write(dir.path("out/b.jpg"), "OTHER").unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("b.jpg already in") && e.contains("nothing was moved"), "{e}"); + assert_eq!(std::fs::read_to_string(dir.path("out/b.jpg")).unwrap(), "OTHER"); + assert!(dir.0.join("in/a.jpg").is_file() && !dir.0.join("out/a.jpg").exists(), "all or nothing"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[test] +fn moving_to_a_missing_folder_or_a_missing_original_is_refused() { + let dir = Scratch::new("move-missing"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("nope")})).unwrap_err().to_string(); + assert!(e.contains("not a folder"), "{e}"); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + std::fs::remove_file(dir.path("in/a.jpg")).unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("missing or offline"), "{e}"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[cfg(unix)] +#[test] +fn a_destination_without_write_permission_reports_an_error_and_keeps_everything() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("move-perm"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("ro")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("ro/probe"), "x").is_ok(); + if !root { + assert!(s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("ro")})).is_err()); + assert!(dir.0.join("in/a.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert!(s.execute("folder.create", &json!({"parent": dir.path("ro"), "name": "x"})).is_err()); + } + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o755)).unwrap(); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 670039ad7..7aff5fd44 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -462,14 +462,14 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let dir = s.media.smart_dir.clone().unwrap(); - let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); { let _fault = lightcraft_catalog::safe_file::fail_writes_after(64); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}"); } assert!(!file.exists(), "no partial proxy"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no temp file left"); + assert_eq!(std::fs::read_dir(file.parent().unwrap()).unwrap().count(), 0, "no temp file left in shard"); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); @@ -795,6 +795,13 @@ fn smart_previews_folder_is_chosen_per_library() { assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1); + // A flat proxy from older builds remains discoverable and migrates to the new sharded layout. + let dir = lib.join("Smart Previews"); + let photo = s.catalog.photo(id).unwrap(); + let shard = crate::smart::sharded_file_path(&dir, photo); + let flat = dir.join(crate::smart::file_name(photo)); + std::fs::rename(&shard, &flat).unwrap(); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true); // relative paths and missing drives are refused, and nothing is created on this drive assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err()); @@ -870,8 +877,8 @@ fn missing_photos_skip_local_browse_records() { assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } -/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the -/// report says it is in the trash; `onDeleted: restore` brings it back with its edits; +/// Import a file that is in Recently Deleted (issues #298, #706): by default the photo comes +/// back with its edits; `onDeleted: skip` leaves it there but the report says it is in the trash; /// `onDeleted: fresh` replaces the trashed record with a new one. fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { let src = temp_dir(tag); @@ -884,14 +891,115 @@ fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { (s, src, id) } +/// Issue #706: import a photo, delete it, import the same file again. Before, the import skipped +/// it as "already in the library" (a duplicate by path) while the grid showed it deleted, and the +/// user was stuck. Now the photo is restored: it is back in All Photos, with its edits. +#[test] +fn importing_a_deleted_photo_again_brings_it_back() { + let (mut s, src, id) = trashed_photo("trash-reimport"); + assert_eq!(s.visible_cloned().len(), 0, "deleted: not in All Photos"); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "not reported as a duplicate: {r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4, "back with its edits"); + assert_eq!(s.catalog.len(), 1, "no second record"); + assert_eq!(s.visible_cloned(), vec![lightcraft_catalog::PhotoId(id)], "shown again in All Photos"); + // undo puts it back in Recently Deleted + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted); + let _ = std::fs::remove_dir_all(&src); +} + +/// The same sequence with the library on disk and a restart between the delete and the import: +/// the trashed record is read back from the catalog and restored, not refused. +#[test] +fn importing_a_deleted_photo_again_after_reopening_the_library_brings_it_back() { + let src = temp_dir("trash-reimport-reopen"); + write_png(&src.join("a.png"), 1); + let lib = src.join("library"); + let id = { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()], "albumName": "Trip"})).unwrap(); + let id = r["imported"][0].as_u64().unwrap(); + s.execute("photo.rate", &json!({"ids": [id], "rating": 2})).unwrap(); + s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); + s.close_library().unwrap(); + id + }; + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "deleted state persisted"); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 2, "restored with its edits"); + let album = s.catalog.albums().find(|a| a.name == "Trip").unwrap(); + assert_eq!(album.photos, vec![lightcraft_catalog::PhotoId(id)], "still in its album"); + assert_eq!(s.visible_cloned().len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +/// A deleted photo whose file was moved meanwhile: importing the file from its new place restores +/// the photo and points it at the file (matched by content), instead of bringing back a record +/// whose file is missing. +#[test] +fn importing_a_deleted_photo_from_its_new_place_restores_and_relinks_it() { + let (mut s, src, id) = trashed_photo("trash-reimport-moved"); + std::fs::create_dir_all(src.join("moved")).unwrap(); + std::fs::rename(src.join("a.png"), src.join("moved/b.png")).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("moved/b.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4); + assert_eq!(p.source, lightcraft_catalog::Source::File { path: src.join("moved/b.png").to_string_lossy().to_string() }); + assert_eq!(p.file_name, "b.png"); + assert_eq!(s.catalog.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +/// Only an import brings a deleted photo back. Looking at its folder in Local, and Auto Import +/// seeing its file again in the watched folder (a copy: the source is not a path the library +/// knows), leave it in Recently Deleted. +#[test] +fn browsing_and_auto_import_never_restore_a_deleted_photo() { + let (mut s, src, id) = trashed_photo("trash-no-silent-restore"); + let r = s.execute("library.browse", &json!({"path": src.to_string_lossy()})).unwrap(); + assert_eq!(r["new"], 0, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "browsing only looks"); + assert_eq!(s.catalog.len(), 1); + // auto import in copy mode: the trashed photo's copy is in the library's folder, so the watched + // folder's file is new by path and a duplicate by content + let lib = src.join("library"); + let watched = src.join("watched"); + write_png(&watched.join("w.png"), 7); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [watched.join("w.png").to_string_lossy()], "mode": "copy"})).unwrap(); + let copied = r["imported"][0].as_u64().unwrap(); + s.execute("photo.delete", &json!({"ids": [copied]})).unwrap(); + s.execute("library.autoImport", &json!({"folder": watched.to_string_lossy(), "copy": true})).unwrap(); + for _ in 0..3 { + let r = s.execute("library.autoImportScan", &json!({})).unwrap(); + assert_eq!(r["imported"], json!([]), "{r}"); + } + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(copied)).unwrap().deleted, "still deleted"); + assert_eq!(s.catalog.len(), 1, "no second copy"); + let _ = std::fs::remove_dir_all(&src); +} + +/// `onDeleted: skip` keeps the old behaviour for callers that want it: nothing imported, the +/// duplicate reported with `existingDeleted`. #[test] fn duplicate_in_recently_deleted_is_reported_as_such() { let (mut s, src, id) = trashed_photo("trash-report"); - let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "skip"})).unwrap(); assert_eq!(ids(&r, "imported"), 0, "{r}"); assert_eq!(r["duplicates"][0]["existing"], id, "{r}"); assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}"); - assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "skip: left in the trash"); let _ = std::fs::remove_dir_all(&src); } @@ -1199,8 +1307,8 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); let dir = s.media.smart_dir.clone().unwrap(); - let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap())); - let png_file = dir.join(crate::smart::file_name(s.catalog.photo(png).unwrap())); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); let (old, new) = (test_tone(1.5), test_tone(3.5)); let calls = std::sync::Arc::new(AtomicUsize::new(0)); let plain = s.media.file_loader.clone().unwrap(); @@ -1387,3 +1495,74 @@ fn heic_imports_as_a_normal_photo_or_says_why_not() { assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC"); let _ = std::fs::remove_dir_all(&src); } + +/// Issue #705: Nikon (and most cameras) write `.JPG` and `.NEF`, and a user may rename to `.Jpg`. +/// The folder scan, `library.browse` and the sidecar lookup match extensions in any letter +/// case. (The file dialog's filters, the one place that was case-sensitive on Linux before +/// #342, are checked in `apps/lightcraft/src/dialog_filter.rs`.) +#[test] +fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup() { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let src = temp_dir("upper-exts"); + let jpeg = |seed: u8| { + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, seed, 200]).collect(); + encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap() + }; + std::fs::write(src.join("a.JPG"), jpeg(1)).unwrap(); + std::fs::write(src.join("b.Jpg"), jpeg(2)).unwrap(); + std::fs::write(src.join("c.JPEG"), jpeg(3)).unwrap(); + write_png(&src.join("d.PNG"), 4); + // a raw container LightCraft shows by its embedded JPEG (a TIFF whose IFD0 points at one) + let nef = { + let j = jpeg(5); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&j); + f + }; + std::fs::write(src.join("e.NEF"), nef).unwrap(); + std::fs::write(src.join("notes.TXT"), "not a photo").unwrap(); + // sidecars: stem-named with an upper-case extension, and full-named beside a mixed-case photo + let sidecar = |rating: u8| { + format!( + r#" +"# + ) + }; + std::fs::write(src.join("a.XMP"), sidecar(4)).unwrap(); + std::fs::write(src.join("b.Jpg.xmp"), sidecar(2)).unwrap(); + + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["scanned"], 5, "{r}"); // the five photos; notes.TXT is not one + assert_eq!(ids(&r, "imported"), 5, "{r}"); + assert_eq!(ids(&r, "failed"), 0, "{r}"); + assert_eq!(r["sidecars"], 2, "{r}"); + let names: Vec<(String, String, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.format.clone(), p.rating)).collect(); + let by_name = |n: &str| names.iter().find(|(f, _, _)| f == n).cloned().unwrap_or_else(|| panic!("{n} missing from {names:?}")); + assert_eq!(by_name("a.JPG").2, 4, "a.XMP read"); + assert_eq!(by_name("b.Jpg").2, 2, "b.Jpg.xmp read"); + assert_eq!(by_name("c.JPEG").1, "JPEG"); + assert_eq!(by_name("d.PNG").1, "PNG"); + let nef = s.catalog.photos().find(|p| p.file_name == "e.NEF").unwrap(); + assert!(nef.kind == lightcraft_catalog::MediaKind::Raw && nef.preview_only.is_some(), "{nef:?}"); + + // Browse Folder (the Local section) lists the same five, without adding them to the library + let mut b = Session::new().with_fs(); + let r = b.execute("library.browse", &json!({"path": src.to_string_lossy()})).unwrap(); + assert_eq!(r["photos"], 5, "{r}"); + assert_eq!(r["new"], 5, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + let visible = b.visible_cloned(); + let shown: Vec = visible.iter().filter_map(|id| b.catalog.photo(*id).map(|p| p.file_name.clone())).collect(); + for n in ["a.JPG", "b.Jpg", "c.JPEG", "d.PNG", "e.NEF"] { + assert!(shown.iter().any(|f| f == n), "{n} not shown: {shown:?}"); + } + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_lightroom_folder.rs b/crates/engine/src/tests_lightroom_folder.rs new file mode 100644 index 000000000..cdf9905c4 --- /dev/null +++ b/crates/engine/src/tests_lightroom_folder.rs @@ -0,0 +1,174 @@ +//! Issue #891: Open Library given a Lightroom Classic catalog (its folder, `.lrdata` / +//! `.lrcat-data` folders, the `.lrcat` or its companion files) refuses it, creating nothing, and +//! names the catalog to import; a LightCraft library still opens, whatever else its folder holds. + +use std::path::{Path, PathBuf}; + +use crate::lightroom_folder::{LightroomPlace, SCAN_LIMIT, lightroom_catalog_at}; +use crate::{EngineError, Session}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-engine-lrfolder-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A Lightroom catalog folder as Lightroom Classic leaves it (fake contents: only names count). +fn lightroom_folder(root: &Path) -> PathBuf { + let dir = root.join("Lightroom"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Lightroom Catalog.lrcat"), b"not really SQLite").unwrap(); + for f in ["Lightroom Catalog.lrcat-wal", "Lightroom Catalog.lrcat-shm", "Lightroom Catalog.lrcat.lock"] { + std::fs::write(dir.join(f), b"").unwrap(); + } + for d in [ + "Lightroom Catalog Previews.lrdata", + "Lightroom Catalog Smart Previews.lrdata", + "Lightroom Catalog Helper.lrdata", + "Lightroom Catalog.lrcat-data", + "Backups", + ] { + std::fs::create_dir_all(dir.join(d)).unwrap(); + } + dir +} + +fn names(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + v.sort(); + v +} + +/// Refused with the catalog named, and nothing created or changed anywhere in `root`. +fn refused(root: &Path, path: &Path, catalog: &Path) { + let before = names(&root.join("Lightroom")); + let mut s = Session::new(); + match s.open_library(path, true) { + Err(EngineError::LightroomCatalog { path: p, catalog: c }) => { + assert_eq!(p, path); + assert_eq!(c.as_deref(), Some(catalog), "{}", path.display()); + } + other => panic!("{}: {other:?}", path.display()), + } + assert!(s.library.is_none()); + assert_eq!(names(&root.join("Lightroom")), before, "nothing written next to the catalog"); + if path.is_dir() { + assert!(!path.join("catalog.snap").exists() && !path.join("catalog.lock").exists(), "no library created in {}", path.display()); + } +} + +#[test] +fn each_part_of_a_lightroom_catalog_is_refused() { + let root = temp_dir("parts"); + let dir = lightroom_folder(&root); + let catalog = dir.join("Lightroom Catalog.lrcat"); + for p in [ + dir.clone(), + catalog.clone(), + dir.join("Lightroom Catalog Previews.lrdata"), + dir.join("Lightroom Catalog Smart Previews.lrdata"), + dir.join("Lightroom Catalog Helper.lrdata"), + dir.join("Lightroom Catalog.lrcat-data"), + dir.join("Lightroom Catalog.lrcat-wal"), + dir.join("Lightroom Catalog.lrcat-shm"), + dir.join("Lightroom Catalog.lrcat.lock"), + dir.join("Backups"), + ] { + refused(&root, &p, &catalog); + } + let e = Session::new().open_library(&dir, false).unwrap_err().to_string(); + assert!(e.contains("Lightroom Classic catalog, not a LightCraft library") && e.contains("Import Lightroom Catalog"), "{e}"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn an_lrdata_folder_whose_catalog_was_renamed_finds_the_one_next_to_it() { + let root = temp_dir("renamed"); + let dir = root.join("Lightroom"); + std::fs::create_dir_all(dir.join("Old Name Previews.lrdata")).unwrap(); + std::fs::write(dir.join("New Name.lrcat"), b"x").unwrap(); + refused(&root, &dir.join("Old Name Previews.lrdata"), &dir.join("New Name.lrcat")); + // no catalog anywhere: still refused (it is Lightroom's data), with nothing to import + let lone = root.join("lone"); + std::fs::create_dir_all(lone.join("Gone Previews.lrdata")).unwrap(); + assert_eq!(lightroom_catalog_at(&lone.join("Gone Previews.lrdata")), Some(LightroomPlace { catalog: None })); + assert_eq!(lightroom_catalog_at(&lone.join("Gone.lrcat")), Some(LightroomPlace { catalog: None }), "a missing .lrcat path"); + assert!(!lone.join("Gone.lrcat").exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn an_empty_folder_still_becomes_a_library() { + let root = temp_dir("empty"); + let lib = root.join("New Library"); + assert_eq!(lightroom_catalog_at(&lib), None, "not there yet"); + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + assert!(s.library.is_some()); + // a folder with other files (and a folder named like a catalog, which is not one) too + let other = root.join("Other"); + std::fs::create_dir_all(other.join("weird.lrcat")).unwrap(); + std::fs::write(other.join("notes.txt"), b"x").unwrap(); + assert_eq!(lightroom_catalog_at(&other), None); + let _ = std::fs::remove_dir_all(&root); +} + +/// Precedence: a LightCraft library (its catalog journal) opens, even with an `.lrcat` beside it. +#[test] +fn a_lightcraft_library_with_a_catalog_inside_still_opens() { + let root = temp_dir("both"); + let lib = root.join("Lightroom"); + { + let mut s = Session::new(); + s.open_library(&lib, true).unwrap(); + assert!(!s.catalog.is_empty()); + s.close_library().unwrap(); + } + std::fs::write(lib.join("Lightroom Catalog.lrcat"), b"x").unwrap(); + std::fs::create_dir_all(lib.join("Lightroom Catalog Previews.lrdata")).unwrap(); + assert_eq!(lightroom_catalog_at(&lib), None); + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + assert!(!s.catalog.is_empty(), "the LightCraft library opened"); + let _ = std::fs::remove_dir_all(&root); +} + +/// Never crash, never scan without end: a huge folder, unreadable paths, odd names. +#[test] +fn odd_folders_never_fail_the_check() { + let root = temp_dir("odd"); + // the catalog after the scan limit isn't looked for (sorted names put it last on most file systems; either answer is fine, the check returns) + let big = root.join("big"); + std::fs::create_dir_all(&big).unwrap(); + for i in 0..SCAN_LIMIT + 10 { + std::fs::write(big.join(format!("{i:06}.jpg")), b"").unwrap(); + } + let _ = lightroom_catalog_at(&big); + assert_eq!(lightroom_catalog_at(&root.join("missing/deeper")), None); + assert_eq!(lightroom_catalog_at(Path::new("")), None); + assert_eq!(lightroom_catalog_at(Path::new("/")), None); + assert!(lightroom_catalog_at(Path::new("x.LRCAT")).is_some(), "case-insensitive"); + // a one-letter name with a multibyte character never cuts inside it + let _ = lightroom_catalog_at(&root.join("é")); + let _ = lightroom_catalog_at(&root.join("ataé-data")); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata")); + std::fs::create_dir_all(&bad).unwrap(); + std::fs::write(root.join("Cat.lrcat"), b"x").unwrap(); + assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name"); + let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe")); + std::fs::create_dir_all(&lr).unwrap(); + std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap(); + std::fs::write(lr.join("b.lrcat"), b"x").unwrap(); + assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped"); + // a symlink to a catalog folder is one too; a dangling one is not + std::os::unix::fs::symlink(&lr, root.join("link")).unwrap(); + assert!(lightroom_catalog_at(&root.join("link")).is_some()); + std::os::unix::fs::symlink(root.join("nowhere"), root.join("dangling")).unwrap(); + assert_eq!(lightroom_catalog_at(&root.join("dangling")), None); + } + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs index 16bfe52b9..25fd482b7 100644 --- a/crates/engine/src/tests_spots.rs +++ b/crates/engine/src/tests_spots.rs @@ -55,3 +55,13 @@ fn spots_select_move_edit_refresh_and_delete() { s.execute("library.next", &json!({})).unwrap(); assert_eq!(s.active_spot, None); } + +#[test] +fn a_content_aware_spot_has_no_source() { + let mut s = Session::with_demo(); + s.execute("spot.add", &json!({"mode": "fill", "points": [[0.4, 0.5]], "size": 0.03})).unwrap(); + let sp = &spots(&s)[0]; + assert_eq!((sp.mode, sp.source_offset), (lightcraft_develop::SpotMode::Fill, None)); + assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill"); + assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err()); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index e3137da00..57981d920 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -566,6 +566,45 @@ fn external_edit_copy_is_stacked() { let _ = std::fs::remove_dir_all(&dir); } +/// Edit in PhotoCraft (engine half): a flat 16-bit PSD `-Edit` copy, which PhotoCraft saves back +/// to in place (it writes a TIFF only through Save As); its saved pixels come back on Reload. +#[test] +fn external_edit_copy_as_psd_round_trips() { + let dir = temp_dir("extedit-psd"); + let src = dir.join("pier.png"); + write_png(&src, 40); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let orig = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with("pier-Edit.psd"), "{path}"); + let d = lightcraft_codecs::decode(&std::fs::read(&path).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16); + assert!(d.icc.is_some(), "the colour space travels with the file"); + let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); + // the editor saves the document back over the same file: Reload shows its pixels + let before = s.render_now(new, 16, 16).unwrap().image.data; + let (w, h) = (d.width as u32, d.height as u32); + let px = vec![1000u16; (w * h * 3) as usize]; + let saved = + lightcraft_codecs::encode_psd(&lightcraft_codecs::EncodeImage::new(w, h, 3, lightcraft_codecs::Samples::U16(&px)), &Default::default()) + .unwrap(); + std::fs::write(&path, saved).unwrap(); + assert_eq!(s.execute("photo.reload", &json!({"ids": [new.0]})).unwrap()["reloaded"], json!([new.0])); + assert_ne!(s.render_now(new, 16, 16).unwrap().image.data, before); + // 8-bit, and what it can't do + s.execute("library.select", &json!({"ids": [orig.0]})).unwrap(); + let r8 = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 8})).unwrap(); + let d8 = lightcraft_codecs::decode(&std::fs::read(r8["path"].as_str().unwrap()).unwrap(), Default::default()).unwrap(); + assert_eq!(d8.bit_depth, 8); + assert!(s.execute("photo.editExternal", &json!({"format": "jpeg"})).is_err()); + assert!(s.execute("photo.editExternal", &json!({"bitDepth": 32})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs index 4c22789b1..12974247f 100644 --- a/crates/engine/src/text.rs +++ b/crates/engine/src/text.rs @@ -178,6 +178,24 @@ impl TextLayout { /// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h` /// (so huge text costs what shows, not what is laid out). + /// Call `f(x, y, coverage)` for every pixel the text covers, in unrounded coordinates relative + /// to the box's top-left corner (for callers that transform them, e.g. a turned watermark). + pub fn draw_points(&self, mut f: impl FnMut(f32, f32, f32)) { + for (face, g, rotation) in &self.glyphs { + if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { + let b = o.px_bounds(); + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + f(sx, sy, cov); + }); + } + } + } + pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) { for (face, g, rotation) in &self.glyphs { if rotation.is_none() { diff --git a/crates/geocode/Cargo.toml b/crates/geocode/Cargo.toml new file mode 100644 index 000000000..0d30af762 --- /dev/null +++ b/crates/geocode/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lightcraft-geocode" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft offline reverse geocoding: GPS position to sublocation / city / state / country from a GeoNames dump (L1)" + +[lints] +workspace = true + +[dependencies] +miniz_oxide = { workspace = true } +serde = { workspace = true } diff --git a/crates/geocode/README.md b/crates/geocode/README.md new file mode 100644 index 000000000..9e0376b0c --- /dev/null +++ b/crates/geocode/README.md @@ -0,0 +1,17 @@ +# lightcraft-geocode (L1) + +Offline reverse geocoding: a GPS position becomes Sublocation / City / State / Country. + +- **Data:** the GeoNames dump (), licensed CC-BY 4.0 + (attribution: "GeoNames, geonames.org"). Nothing is bundled: the engine + (`metadata.downloadPlaceNames`) downloads `cities15000.zip`, `admin1CodesASCII.txt` and + `countryInfo.txt` on the user's request into the settings folder. This crate only parses and + searches; it never touches the network. +- **Lookup:** the nearest populated place within `MAX_KM` (great-circle distance), searched in the + 1° cells around the position, so one lookup looks at a few dozen places, not all of them. +- **unzip:** just enough ZIP to read one file out of `cities15000.zip` (stored or deflated). +- **Never panics on hostile input:** bad lines are skipped, NaN / out-of-range positions find + nothing, sizes and counts are capped. + +Sources: GeoNames dump readme (file layout, prose); haversine distance (textbook); PKWARE APPNOTE +(ZIP, prose spec); the cell index is own design. diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs new file mode 100644 index 000000000..3acb6b5f6 --- /dev/null +++ b/crates/geocode/src/lib.rs @@ -0,0 +1,273 @@ +//! Offline reverse geocoding: a GPS position → Sublocation / City / State / Country, from the +//! GeoNames dump (CC-BY 4.0, "GeoNames, geonames.org"). See README.md. +//! +//! Sources: GeoNames dump readme (prose); haversine (textbook); cell index own design. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod unzip; + +use std::collections::HashMap; +use std::path::Path; + +use serde::Serialize; + +/// Lookups farther than this from any place find nothing. +pub const MAX_KM: f64 = 50.0; +/// The files the engine downloads (the first comes zipped as `cities15000.zip`). +pub const GEONAMES_FILES: [&str; 3] = ["cities15000.txt", "admin1CodesASCII.txt", "countryInfo.txt"]; +/// The zip holding [`GEONAMES_FILES`]`[0]`. +pub const GEONAMES_ZIP: &str = "cities15000.zip"; +pub const GEONAMES_BASE: &str = "https://download.geonames.org/export/dump/"; +pub const GEONAMES_ATTRIBUTION: &str = "Place names: GeoNames (geonames.org), CC-BY 4.0"; +/// Places kept from a dataset (cities15000 has ~33 000; cities500 ~200 000). +const MAX_PLACES: usize = 2_000_000; +/// Largest name kept (GeoNames names are short; anything longer is junk). +const MAX_NAME: usize = 200; +const EARTH_RADIUS_M: f64 = 6_371_008.8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeocodeError { + /// The place names aren't there (not downloaded yet) or can't be read. + Missing(String), + /// The data is not what it should be. + Invalid(String), +} + +impl std::fmt::Display for GeocodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GeocodeError::Missing(e) | GeocodeError::Invalid(e) => f.write_str(e), + } + } +} + +impl std::error::Error for GeocodeError {} + +/// A position in degrees. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LatLon { + pub lat: f64, + pub lon: f64, +} + +impl LatLon { + pub fn new(lat: f64, lon: f64) -> LatLon { + LatLon { lat, lon } + } + + /// Finite and on the globe. + pub fn is_valid(self) -> bool { + self.lat.is_finite() && self.lon.is_finite() && self.lat.abs() <= 90.0 && self.lon.abs() <= 180.0 + } +} + +/// Great-circle distance in metres (haversine). NaN in, NaN out. +pub fn distance_m(a: LatLon, b: LatLon) -> f64 { + let (p1, p2) = (a.lat.to_radians(), b.lat.to_radians()); + let dp = p2 - p1; + let dl = (b.lon - a.lon).to_radians(); + let h = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin() +} + +/// What a lookup fills in (empty strings: unknown). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct Place { + /// Below city level. GeoNames' city list has none, so offline lookups leave it empty. + pub sublocation: String, + pub city: String, + pub state: String, + pub country: String, + /// ISO 3166-1 alpha-2, upper case. + pub iso: String, + /// Metres from the position to the place. + pub distance: f64, +} + +#[derive(Clone, Debug)] +struct City { + name: String, + at: LatLon, + country: String, + admin1: String, +} + +/// An offline GeoNames index: places bucketed by 1° cells. +#[derive(Clone, Debug, Default)] +pub struct GeoNames { + cities: Vec, + cells: HashMap<(i32, i32), Vec>, + admin1: HashMap, + countries: HashMap, +} + +fn cell(p: LatLon) -> (i32, i32) { + // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range + (p.lat.floor() as i32, p.lon.floor() as i32) +} + +fn short(s: &str) -> String { + s.trim().chars().take(MAX_NAME).collect() +} + +impl GeoNames { + /// From the dump's text: `cities` (tab-separated, the `cities*.txt` layout), and optionally + /// `admin1CodesASCII.txt` and `countryInfo.txt`. Bad lines are skipped. + pub fn parse(cities: &str, admin1: Option<&str>, countries: Option<&str>) -> GeoNames { + let mut g = GeoNames::default(); + for line in cities.lines() { + if g.cities.len() >= MAX_PLACES { + break; + } + let f: Vec<&str> = line.split('\t').collect(); + // columns: 1 name, 4 latitude, 5 longitude, 8 country code, 10 admin1 code + let (Some(name), Some(lat), Some(lon), Some(cc)) = (f.get(1), f.get(4), f.get(5), f.get(8)) else { continue }; + let (Ok(lat), Ok(lon)) = (lat.trim().parse::(), lon.trim().parse::()) else { continue }; + let at = LatLon::new(lat, lon); + let name = short(name); + if !at.is_valid() || name.is_empty() { + continue; + } + let Ok(i) = u32::try_from(g.cities.len()) else { break }; + g.cells.entry(cell(at)).or_default().push(i); + g.cities.push(City { name, at, country: short(cc).to_ascii_uppercase(), admin1: f.get(10).map(|s| short(s)).unwrap_or_default() }); + } + for line in admin1.unwrap_or("").lines() { + let mut f = line.split('\t'); + if let (Some(code), Some(name)) = (f.next(), f.next()) { + g.admin1.insert(short(code), short(name)); + } + } + for line in countries.unwrap_or("").lines().filter(|l| !l.starts_with('#')) { + let f: Vec<&str> = line.split('\t').collect(); + if let (Some(iso), Some(name)) = (f.first(), f.get(4)) { + g.countries.insert(short(iso).to_ascii_uppercase(), short(name)); + } + } + g + } + + /// Load from a folder holding [`GEONAMES_FILES`] (the last two are optional: without them + /// states stay blank and countries are ISO codes). + pub fn load(dir: &Path) -> Result { + let read = |n: &str| std::fs::read(dir.join(n)).map(|b| String::from_utf8_lossy(&b).into_owned()); + let cities = read(GEONAMES_FILES[0]).map_err(|e| GeocodeError::Missing(format!("the place names aren't downloaded ({e})")))?; + let g = GeoNames::parse(&cities, read(GEONAMES_FILES[1]).ok().as_deref(), read(GEONAMES_FILES[2]).ok().as_deref()); + if g.is_empty() { + return Err(GeocodeError::Invalid("the place names file holds no places".into())); + } + Ok(g) + } + + pub fn len(&self) -> usize { + self.cities.len() + } + + pub fn is_empty(&self) -> bool { + self.cities.is_empty() + } + + /// The nearest place within [`MAX_KM`]; `None` for an invalid position or open country. + pub fn reverse(&self, p: LatLon) -> Option { + if !p.is_valid() { + return None; + } + let (cy, cx) = cell(p); + let mut best: Option<(f64, &City)> = None; + // ±1° covers 50 km everywhere except very near the poles (where there are no cities) + for dy in -1..=1 { + for dx in -1..=1 { + let mut x = cx + dx; + if x < -180 { + x += 360; + } else if x >= 180 { + x -= 360; + } + for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() { + let Some(c) = self.cities.get(i as usize) else { continue }; + let d = distance_m(p, c.at); + if best.is_none_or(|(b, _)| d < b) { + best = Some((d, c)); + } + } + } + } + let (d, c) = best?; + (d <= MAX_KM * 1000.0).then(|| Place { + sublocation: String::new(), + city: c.name.clone(), + state: self.admin1.get(&format!("{}.{}", c.country, c.admin1)).cloned().unwrap_or_default(), + country: self.countries.get(&c.country).cloned().unwrap_or_else(|| c.country.clone()), + iso: c.country.clone(), + distance: d.round(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\ + 2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n\ + bad line\n\ + 1\tNowhere\tx\t\tNaN\t0\tP\tP\tXX\n\ + 2\tFar\tx\t\t500\t0\tP\tP\tXX\n\ + 3\tInf\tx\t\tinf\t-inf\tP\tP\tXX\n"; + + #[test] + fn nearest_place() { + let g = GeoNames::parse(CITIES, Some("FR.11\tÎle-de-France\nGB.ENG\tEngland\n"), Some("#ISO\tx\nFR\tFRA\t250\tFR\tFrance\n")); + assert_eq!(g.len(), 2); + let p = g.reverse(LatLon::new(48.86, 2.34)).unwrap(); + assert_eq!((p.city.as_str(), p.state.as_str(), p.country.as_str(), p.iso.as_str()), ("Paris", "Île-de-France", "France", "FR")); + assert!(p.distance > 0.0 && p.distance < 2000.0); + // no countryInfo row: the ISO code stands in + let l = g.reverse(LatLon::new(51.5, -0.1)).unwrap(); + assert_eq!((l.city.as_str(), l.state.as_str(), l.country.as_str()), ("London", "England", "GB")); + // open sea + assert!(g.reverse(LatLon::new(0.0, 0.0)).is_none()); + } + + #[test] + fn hostile_positions_find_nothing() { + let g = GeoNames::parse(CITIES, None, None); + for (la, lo) in [(f64::NAN, 0.0), (0.0, f64::NAN), (f64::INFINITY, 0.0), (91.0, 0.0), (0.0, -180.5), (-1e300, 1e300)] { + assert!(g.reverse(LatLon::new(la, lo)).is_none(), "{la}, {lo}"); + } + // the poles and the antimeridian are fine + assert!(g.reverse(LatLon::new(90.0, 180.0)).is_none()); + assert!(g.reverse(LatLon::new(-90.0, -180.0)).is_none()); + assert!(GeoNames::default().reverse(LatLon::new(48.86, 2.34)).is_none()); + } + + #[test] + fn across_the_antimeridian() { + let fiji = GeoNames::parse("1\tX\tX\t\t-17.0\t179.9\tP\tP\tFJ\t\t\n", None, None); + assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); + } + + #[test] + fn junk_text_parses_to_nothing() { + let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x")); + assert!(g.is_empty()); + let long = format!("1\t{}\tx\t\t1\t1\tP\tP\tXX\n", "a".repeat(10_000)); + assert_eq!(GeoNames::parse(&long, None, None).reverse(LatLon::new(1.0, 1.0)).unwrap().city.len(), MAX_NAME); + } + + #[test] + fn load_from_a_folder() { + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-load-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Missing(_)))); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[0]), "junk").unwrap(); + assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Invalid(_)))); + std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[2]), "GB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap(); + let g = GeoNames::load(&dir).unwrap(); + assert_eq!(g.reverse(LatLon::new(51.5, -0.1)).unwrap().country, "United Kingdom"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/geocode/src/unzip.rs b/crates/geocode/src/unzip.rs new file mode 100644 index 000000000..f78648d83 --- /dev/null +++ b/crates/geocode/src/unzip.rs @@ -0,0 +1,123 @@ +//! Just enough ZIP to read one file out of a GeoNames dump (`cities15000.zip`): the central +//! directory, stored or deflated entries, sizes capped. +//! +//! Sources: PKWARE APPNOTE (prose spec); own design. + +use crate::GeocodeError; + +fn u16_at(b: &[u8], i: usize) -> Option { + let s = b.get(i..i.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], i: usize) -> Option { + let s = b.get(i..i.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +fn bad(what: &str) -> GeocodeError { + GeocodeError::Invalid(format!("not a readable ZIP archive ({what})")) +} + +/// The contents of the entry whose name ends with `name`, at most `max` bytes. +pub fn extract(zip: &[u8], name: &str, max: usize) -> Result, GeocodeError> { + // end of central directory: the last "PK\x05\x06" within the final 64 KiB + 22 bytes + let from = zip.len().saturating_sub(65_557); + let eocd = (from..zip.len().saturating_sub(21)) + .rev() + .find(|&i| zip.get(i..i.saturating_add(4)) == Some(b"PK\x05\x06")) + .ok_or_else(|| bad("no directory"))?; + let field16 = |at: usize, what: &str| u16_at(zip, at).map(usize::from).ok_or_else(|| bad(what)); + let field32 = |at: usize, what: &str| u32_at(zip, at).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(what)); + let count = field16(eocd.saturating_add(10), "directory")?; + let mut p = field32(eocd.saturating_add(16), "directory")?; + for _ in 0..count { + if zip.get(p..p.saturating_add(4)) != Some(b"PK\x01\x02") { + return Err(bad("directory entry")); + } + let method = field16(p.saturating_add(10), "entry")?; + let csize = field32(p.saturating_add(20), "entry")?; + let usize_ = field32(p.saturating_add(24), "entry")?; + let nlen = field16(p.saturating_add(28), "entry")?; + let xlen = field16(p.saturating_add(30), "entry")?; + let clen = field16(p.saturating_add(32), "entry")?; + let local = field32(p.saturating_add(42), "entry")?; + let name_at = p.saturating_add(46); + let entry = zip.get(name_at..name_at.saturating_add(nlen)).ok_or_else(|| bad("entry name"))?; + p = name_at.saturating_add(nlen).saturating_add(xlen).saturating_add(clen); + if !String::from_utf8_lossy(entry).ends_with(name) { + continue; + } + if usize_ > max { + return Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))); + } + if zip.get(local..local.saturating_add(4)) != Some(b"PK\x03\x04") { + return Err(bad("local header")); + } + let lnlen = field16(local.saturating_add(26), "local header")?; + let lxlen = field16(local.saturating_add(28), "local header")?; + let start = local.saturating_add(30).saturating_add(lnlen).saturating_add(lxlen); + let data = zip.get(start..start.saturating_add(csize)).ok_or_else(|| bad("truncated"))?; + return match method { + 0 if data.len() <= max => Ok(data.to_vec()), + 0 => Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))), + 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}"))), + m => Err(bad(&format!("compression method {m}"))), + }; + } + Err(GeocodeError::Invalid(format!("{name} is not in the archive"))) +} + +/// A one-file deflated ZIP (test fixtures: the engine's download test serves one). +#[doc(hidden)] +pub fn make(name: &str, data: &[u8]) -> Vec { + let c = miniz_oxide::deflate::compress_to_vec(data, 6); + let mut z = Vec::new(); + z.extend_from_slice(b"PK\x03\x04"); + z.extend_from_slice(&[20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(c.len() as u32).to_le_bytes()); + z.extend_from_slice(&(data.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&[0, 0]); + z.extend_from_slice(name.as_bytes()); + z.extend_from_slice(&c); + let cd = z.len(); + z.extend_from_slice(b"PK\x01\x02"); + z.extend_from_slice(&[20, 0, 20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(c.len() as u32).to_le_bytes()); + z.extend_from_slice(&(data.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&[0; 12]); + z.extend_from_slice(&0u32.to_le_bytes()); + z.extend_from_slice(name.as_bytes()); + let cdlen = z.len() - cd; + z.extend_from_slice(b"PK\x05\x06"); + z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]); + z.extend_from_slice(&(cdlen as u32).to_le_bytes()); + z.extend_from_slice(&(cd as u32).to_le_bytes()); + z.extend_from_slice(&[0, 0]); + z +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_junk() { + let z = make("cities15000.txt", b"hello world"); + assert_eq!(extract(&z, "cities15000.txt", 100).unwrap(), b"hello world"); + assert!(extract(&z, "cities15000.txt", 5).is_err()); + assert!(extract(&z, "other.txt", 100).is_err()); + assert!(extract(b"PK", "x", 10).is_err()); + assert!(extract(b"", "x", 10).is_err()); + for cut in 0..z.len() { + let _ = extract(&z[..cut], "cities15000.txt", 100); + } + for i in 0..z.len() { + let mut bad = z.clone(); + bad[i] ^= 0xff; + let _ = extract(&bad, "cities15000.txt", 100); + } + } +} diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index a86fdb7e5..810e39cd4 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -79,6 +79,22 @@ impl CropGeometry { let _ = best; CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle } } + + /// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside + /// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's + /// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle, + /// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so + /// they all give the same crop (issue #742). + pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry { + let cand = CropGeometry { rect: self.rect, angle: angle_deg }; + if cand.is_within_image(w, h) { + return cand; + } + let r = self.rect_px(w, h); + // a degenerate rect has no aspect to keep: fall back to the image's own + let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0); + crop_fit_angle(w, h, angle_deg, aspect) + } } /// Largest scale `s` such that a centred `cw·s × ch·s` rectangle fits inside a `w × h` image rotated @@ -133,6 +149,38 @@ mod tests { assert!(k.is_within_image(300.0, 200.0)); } + #[test] + fn with_angle_keeps_a_crop_that_still_fits() { + // a small centred crop fits at a few degrees: the angle changes, the rect does not + let c = CropGeometry { rect: Rect::new(0.3, 0.3, 0.7, 0.7), angle: 0.0 }; + for ang in [-3.0, 0.5, 2.0, 5.0] { + let t = c.with_angle(ang, 600.0, 400.0); + assert_eq!(t.rect, c.rect, "{ang}°"); + assert_eq!(t.angle, ang); + } + } + + #[test] + fn with_angle_shrinks_a_crop_that_no_longer_fits_keeping_its_aspect() { + for (rect, ang) in [(Rect::UNIT, 15.0), (Rect::new(0.0, 0.1, 0.8, 0.9), -20.0), (Rect::new(0.2, 0.0, 0.8, 1.0), 45.0)] { + let c = CropGeometry { rect, angle: 0.0 }; + let t = c.with_angle(ang, 600.0, 400.0); + assert!(t.is_within_image(600.0, 400.0), "{rect:?} at {ang}°"); + let (a, b) = (c.rect_px(600.0, 400.0), t.rect_px(600.0, 400.0)); + assert!((a.aspect() - b.aspect()).abs() < 1e-9, "aspect kept: {} vs {}", a.aspect(), b.aspect()); + assert_eq!(t.angle, ang); + } + } + + #[test] + fn with_angle_survives_a_degenerate_crop() { + // zero height: no aspect to keep (width / height = ∞) + let c = CropGeometry { rect: Rect::new(0.0, 0.5, 1.0, 0.5), angle: 0.0 }; + let t = c.with_angle(30.0, 600.0, 400.0); + assert!(t.is_within_image(600.0, 400.0)); + assert!([t.rect.x0, t.rect.y0, t.rect.x1, t.rect.y1].iter().all(|v| v.is_finite())); + } + #[test] fn half_crop_maps_to_centre_quadrant() { let c = CropGeometry { rect: Rect::new(0.25, 0.25, 0.75, 0.75), angle: 0.0 }; diff --git a/crates/geom/src/real.rs b/crates/geom/src/real.rs index 07da59f34..dafa76fce 100644 --- a/crates/geom/src/real.rs +++ b/crates/geom/src/real.rs @@ -21,12 +21,23 @@ pub trait Real: { /// `if |v| < tiny { tiny } else { v }`: the guard of a denominator against zero ([`crate::Homography::apply`]). fn clamp_tiny(self, tiny: f64) -> Self; + /// The square root of a non-negative value (a negative one gives NaN, or for an [`Interval`] its lower bound + /// is taken as 0, since the value is a sum of squares). + fn sqrt_nonneg(self) -> Self; + /// `self` limited to `lo..=hi` (`lo <= hi`). + fn clamp_range(self, lo: f64, hi: f64) -> Self; } impl Real for f64 { fn clamp_tiny(self, tiny: f64) -> f64 { if self.abs() < tiny { tiny } else { self } } + fn sqrt_nonneg(self) -> f64 { + self.sqrt() + } + fn clamp_range(self, lo: f64, hi: f64) -> f64 { + if lo <= hi { self.clamp(lo, hi) } else { self } + } } /// A closed interval `[lo, hi]` with outward rounding, or *undecided* (both NaN). @@ -165,6 +176,23 @@ impl Real for Interval { fn clamp_tiny(self, tiny: f64) -> Interval { if self.lo >= tiny || self.hi <= -tiny { self } else { Interval::UNDECIDED } } + + /// `sqrt` is monotonic and correctly rounded, so one ulp outward encloses the `f64` result. + fn sqrt_nonneg(self) -> Interval { + if !self.is_decided() || self.hi < 0.0 { + return Interval::UNDECIDED; + } + Interval::outward(self.lo.max(0.0).sqrt(), self.hi.sqrt()) + } + + /// Clamping is monotonic and exact. + fn clamp_range(self, lo: f64, hi: f64) -> Interval { + let ordered = lo <= hi; + if !self.is_decided() || !ordered { + return Interval::UNDECIDED; + } + Interval::new(self.lo.clamp(lo, hi), self.hi.clamp(lo, hi)) + } } #[cfg(test)] @@ -201,6 +229,10 @@ mod tests { prop_assert!(k == 0.0 || contains(ia / k, a / k)); let c = ib.clamp_tiny(1.0); prop_assert!(!c.is_decided() || contains(c, b.clamp_tiny(1.0))); + let sq = (ia * ia).sqrt_nonneg(); + prop_assert!(!sq.is_decided() || contains(sq, (a * a).sqrt())); + let cl = ib.clamp_range(-1.0, 2.0); + prop_assert!(contains(cl, b.clamp_range(-1.0, 2.0))); } } diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 81bfe719e..57e47f22e 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -40,6 +40,8 @@ pub fn parse_backends(s: &str) -> Option { "" => None, "auto" | "default" => Some(BackendChoice::Auto), "off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off), + // the window's OpenGL renderer (see `WindowRenderer`): compute keeps its default + "glow" => Some(BackendChoice::Auto), _ => { let b = Backends::from_comma_list(&s); (!b.is_empty()).then_some(BackendChoice::Use(b)) @@ -170,29 +172,71 @@ pub(crate) fn env_off() -> bool { } static MARKER: Mutex> = Mutex::new(None); +/// Scopes of driver work (device creation, shader and pipeline compilation) running now, so +/// that overlapping ones (the window-side warm-up and a first denoise) share one marker file. +static ACTIVE: Mutex = Mutex::new(0); -/// Write this file before the compute device is created and remove it once creation returned -/// (successfully or not). A file still there at the next launch means the process died inside -/// the driver: see [`take_init_marker`]. `None` (the default) writes nothing. +/// Write this file before the compute device is created or any shader / pipeline is compiled +/// (the kernels, the denoise network's), and remove it once that returned (successfully or not). +/// A file still there at the next launch means the process died inside the driver — e.g. Apple's +/// Metal compiler (`MTLCompilerService`) aborting on a kernel, issue #250: see +/// [`take_init_marker`]. `None` (the default) writes nothing. pub fn set_init_marker(path: Option) { *MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path; } -/// Run device creation `f` between writing and removing the init marker (if one is set). -pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> T { - let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); - if let Some(m) = &marker { - if let Some(d) = m.parent() { +/// The marker's text: what started, and when. +fn marker_text(what: &str, secs: u64) -> String { + format!("GPU {what} started (unix time {secs})\n") +} + +/// Enter a driver-work scope on `marker`: the first one writes the file. +fn arm(marker: &std::path::Path, active: &Mutex, what: &str) { + let mut n = active.lock().unwrap_or_else(|e| e.into_inner()); + if *n == 0 { + if let Some(d) = marker.parent() { let _ = std::fs::create_dir_all(d); } let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let _ = std::fs::write(m, format!("GPU device creation started (backends {backends:?}, unix time {secs})\n")); + let _ = std::fs::write(marker, marker_text(what, secs)); } - let r = f(); + *n += 1; +} + +/// Leave a scope: the last one removes the file. +fn disarm(marker: &std::path::Path, active: &Mutex) { + let mut n = active.lock().unwrap_or_else(|e| e.into_inner()); + *n = n.saturating_sub(1); + if *n == 0 { + let _ = std::fs::remove_file(marker); + } +} + +/// Removes the marker on the way out, a panic included (the process survived). +struct Armed(Option); + +impl Drop for Armed { + fn drop(&mut self) { + if let Some(m) = &self.0 { + disarm(m, &ACTIVE); + } + } +} + +/// Run driver work `f` (`what`: "device creation", "kernel compilation"…) between writing and +/// removing the init marker (if one is set). +pub(crate) fn with_marker(what: &str, f: impl FnOnce() -> T) -> T { + let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); if let Some(m) = &marker { - let _ = std::fs::remove_file(m); + arm(m, &ACTIVE, what); } - r + let _armed = Armed(marker); + f() +} + +/// Run device creation (and the kernel build that follows it) `f` under the init marker. +pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> T { + with_marker(&format!("device and kernel creation (backends {backends:?})"), f) } /// If the init marker `path` is present — the last process died while creating the GPU device — @@ -211,6 +255,87 @@ pub fn read_init_marker(path: &std::path::Path) -> Option { Some(text.trim().to_string()) } +/// What draws the desktop window: egui-wgpu on one wgpu backend, or eframe's OpenGL renderer +/// (glow) as the last resort when no wgpu backend can build the window's pipeline. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowRenderer { + /// egui-wgpu on exactly one backend. + Wgpu(Backends), + /// eframe's glow (OpenGL) renderer. + Glow, +} + +impl WindowRenderer { + /// The name used in `LIGHTCRAFT_GPU_BACKEND`, the remembered-renderer file and the logs. + pub fn name(self) -> &'static str { + match self { + WindowRenderer::Glow => "glow", + WindowRenderer::Wgpu(b) if b == Backends::DX12 => "dx12", + WindowRenderer::Wgpu(b) if b == Backends::VULKAN => "vulkan", + WindowRenderer::Wgpu(b) if b == Backends::METAL => "metal", + WindowRenderer::Wgpu(b) if b == Backends::GL => "gl", + WindowRenderer::Wgpu(_) => "wgpu", + } + } + + /// One renderer name (`dx12`, `vulkan`, `metal`, `gl`, `glow`); anything else (lists, `auto`, + /// `off`, unknown names) is `None`. + pub fn parse(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + if s == "glow" { + return Some(WindowRenderer::Glow); + } + match parse_backends(&s)? { + BackendChoice::Use(b) if [Backends::DX12, Backends::VULKAN, Backends::METAL, Backends::GL].contains(&b) => Some(WindowRenderer::Wgpu(b)), + _ => None, + } + } +} + +/// The renderer the environment forces for the window: `LIGHTCRAFT_GPU_BACKEND` (`app`), else +/// `WGPU_BACKEND` (`wgpu`), when it names exactly one renderer. +pub fn forced_window_renderer(app: Option<&str>, wgpu: Option<&str>) -> Option { + app.and_then(WindowRenderer::parse).or_else(|| wgpu.and_then(WindowRenderer::parse)) +} + +/// The order in which the window's renderers are tried on `os`: the forced one (environment) +/// first, then the one remembered from the last good start, then the platform chain — on Windows +/// DX12, Vulkan, GL, then glow. DX12 stays first: with Vulkan loaded, a broken Vulkan driver can +/// crash the process uncatchably (issue #136), so Vulkan is touched only after DX12 failed. +/// `failed` (the renderer the last start died in) goes last, as a last resort only. Backends not +/// in `compiled`, and glow without `glow`, are left out; the list is never empty. +pub fn window_renderer_order( + forced: Option, + remembered: Option, + failed: Option, + os: &str, + compiled: Backends, + glow: bool, +) -> Vec { + let platform = match os { + "windows" => vec![Backends::DX12, Backends::VULKAN, Backends::GL], + "macos" | "ios" => vec![Backends::METAL], + _ => vec![Backends::VULKAN, Backends::GL], + }; + let chain = platform.into_iter().map(WindowRenderer::Wgpu).chain([WindowRenderer::Glow]); + let usable = |r: &WindowRenderer| match r { + WindowRenderer::Glow => glow, + WindowRenderer::Wgpu(b) => !b.is_empty() && compiled.contains(*b), + }; + let not_failed = |r: &WindowRenderer| Some(*r) != failed; + let candidates = forced.into_iter().chain(remembered.filter(not_failed)).chain(chain.filter(not_failed)).chain(failed); + let mut out: Vec = Vec::new(); + for r in candidates { + if usable(&r) && !out.contains(&r) { + out.push(r); + } + } + if out.is_empty() { + out.push(WindowRenderer::Wgpu(default_window_backends(os) & compiled)); + } + out +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -227,6 +352,9 @@ mod tests { } use super::*; + /// The tests that set the process-wide marker take turns. + static GLOBAL: Mutex<()> = Mutex::new(()); + #[test] fn parses_backend_names() { assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12))); @@ -293,6 +421,7 @@ mod tests { #[test] fn init_marker_is_written_during_creation_and_removed_after() { + let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id())); let m = dir.join("gpu-init.marker"); let _ = std::fs::remove_dir_all(&dir); @@ -302,7 +431,7 @@ mod tests { assert!(seen, "the marker exists while the device is created"); assert!(!m.exists(), "and is removed afterwards"); // a marker left behind by a crashed process is reported once; reading it leaves it in place - std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap(); + std::fs::write(&m, "GPU device and kernel creation started (backends DX12)").unwrap(); assert!(read_init_marker(&m).unwrap().contains("DX12")); assert!(m.exists(), "read_init_marker leaves the marker for the next launch"); assert!(take_init_marker(&m).unwrap().contains("DX12")); @@ -310,4 +439,81 @@ mod tests { assert_eq!(read_init_marker(&m), None); let _ = std::fs::remove_dir_all(&dir); } + + /// The window's renderer fallback: forced, remembered, the platform chain, the failed one last. + #[test] + fn window_renderer_fallback_order() { + use WindowRenderer::{Glow, Wgpu}; + let all = Backends::DX12 | Backends::VULKAN | Backends::GL | Backends::METAL; + let (dx, vk, gl) = (Wgpu(Backends::DX12), Wgpu(Backends::VULKAN), Wgpu(Backends::GL)); + assert_eq!(window_renderer_order(None, None, None, "windows", all, true), [dx, vk, gl, Glow]); + assert_eq!(window_renderer_order(None, None, None, "windows", all, false), [dx, vk, gl]); + // a build without GL + assert_eq!(window_renderer_order(None, None, None, "windows", Backends::DX12 | Backends::VULKAN, true), [dx, vk, Glow]); + // remembered from the last good start goes first + assert_eq!(window_renderer_order(None, Some(vk), None, "windows", all, true), [vk, dx, gl, Glow]); + // the environment wins over the remembered one + assert_eq!(window_renderer_order(Some(gl), Some(vk), None, "windows", all, true), [gl, vk, dx, Glow]); + assert_eq!(window_renderer_order(Some(Glow), None, None, "windows", all, true)[0], Glow); + // the last start died in DX12: tried last only (also when it was remembered) + assert_eq!(window_renderer_order(None, Some(dx), Some(dx), "windows", all, true), [vk, gl, Glow, dx]); + // a forced backend that is not in the build is skipped + assert_eq!(window_renderer_order(Some(Wgpu(Backends::METAL)), None, None, "windows", Backends::DX12, false), [dx]); + // never empty + assert!(!window_renderer_order(None, None, None, "windows", Backends::empty(), false).is_empty()); + assert_eq!(window_renderer_order(None, None, None, "macos", all, true), [Wgpu(Backends::METAL), Glow]); + } + + #[test] + fn window_renderer_names_round_trip() { + for n in ["dx12", "vulkan", "metal", "gl", "glow"] { + assert_eq!(WindowRenderer::parse(n).map(WindowRenderer::name), Some(n)); + } + assert_eq!(WindowRenderer::parse(" DX12 "), Some(WindowRenderer::Wgpu(Backends::DX12))); + for n in ["auto", "off", "vulkan,gl", "", "bogus"] { + assert_eq!(WindowRenderer::parse(n), None, "{n}"); + } + assert_eq!(forced_window_renderer(Some("glow"), Some("dx12")), Some(WindowRenderer::Glow)); + assert_eq!(forced_window_renderer(Some("auto"), Some("vulkan")), Some(WindowRenderer::Wgpu(Backends::VULKAN))); + assert_eq!(forced_window_renderer(None, None), None); + // `glow` only concerns the window: compute keeps its default, without a warning + assert_eq!(parse_backends("glow"), Some(BackendChoice::Auto)); + } + + #[test] + fn overlapping_scopes_share_one_marker_until_the_last_ends() { + let dir = std::env::temp_dir().join(format!("lc-gpu-scopes-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + let active = Mutex::new(0); + arm(&m, &active, "device creation"); + assert!(read_init_marker(&m).unwrap().contains("device creation")); + // a second scope (a denoise network's kernels) keeps the first's text and the file + arm(&m, &active, "denoise kernel compilation"); + assert!(read_init_marker(&m).unwrap().contains("device creation")); + disarm(&m, &active); + assert!(m.exists(), "the first scope is still running"); + disarm(&m, &active); + assert!(!m.exists(), "the last scope removes it"); + // a stray extra disarm does not underflow or break the next scope + disarm(&m, &active); + arm(&m, &active, "kernel compilation"); + assert!(m.exists()); + disarm(&m, &active); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_panic_inside_the_scope_still_removes_the_marker() { + let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("lc-gpu-panic-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + set_init_marker(Some(m.clone())); + let r = std::panic::catch_unwind(|| with_marker("kernel compilation", || panic!("compile"))); + set_init_marker(None); + assert!(r.is_err()); + assert!(!m.exists(), "the process survived, so nothing is left to report"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 8d73703c3..fb000c031 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -29,7 +29,11 @@ const MODULES: &[Module] = &[ ], entries: &["main"], }, - Module { src: include_str!("wgsl/blur.wgsl"), bindings: &[("src", false, "f32"), ("dst", true, "f32")], entries: &["box_h", "box_v"] }, + Module { + src: include_str!("wgsl/blur.wgsl"), + bindings: &[("src", false, "f32"), ("dst", true, "f32")], + entries: &["box_h", "box_v", "median_h", "median_v"], + }, Module { src: include_str!("wgsl/resize.wgsl"), bindings: &[("src", false, "f32"), ("table", false, "u32"), ("dst", true, "f32")], @@ -323,7 +327,8 @@ impl Gpu { log::error!("gpu: device lost ({reason:?}): {msg}"); crate::mark_broken(&format!("device lost ({reason:?}): {msg}")); }); - let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + // a kernel the driver's compiler refuses is a validation or an internal error: no GPU, not a half-built one + let scopes = [device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)]; let consts = constants(); let mut kernels = HashMap::new(); for m in MODULES { @@ -349,7 +354,9 @@ impl Gpu { kernels.insert(*e, Kernel { pipeline, layout: layout.clone(), nbuf: m.bindings.len() }); } } - if let Some(e) = pollster::block_on(scope.pop()) { + let [validation, internal] = scopes; + let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop())); + if let Some(e) = internal.or(validation) { log::error!("gpu: kernels failed to build on {}: {e}", info.name); return Err(format!("{}: kernels failed to build ({e})", info.name)); } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 1eae995a4..fb9785ba6 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -11,8 +11,10 @@ //! //! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, //! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out -//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`] -//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). +//! of memory or returned an incomplete image — callers then render on the CPU. [`try_render`] is +//! the same call reporting why *this* render fell back ([`Fallback`]); [`unavailable_reason`] and +//! [`last_fallback`] (the latest fallback in the process) feed the status displays (`ui.inspect` → +//! `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here //! compiles to the CPU fallback on wasm32. //! @@ -88,6 +90,9 @@ pub fn unavailable_reason() -> Option { let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into()); return Some(format!("stopped after a GPU failure: {r}")); } + if let Some(r) = broken_here() { + return Some(format!("stopped after a GPU failure: {r}")); + } #[cfg(not(target_arch = "wasm32"))] { match GPU.get() { @@ -101,12 +106,35 @@ pub fn unavailable_reason() -> Option { } } -/// The latest render that fell back from the GPU to the CPU and why (device limit, out of memory, -/// device error, incomplete or blank result), if any. +/// The latest render in the process that fell back from the GPU to the CPU and why (device limit, +/// out of memory, device error, incomplete or blank result), if any. A diagnostic for status +/// displays (`ui.inspect` → `perf.gpuFallback`): renders on other threads overwrite it, so a caller +/// that wants the reason for *its own* render uses [`try_render`], which returns it. pub fn last_fallback() -> Option { LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()).clone() } +/// Why [`try_render`] made no GPU image: the caller renders on the CPU. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fallback { + /// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or + /// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay). + NotTried, + /// The render was tried on the GPU and failed, for this reason (the same text + /// [`last_fallback`] reports). The CPU renders it instead. + Failed(String), +} + +impl Fallback { + /// The failure's reason; `None` when the GPU was not tried. + pub fn reason(&self) -> Option<&str> { + match self { + Fallback::NotTried => None, + Fallback::Failed(r) => Some(r), + } + } +} + /// A simulated GPU failure for the next [`render`] on this thread (tests of the CPU fallback). #[doc(hidden)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -122,9 +150,15 @@ pub enum Fault { thread_local! { static FAULT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Why a simulated failure ([`inject_fault`]) stopped GPU use on this thread. A simulated + /// fatal failure behaves like a real one for the thread that asked for it — [`render`] returns + /// `None`, [`available`] is false, [`unavailable_reason`] says why — but leaves the rest of + /// the process on the GPU: tests of the fallback run beside other tests rendering on it. + static BROKEN_HERE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } -/// Make the next [`render`] on this thread fail with `f` (tests). +/// Make the next [`render`] on this thread fail with `f` (tests). A fatal `f` then stops GPU use +/// on this thread only, until [`reset_failures`]. #[doc(hidden)] pub fn inject_fault(f: Fault) { FAULT.with(|c| c.set(Some(f))); @@ -135,12 +169,27 @@ fn take_fault() -> Option { FAULT.with(|c| c.take()) } -/// Use the GPU again after a failure (tests that inject faults). +/// Stop using the GPU on this thread (a simulated fatal failure). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn mark_broken_here(reason: &str) { + let _ = BROKEN_HERE.try_with(|b| { + let mut b = b.borrow_mut(); + if b.is_none() { + *b = Some(reason.to_string()); + } + }); +} + +/// Why a simulated failure stopped GPU use on this thread, if one did. +fn broken_here() -> Option { + BROKEN_HERE.try_with(|b| b.borrow().clone()).ok().flatten() +} + +/// Use the GPU again on this thread after a simulated failure ([`inject_fault`]; tests). Real +/// failures are not undone: the process stays on the CPU, and nothing another thread did changes. #[doc(hidden)] pub fn reset_failures() { - BROKEN.store(false, Ordering::Relaxed); - *BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()) = None; - *LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = None; + let _ = BROKEN_HERE.try_with(|b| *b.borrow_mut() = None); } /// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the @@ -149,9 +198,10 @@ pub fn set_enabled(on: bool) { ENABLED.store(on, Ordering::Relaxed); } -/// Whether GPU rendering is allowed (environment, preference, no earlier failure). +/// Whether GPU rendering is allowed (environment, preference, no earlier failure — real, or +/// simulated on this thread). pub fn enabled() -> bool { - ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() + ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() && broken_here().is_none() } /// The process is about to end: no GPU work starts from now on, on any thread. [`render`] returns @@ -226,9 +276,13 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> { let _work = exit::enter()?; GPU.get_or_init(|| { let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; - backend::with_init_marker(backends, || { + let r = backend::with_init_marker(backends, || { std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) - }) + }); + if let Err(why) = &r { + log::warn!("gpu: rendering on the CPU: {why}"); + } + r }) .as_ref() .ok() @@ -378,8 +432,22 @@ pub fn stage_bytes(stages: &StageCache) -> usize { } /// Render `src` with `s` on the GPU, reusing the device-resident stages kept with `stages` (the -/// view's CPU stage cache). `None`: render on the CPU instead. +/// view's CPU stage cache). `None`: render on the CPU instead. [`try_render`] also says why. pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { + try_render(src, info, s, req, stages).ok() +} + +/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU +/// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason +/// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike +/// [`last_fallback`], which the latest render on any thread overwrites. +pub fn try_render( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, +) -> Result { #[cfg(not(target_arch = "wasm32"))] { // sections switched off with their eye render as if at their defaults (issue #316) @@ -392,14 +460,16 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R || req.proof.is_some() || req.overlay == lightcraft_pipeline::Overlay::HdrRange { - return None; + return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); - // in flight until this returns; `None` once the process is ending (issue #620) - let _work = exit::enter()?; - let gpu = device()?; + // in flight until this returns; not tried once the process is ending (issue #620) + let _work = exit::enter().ok_or(Fallback::NotTried)?; + let gpu = device().ok_or(Fallback::NotTried)?; let ext = stages.map(|c| c.extension::()); let fault = take_fault(); + // A simulated failure stops GPU use on this thread only (see `BROKEN_HERE`). + let stop = |reason: &str| if fault.is_some() { mark_broken_here(reason) } else { mark_broken(reason) }; let _ = ctx::take_failure(); // nothing left over from an earlier render on this thread let (r, scoped) = { let _scope = ctx::RenderScope::new(gpu); @@ -413,12 +483,11 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R (Some(f), _) if f.kind == ctx::FailKind::Limit => Some(f), (f, scoped) => scoped.or(f), }; - match (r, failure) { + let reason = match (r, failure) { (Err(_), _) => { log::error!("gpu: render failed; using the CPU pipeline from now on"); - mark_broken("a GPU render panicked"); - record_fallback("a GPU render panicked; the GPU is not used again".into()); - None + stop("a GPU render panicked"); + "a GPU render panicked; the GPU is not used again".to_string() } (Ok(r), Some(f)) => { // the view's cached device stages may hold results of the failed passes @@ -431,7 +500,7 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R }; let reason = match f.kind { ctx::FailKind::Fatal => { - mark_broken(&f.reason); + stop(&f.reason); format!("{what}: {}; the GPU is not used again", f.reason) } ctx::FailKind::OutOfMemory => { @@ -444,20 +513,23 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R if render::profiling() { eprintln!(" gpu fallback to the CPU: {reason}"); } - record_fallback(reason); - None + reason } // a device error on another thread or a lost device during the render (Ok(Some(_)), None) if BROKEN.load(Ordering::Relaxed) => { - record_fallback(format!("device failure during the render: {}", unavailable_reason().unwrap_or_default())); - None + format!("device failure during the render: {}", unavailable_reason().unwrap_or_default()) } - (Ok(r), None) => r, - } + (Ok(Some(r)), None) => return Ok(r), + // every path of `render::render` that gives up records a failure first; should one + // not, the CPU renders it rather than nothing + (Ok(None), None) => "GPU render: no image came back".to_string(), + }; + record_fallback(reason.clone()); + Err(Fallback::Failed(reason)) } #[cfg(target_arch = "wasm32")] { let _ = (src, info, s, req, stages); - None + Err(Fallback::NotTried) } } diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index ca0975e9f..4f078ff93 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -672,7 +672,8 @@ pub fn runner(net: &Net, tile: usize) -> Result { d.device.push_error_scope(wgpu::ErrorFilter::Validation), d.device.push_error_scope(wgpu::ErrorFilter::Internal), ]; - let pipes = build_pipelines(d, &layout); + // the driver compiles the kernels here: a crash in it is reported at the next launch (issue #250) + let pipes = crate::backend::with_marker("denoise kernel compilation", || build_pipelines(d, &layout)); let weights: Vec<(wgpu::Buffer, wgpu::Buffer)> = layers .iter() .map(|l| { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 45a5a5f6d..44458350f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -8,6 +8,7 @@ use lightcraft_develop::DevelopSettings; use lightcraft_geom::Orientation; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; +use lightcraft_pipeline::local::plane_at; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; use lightcraft_raster::resample::Filter; use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; @@ -40,7 +41,7 @@ struct Entry { struct Planes { key: u64, log_l: Option>, - base: Option<(u32, Arc)>, + base: Option<(local::BaseKey, Arc)>, clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, dark: Option<(u32, Arc, f32)>, @@ -312,6 +313,28 @@ fn guided_coeffs(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: gaussian(cx, &ab, w, h, 2, sigma) } +/// Separable median of a plane (`local::median_hv`). +fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf { + if r == 0 || w * h == 0 { + return cx.copy(p); + } + let pr = [w as u32, h as u32, 1, r.min(local::BASE_DETAIL_MAX) as u32]; + let tmp = cx.gpu.buffer(w * h); + cx.run("median_h", &pr, &[Some(p), Some(&tmp)], groups2(w, h, [16, 16])); + let out = cx.gpu.buffer(w * h); + cx.run("median_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h, [16, 16])); + out +} + +/// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no median). +fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize) -> Buf { + if detail == 0 { + return guided_fast(cx, l, w, h, sigma, local::BASE_EPS); + } + let m = median_hv(cx, l, w, h, detail); + guided_fast(cx, &m, w, h, sigma, local::BASE_EPS) +} + /// Fast guided filter (`local::guided_fast`): coefficients on a subsampled grid. fn guided_fast(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf { let s = local::guided_fast_step(sigma); @@ -592,7 +615,8 @@ pub fn render( // 1. geometry (on the CPU when the source exceeds the device's buffer limit) let sampled = match &cached { Some(e) => e.sampled.clone(), - None if gpu.fits(src.data.len() * 3) => { + // (a WarpRectilinear2 chain also goes to the CPU: its degree-14 polynomial needs f64) + None if gpu.fits(src.data.len() * 3) && !plan.frame.warp.as_ref().is_some_and(|w| w.needs_f64()) => { let upload = || gpu.upload(rgb_words(src)); // (a source too big for a view's stages to keep is the one shared copy: a window is // cut from the photo's own pixels, and what its stages keep is its own sampled pixels) @@ -805,17 +829,6 @@ struct Prep { air: f32, } -fn plane_at(slot: &mut Option<(u32, Arc)>, sigma: f32, f: impl FnOnce() -> Buf) -> Arc { - match slot { - Some((k, p)) if *k == sigma.to_bits() => p.clone(), - _ => { - let p = Arc::new(f()); - *slot = Some((sigma.to_bits(), p.clone())); - p - } - } -} - /// The spatial planes (`local::prepare`), reusing those in `planes`. fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, planes: &mut Planes) -> Prep { let (w, h) = (plan.w, plan.h); @@ -832,11 +845,14 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla } }; let base = match sig.base { - Some(sg) => plane_at(&mut planes.base, sg, || guided_fast(cx, &log_l, w, h, sg, local::BASE_EPS)), + Some(sg) => { + let key = local::base_key(&plan.settings, sg, sig.base_detail); + plane_at(&mut planes.base, key, || tone_base(cx, &log_l, w, h, sg, sig.base_detail)) + } None => log_l.clone(), }; - let clarity = sig.clarity.map(|sg| plane_at(&mut planes.clarity, sg, || guided_fast(cx, &log_l, w, h, sg, local::CLARITY_EPS))); - let texture = sig.texture.map(|sg| plane_at(&mut planes.texture, sg, || gaussian(cx, &log_l, w, h, 1, sg))); + let clarity = sig.clarity.map(|sg| plane_at(&mut planes.clarity, sg.to_bits(), || guided_fast(cx, &log_l, w, h, sg, local::CLARITY_EPS))); + let texture = sig.texture.map(|sg| plane_at(&mut planes.texture, sg.to_bits(), || gaussian(cx, &log_l, w, h, 1, sg))); let (dark, air) = match sig.dark { None => (None, 1.0), Some(sg) => match &planes.dark { @@ -858,7 +874,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla }, }; let chroma = sig.chroma.map(|sg| { - plane_at(&mut planes.chroma, sg, || { + plane_at(&mut planes.chroma, sg.to_bits(), || { let ch = cx.gpu.buffer(n * 3); map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch); gaussian(cx, &ch, w, h, 3, sg) @@ -1058,6 +1074,7 @@ mod tests { radius: 0.6, }), vignette: None, + stages: Default::default(), }; let mut s = DevelopSettings::default(); s.optics.lens_profile = true; diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl index 576aff09a..b0e28370d 100644 --- a/crates/gpu/src/wgsl/blur.wgsl +++ b/crates/gpu/src/wgsl/blur.wgsl @@ -78,3 +78,58 @@ fn box_v(@builtin(global_invocation_id) g: vec3) { acc = acc + ld(u32(min(i32(y) + r + 1, last)) * w + x, nc) - ld(u32(max(i32(y) - r, 0)) * w + x, nc); } } + +// Separable median of a single-channel plane (`lightcraft_pipeline::local::median_hv`): 2r+1 +// taps (r ≤ 24, clamped edges) along a row (median_h) or a column (median_v), one thread per +// pixel. The median is a selection, so the result matches the CPU's bit for bit. +// P: w, h, nc (unused), r. +fn median_of(n: u32, r: u32, v: ptr>) -> f32 { + // partial selection sort up to the middle + for (var i = 0u; i <= r; i++) { + var m = i; + for (var j = i + 1u; j < n; j++) { + if ((*v)[j] < (*v)[m]) { + m = j; + } + } + let t = (*v)[i]; + (*v)[i] = (*v)[m]; + (*v)[m] = t; + } + return (*v)[r]; +} + +@compute @workgroup_size(16, 16) +fn median_h(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = min(pu(3u), 24u); + if (g.x >= w || g.y >= h) { + return; + } + let n = 2u * r + 1u; + var v: array; + let row = g.y * w; + for (var k = 0u; k < n; k++) { + let x = clamp(i32(g.x + k) - i32(r), 0, i32(w) - 1); + v[k] = src[row + u32(x)]; + } + dst[row + g.x] = median_of(n, r, &v); +} + +@compute @workgroup_size(16, 16) +fn median_v(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = min(pu(3u), 24u); + if (g.x >= w || g.y >= h) { + return; + } + let n = 2u * r + 1u; + var v: array; + for (var k = 0u; k < n; k++) { + let y = clamp(i32(g.y + k) - i32(r), 0, i32(h) - 1); + v[k] = src[u32(y) * w + g.x]; + } + dst[g.y * w + g.x] = median_of(n, r, &v); +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index b10836c37..96771a971 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -6,7 +6,9 @@ use std::sync::Arc; -use lightcraft_develop::{BrushStroke, DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape, Spot, Treatment, VignetteStyle, WbMode, Wheel}; +use lightcraft_develop::{ + BrushStroke, DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape, Process, Spot, Treatment, VignetteStyle, WbMode, Wheel, +}; use lightcraft_geom::{Orientation, Point, Rect}; use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo, StageCache, render}; use lightcraft_raster::{Rgb32f, Rgba8}; @@ -517,6 +519,7 @@ fn embedded_lens_perspective_edges_match() { radius: 0.6, }), vignette: Some(EmbeddedVignette { k: [0.4, -0.1, 0.02, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), + stages: Default::default(), }; let info = SourceInfo { lens: Some(lens), ..raw }; let mut s = DevelopSettings::default(); @@ -550,6 +553,48 @@ fn cached_renders_match_uncached() { } } +/// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log +/// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and +/// a warm cache never hands one process the other's base. +#[test] +fn highlights_shadows_base_matches_on_every_process() { + if !gpu() { + return; + } + let raw = SourceInfo { raw: true, ..Default::default() }; + let src = scene(0, 2400, 1600); + let cache = StageCache::default(); + for &(w, h) in &[(720, 480), (2400, 1600)] { + let req = RenderRequest::fit(w, h); + for p in Process::ALL { + let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() }; + s.light.highlights = -100.0; + s.light.shadows = 60.0; + check(&format!("{p:?} highlights/shadows {w}"), &src, &raw, &s, &req); + typical(&mut s); + check(&format!("{p:?} typical {w}"), &src, &raw, &s, &req); + s.masks = vec![Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.9) }, + }], + adjust: lightcraft_develop::LocalAdjustments { highlights: -70.0, shadows: 40.0, ..Default::default() }, + ..Default::default() + }]; + check(&format!("{p:?} local highlights {w}"), &src, &raw, &s, &req); + } + for p in [Process::V1, Process::V2, Process::V1, Process::V2] { + let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() }; + s.light.highlights = -100.0; + let warm = lightcraft_gpu::render(&src, &raw, &s, &req, Some(&cache)).expect("gpu"); + let fresh = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu"); + assert_eq!(warm.image, fresh.image, "{p:?} at {w} px, cached"); + } + } +} + #[test] fn overlays_match() { if !gpu() { diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index 1f7ceb2f0..fe2ecad5e 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -1,14 +1,15 @@ //! Issue #78: a GPU render that cannot be trusted — over the device's buffer limits, a device //! error, work that silently did not run — must come back as `None` (the caller renders on the -//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the fatal -//! faults stop GPU use for the whole process until `reset_failures`. +//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the simulated +//! fatal faults stop GPU use on this thread until `reset_failures` (issue #675: on this thread +//! only, so a test of the fallback can run beside tests that render on the GPU). //! Skips (passes with a note) when no GPU adapter exists. use std::sync::Arc; use lightcraft_develop::{DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape}; use lightcraft_geom::Point; -use lightcraft_gpu::Fault; +use lightcraft_gpu::{Fallback, Fault}; use lightcraft_pipeline::{RenderRequest, SourceInfo, render}; use lightcraft_raster::Rgba8; @@ -57,39 +58,51 @@ fn failed_gpu_renders_fall_back_with_a_reason() { assert!(d < 0.5, "{what}: GPU differs from the CPU by {d:.3} LSB"); }; ok("healthy"); + // the render that fell back says why itself; the process-wide diagnostic agrees + let failed = |what: &str| -> String { + match lightcraft_gpu::try_render(&src, &info, &s, &req, None) { + Ok(_) => panic!("{what}: the GPU render must fall back"), + Err(Fallback::NotTried) => panic!("{what}: the render must be tried on the GPU ({:?})", lightcraft_gpu::unavailable_reason()), + Err(Fallback::Failed(why)) => { + assert_eq!(lightcraft_gpu::last_fallback().as_deref(), Some(why.as_str()), "{what}"); + why + } + } + }; // the output itself is over the limit: refused up front, the GPU stays in use lightcraft_gpu::inject_fault(Fault::Limit(1 << 20)); - assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + let why = failed("output over the limit"); assert!(why.contains("limit"), "{why}"); assert!(lightcraft_gpu::available(), "a limit is no device failure"); // the output fits but the mask planes (4 masks: 4 planes in one buffer) don't: caught when the // buffer is created, without a device error, and the GPU stays in use lightcraft_gpu::inject_fault(Fault::Limit((w * h * 3 * 4 + 1024) as u64)); - assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + let why = failed("mask planes over the limit"); assert!(why.contains("exceeds"), "{why}"); assert!(lightcraft_gpu::available(), "{:?}", lightcraft_gpu::unavailable_reason()); ok("after the limit faults"); // a device (validation) error: captured by the render's error scope; the GPU is not used again + // on this thread — other threads keep it (a simulated failure, not a real one) lightcraft_gpu::inject_fault(Fault::Validation); - assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + let why = failed("validation error"); assert!(why.contains("device error"), "{why}"); assert!(!lightcraft_gpu::available()); assert!(lightcraft_gpu::unavailable_reason().is_some_and(|r| r.contains("device error")), "{:?}", lightcraft_gpu::unavailable_reason()); + assert_eq!(lightcraft_gpu::render(&src, &info, &s, &req, None).map(|_| ()), None, "no GPU render on this thread after a fatal fault"); + let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join(); + assert_eq!(elsewhere.ok(), Some((true, None)), "a simulated failure switches the GPU off for its own thread only"); lightcraft_gpu::reset_failures(); + assert_eq!(lightcraft_gpu::unavailable_reason(), None); ok("after a device error"); // the per-pixel work never runs (a driver dropping a submission without an error): before // the fix this came back as a fully black image lightcraft_gpu::inject_fault(Fault::DropWork); - assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none(), "an unwritten image must not be returned"); - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); - assert!(why.contains("incomplete"), "{why}"); + let why = failed("dropped work"); + assert!(why.contains("incomplete"), "an unwritten image must not be returned: {why}"); assert!(!lightcraft_gpu::available()); lightcraft_gpu::reset_failures(); ok("after dropped work"); @@ -97,7 +110,14 @@ fn failed_gpu_renders_fall_back_with_a_reason() { // a black photo is still rendered (on the CPU, to be sure) let black = Arc::new(lightcraft_raster::Rgb32f::from_fn(256, 256, |_, _| [0.0; 3])); let s0 = DevelopSettings::default(); - assert!(lightcraft_gpu::render(&black, &info, &s0, &RenderRequest::fit(256, 256), None).is_none()); + match lightcraft_gpu::try_render(&black, &info, &s0, &RenderRequest::fit(256, 256), None) { + Err(Fallback::Failed(why)) => assert!(why.contains("black"), "{why}"), + other => panic!("a black result is redone on the CPU: {:?}", other.map(|_| ())), + } assert!(lightcraft_gpu::last_fallback().unwrap_or_default().contains("black")); assert!(lightcraft_gpu::available()); + + // a request the GPU never renders is not a fallback + let deep = RenderRequest { depth: lightcraft_pipeline::OutputDepth::U16, ..RenderRequest::fit(256, 256) }; + assert_eq!(lightcraft_gpu::try_render(&src, &info, &s, &deep, None).err(), Some(Fallback::NotTried)); } diff --git a/crates/inpaint/Cargo.toml b/crates/inpaint/Cargo.toml new file mode 100644 index 000000000..b2d111fba --- /dev/null +++ b/crates/inpaint/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lightcraft-inpaint" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Content-aware hole filling: PatchMatch-based patch completion and membrane fill (L1). Ported from PhotoCraft" + +[lints] +workspace = true + +[dependencies] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } diff --git a/crates/inpaint/src/lib.rs b/crates/inpaint/src/lib.rs new file mode 100644 index 000000000..fb76304af --- /dev/null +++ b/crates/inpaint/src/lib.rs @@ -0,0 +1,61 @@ +//! Content-aware hole filling (inpainting) for the Remove tool. +//! +//! * [`complete`]: multi-scale patch-based completion (Wexler, Shechtman, Irani 2007) with the +//! nearest-neighbour field found by PatchMatch (Barnes et al. 2009). See [`patchmatch`]. +//! * [`membrane_fill`]: smooth harmonic fill, used to seed the coarsest level and as a fallback. +//! +//! Buffers are interleaved `w × h × ch` floats in any colour model plus a `w × h` hole mask. +//! Everything is deterministic (fixed-seed RNG, results independent of the thread count) and never +//! panics on mismatched sizes: such calls return `None` or the input unchanged. +//! +//! Sources: C. Barnes, E. Shechtman, A. Finkelstein, D. B. Goldman, *PatchMatch: A Randomized +//! Correspondence Algorithm for Structural Image Editing*, SIGGRAPH 2009; Y. Wexler, E. Shechtman, +//! M. Irani, *Space-Time Completion of Video*, PAMI 2007. +//! +//! Ported from PhotoCraft (`crates/algo/src/inpaint.rs`, `poisson.rs`, MIT OR Apache-2.0, same +//! organisation; see `NOTICE`). + +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod membrane; +pub mod patchmatch; + +pub use membrane::membrane_fill; +pub use patchmatch::{CompleteParams, complete, complete_with}; + +/// A cancelled [`complete_with`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostile_sizes_do_not_panic() { + assert_eq!(complete(4, 4, 3, &[0.0; 5], &[true; 16], &CompleteParams::default()), None); + assert_eq!(complete(4, 4, 0, &[], &[true; 16], &CompleteParams::default()), None); + assert_eq!(complete(usize::MAX, 2, 3, &[0.0; 6], &[true; 2], &CompleteParams::default()), None); + assert_eq!(membrane_fill(4, 4, 1, &[0.5; 3], &[true; 16]), vec![0.5; 3]); + // A hole covering everything: nothing to copy from. + assert_eq!(complete(16, 16, 1, &[0.5; 256], &[true; 256], &CompleteParams::default()), None); + // A tiny image smaller than a patch. + let _ = complete(2, 2, 1, &[0.5; 4], &[true, false, false, false], &CompleteParams::default()); + } + + #[test] + fn cancellation_stops() { + let img = vec![0.5f32; 64 * 64]; + let hole: Vec = (0..64 * 64usize).map(|i| (i % 64).abs_diff(32) < 4 && (i / 64).abs_diff(32) < 4).collect(); + assert_eq!(complete_with(64, 64, 1, &img, &hole, &CompleteParams::default(), &|| true), Err(Cancelled)); + } +} diff --git a/crates/inpaint/src/membrane.rs b/crates/inpaint/src/membrane.rs new file mode 100644 index 000000000..1f259b02f --- /dev/null +++ b/crates/inpaint/src/membrane.rs @@ -0,0 +1,197 @@ +//! Membrane (harmonic) fill: `Δv = 0` inside a hole with the known pixels as Dirichlet boundary. +//! Used to initialise the coarsest level of [`crate::complete`] and as its fallback. +//! +//! Sources: the membrane interpolant of P. Pérez, M. Gangnet, A. Blake, *Poisson Image Editing*, +//! ACM SIGGRAPH 2003 (§3), solved by successive over-relaxation on the 4-connected grid, accelerated +//! cascadically (solve on a half-resolution grid, prolong bilinearly, a few SOR sweeps per level). +//! Ported from PhotoCraft's `algo::poisson` (MIT OR Apache-2.0). + +/// Convergence threshold (max per-sweep update) for the SOR sweeps. +const TOL: f32 = 2e-5; +/// SOR sweeps (cap) and relaxation factor on every level above the coarsest: tuned so a 96×72 disc +/// is within ~1e-3 of a fully converged Gauss–Seidel solve (below one 8-bit step). +const FINE_ITERS: usize = 30; +const FINE_OMEGA: f32 = 1.7; + +/// Solve `Δv = 0` on `unknown` pixels with Dirichlet values from the known pixels of `v` +/// (out-of-grid neighbours are ignored, i.e. a Neumann boundary). `v` holds the known values on +/// entry and the solution on exit. Cascadic multigrid + SOR. +pub(crate) fn solve_membrane(w: usize, h: usize, unknown: &[bool], v: &mut [f32]) { + if unknown.len() != w * h || v.len() != w * h { + return; + } + solve_rec(w, h, unknown, v, 0, &|| false); +} + +fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) { + if stop() { + return; + } + let n_unknown = unknown.iter().filter(|u| **u).count(); + if n_unknown == 0 { + return; + } + let known: Vec = v.iter().zip(unknown).filter(|(_, u)| !**u).map(|(x, _)| *x).collect(); + if known.is_empty() { + return; // nothing to interpolate from + } + if w >= 8 && h >= 8 && n_unknown > 64 && depth < 16 { + // Restrict: a coarse pixel is known if any of its children is (value = mean of known children). + let (cw, chh) = (w.div_ceil(2), h.div_ceil(2)); + let mut cu = vec![true; cw * chh]; + let mut cv = vec![0.0f32; cw * chh]; + for cy in 0..chh { + for cx in 0..cw { + let (mut s, mut n) = (0.0f32, 0u32); + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let (x, y) = (cx * 2 + dx, cy * 2 + dy); + if x < w && y < h && !unknown[y * w + x] { + s += v[y * w + x]; + n += 1; + } + } + if n > 0 { + cu[cy * cw + cx] = false; + cv[cy * cw + cx] = s / n as f32; + } + } + } + solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop); + // Prolong: bilinear sample of the coarse solution as the initial guess. + for y in 0..h { + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (chh - 1) as f32); + let y0 = fy.floor() as usize; + let y1 = (y0 + 1).min(chh - 1); + let ty = fy - y0 as f32; + for x in 0..w { + if !unknown[y * w + x] { + continue; + } + let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); + let x0 = fx.floor() as usize; + let x1 = (x0 + 1).min(cw - 1); + let tx = fx - x0 as f32; + let a = cv[y0 * cw + x0] + (cv[y0 * cw + x1] - cv[y0 * cw + x0]) * tx; + let b = cv[y1 * cw + x0] + (cv[y1 * cw + x1] - cv[y1 * cw + x0]) * tx; + v[y * w + x] = a + (b - a) * ty; + } + } + sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA, stop); + } else { + let mean = known.iter().sum::() / known.len() as f32; + for (x, u) in v.iter_mut().zip(unknown) { + if *u { + *x = mean; + } + } + sor(w, h, unknown, v, 2000, 1.85, stop); + } +} + +/// SOR sweeps over the unknown pixels until the largest update drops below [`TOL`]. +fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32, stop: &(dyn Fn() -> bool + Sync)) { + // Precompute the unknown pixels and which neighbours exist. + let cells: Vec<(u32, u8)> = (0..w * h) + .filter(|&i| unknown[i]) + .map(|i| { + let (x, y) = (i % w, i / w); + let m = u8::from(x > 0) | (u8::from(x + 1 < w) << 1) | (u8::from(y > 0) << 2) | (u8::from(y + 1 < h) << 3); + (i as u32, m) + }) + .collect(); + for _ in 0..max_iter { + if stop() { + return; + } + let mut max_d = 0.0f32; + for &(i, m) in &cells { + let i = i as usize; + let (s, n) = if m == 0b1111 { + (v[i - 1] + v[i + 1] + v[i - w] + v[i + w], 4.0) + } else { + let mut s = 0.0; + let mut n = 0.0; + if m & 1 != 0 { + s += v[i - 1]; + n += 1.0; + } + if m & 2 != 0 { + s += v[i + 1]; + n += 1.0; + } + if m & 4 != 0 { + s += v[i - w]; + n += 1.0; + } + if m & 8 != 0 { + s += v[i + w]; + n += 1.0; + } + if n == 0.0 { + continue; + } + (s, n) + }; + let d = omega * (s / n - v[i]); + v[i] += d; + max_d = max_d.max(d.abs()); + } + if max_d < TOL { + break; + } + } +} + +/// Map `f` over the channels of an interleaved buffer as planar slices (parallel on native). +fn per_channel(w: usize, h: usize, ch: usize, f: impl Fn(usize) -> Vec + Sync + Send) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + let planes: Vec> = if w * h >= 64 * 64 { + use rayon::prelude::*; + (0..ch).into_par_iter().map(&f).collect() + } else { + (0..ch).map(&f).collect() + }; + #[cfg(target_arch = "wasm32")] + let planes: Vec> = (0..ch).map(&f).collect(); + let mut out = vec![0.0f32; w * h * ch]; + for (c, p) in planes.iter().enumerate() { + for (i, v) in p.iter().enumerate() { + out[i * ch + c] = *v; + } + } + out +} + +/// Membrane fill (no source texture): harmonic interpolation of the known pixels into `hole`. +/// `img` is interleaved `w × h × ch`, `hole` has `w × h` entries; on a size mismatch `img` comes back +/// unchanged. +pub fn membrane_fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec { + if Some(hole.len()) != w.checked_mul(h) || Some(img.len()) != hole.len().checked_mul(ch) { + return img.to_vec(); + } + per_channel(w, h, ch, |c| { + let mut v: Vec = (0..w * h).map(|i| if hole[i] { 0.0 } else { img[i * ch + c] }).collect(); + solve_membrane(w, h, hole, &mut v); + v + }) +} +#[cfg(test)] +mod tests { + use super::*; + + fn disc(w: usize, h: usize, cx: f32, cy: f32, r: f32) -> Vec { + (0..w * h).map(|i| ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r).collect() + } + + #[test] + fn membrane_reproduces_linear_boundary() { + // A linear function is harmonic: the solve must reproduce it exactly inside the hole. + let (w, h) = (80, 60); + let unknown = disc(w, h, 40.0, 30.0, 20.0); + let truth: Vec = (0..w * h).map(|i| 0.2 + 0.005 * (i % w) as f32 + 0.003 * (i / w) as f32).collect(); + let mut v: Vec = truth.iter().zip(&unknown).map(|(t, u)| if *u { 0.0 } else { *t }).collect(); + solve_membrane(w, h, &unknown, &mut v); + let err = v.iter().zip(&truth).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max); + assert!(err < 2e-3, "{err}"); + } +} diff --git a/crates/inpaint/src/patchmatch.rs b/crates/inpaint/src/patchmatch.rs new file mode 100644 index 000000000..ea69f38d8 --- /dev/null +++ b/crates/inpaint/src/patchmatch.rs @@ -0,0 +1,587 @@ +//! Hole filling for Spot Healing: patch-based completion, proximity match and texture synthesis. +//! +//! * [`complete`], content-aware fill: the multi-scale "space-time completion" EM scheme of +//! Y. Wexler, E. Shechtman, M. Irani, *Space-Time Completion of Video*, IEEE TPAMI 2007, with the +//! nearest-neighbour field computed by C. Barnes, E. Shechtman, A. Finkelstein, D. B. Goldman, +//! *PatchMatch: A Randomized Correspondence Algorithm for Structural Image Editing*, SIGGRAPH 2009 +//! (random init, propagation, exponentially shrinking random search). Each EM iteration finds, for +//! every patch overlapping the hole, its most similar fully-known patch, then re-estimates hole +//! pixels by a similarity-weighted vote of the overlapping matches. Coarse-to-fine over an image +//! pyramid (the coarsest level is initialised by membrane interpolation), so large holes stay cheap. +//! * [`best_offset`], proximity match: the single displacement whose surrounding ring best matches the +//! ring around the hole (sum of squared differences), i.e. an automatic Healing Brush source. +//! * [`synthesize`], create texture: patch-based texture synthesis in the style of A. Efros, +//! W. Freeman, *Image Quilting for Texture Synthesis and Transfer*, SIGGRAPH 2001 (blocks picked from +//! random candidates by overlap error, feathered overlap instead of a min-cut seam). +//! +//! Ported from PhotoCraft's `algo::inpaint` (MIT OR Apache-2.0); its proximity match and texture +//! synthesis are left out. +//! +//! All functions take interleaved `w × h × ch` normalised floats (any colour model/depth) and a +//! `w × h` hole mask, and return a full buffer equal to the input outside the hole. They are +//! deterministic (fixed-seed hash RNG, results independent of thread count). + +use crate::Cancelled; +use crate::membrane::membrane_fill; + +/// Below this many grid cells a level runs single-threaded (thread hand-off costs more than it saves). +#[cfg(not(target_arch = "wasm32"))] +const PAR_MIN: usize = 128 * 128; + +/// SplitMix64: tiny deterministic RNG. +#[derive(Clone)] +struct Rng(u64); +impl Rng { + fn new(seed: u64) -> Self { + Rng(seed ^ 0x9E37_79B9_7F4A_7C15) + } + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform integer in `lo..=hi`. + fn range(&mut self, lo: i32, hi: i32) -> i32 { + if hi <= lo { + return lo; + } + lo + (self.next() % (hi - lo + 1) as u64) as i32 + } +} + +/// Parameters for [`complete`]. +#[derive(Clone, Debug, PartialEq)] +pub struct CompleteParams { + /// Patch radius (patches are `2r+1` square). + pub patch_radius: usize, + /// EM iterations at the coarsest level (finer levels use fewer, down to `min_em_iters`). + pub em_iters: usize, + pub min_em_iters: usize, + /// PatchMatch passes per EM iteration. + pub pm_iters: usize, + /// Levels where the hole is wider than this (in that level's pixels) only *refine* the upsampled + /// match field (one local PatchMatch pass and one vote), which keeps big holes tractable: the + /// structure is decided at coarse scale, fine levels just add detail. + pub refine_extent: usize, + pub seed: u64, +} + +impl Default for CompleteParams { + fn default() -> Self { + Self { patch_radius: 3, em_iters: 8, min_em_iters: 2, pm_iters: 2, refine_extent: 96, seed: 1 } + } +} + +/// One pyramid level. +#[derive(Clone)] +struct Level { + w: usize, + h: usize, + img: Vec, + hole: Vec, +} + +fn downsample(l: &Level, ch: usize) -> Level { + let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2)); + let mut img = vec![0.0f32; w * h * ch]; + let mut hole = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let mut n = 0.0f32; + let o = (y * w + x) * ch; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let (sx, sy) = (x * 2 + dx, y * 2 + dy); + if sx >= l.w || sy >= l.h { + continue; + } + let si = sy * l.w + sx; + if l.hole[si] { + hole[y * w + x] = true; + } else { + for c in 0..ch { + img[o + c] += l.img[si * ch + c]; + } + n += 1.0; + } + } + if n > 0.0 { + for c in 0..ch { + img[o + c] /= n; + } + } + } + } + Level { w, h, img, hole } +} + +/// Summed-area table of a boolean grid (`(w+1) × (h+1)`). +fn integral(w: usize, h: usize, m: &[bool]) -> Vec { + let mut s = vec![0u32; (w + 1) * (h + 1)]; + for y in 0..h { + let mut row = 0u32; + for x in 0..w { + row += u32::from(m[y * w + x]); + s[(y + 1) * (w + 1) + x + 1] = s[y * (w + 1) + x + 1] + row; + } + } + s +} + +/// Count of set cells in the clipped window `[x0, x1) × [y0, y1)`. +fn window_count(s: &[u32], gw: usize, gh: usize, x0: i32, y0: i32, x1: i32, y1: i32) -> u32 { + let (x0, y0) = (x0.max(0) as usize, y0.max(0) as usize); + let (x1, y1) = ((x1.max(0) as usize).min(gw), (y1.max(0) as usize).min(gh)); + if x1 <= x0 || y1 <= y0 { + return 0; + } + let w1 = gw + 1; + s[y1 * w1 + x1] + s[y0 * w1 + x0] - s[y0 * w1 + x1] - s[y1 * w1 + x0] +} + +fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, usize)> { + let mut b: Option<(usize, usize, usize, usize)> = None; + for y in 0..h { + for x in 0..w { + if hole[y * w + x] { + b = Some(match b { + None => (x, y, x + 1, y + 1), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)), + }); + } + } + } + b +} + +/// Per-level PatchMatch/EM state. +struct Solver<'a> { + w: usize, + h: usize, + ch: usize, + r: i32, + img: &'a mut Vec, + hole: &'a [bool], + /// Valid source centres: the whole patch is inside the grid and contains no hole pixel. + valid: Vec, + valid_list: Vec<(i32, i32)>, + /// Target centres (patch overlaps the hole), row-major. + targets: Vec, + /// Cancellation: checked per row band; a cancelled solve's output is discarded. + ctl: &'a (dyn Fn() -> bool + Sync), +} + +impl Solver<'_> { + #[inline] + fn is_valid(&self, x: i32, y: i32) -> bool { + x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.valid[y as usize * self.w + x as usize] + } + + /// SSD between the (clipped) target patch at `t` and the source patch at `s`, per sample. + fn dist(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 { + let (w, h, ch, r) = (self.w as i32, self.h as i32, self.ch, self.r); + let full = ((2 * r + 1) * (2 * r + 1)) as f32 * ch as f32; + let mut sum = 0.0f32; + let mut n = 0usize; + for dy in -r..=r { + let ty = t.1 + dy; + if ty < 0 || ty >= h { + continue; + } + let sy = s.1 + dy; + for dx in -r..=r { + let tx = t.0 + dx; + if tx < 0 || tx >= w { + continue; + } + let ti = (ty as usize * self.w + tx as usize) * ch; + let si = (sy as usize * self.w + (s.0 + dx) as usize) * ch; + for c in 0..ch { + let d = self.img[ti + c] - self.img[si + c]; + sum += d * d; + } + n += ch; + } + // Early out: the mean can only be ≥ sum / (full patch size). + if sum > cutoff * full { + return f32::INFINITY; + } + } + if n == 0 { f32::INFINITY } else { sum / n as f32 } + } + + /// One PatchMatch pass over all target rows, in parallel bands (each band propagates within itself). + fn patchmatch(&self, nnf: &mut [(i32, i32)], cost: &mut [f32], pass: usize, seed: u64, search_radius: i32) { + const BAND: usize = 16; + let w = self.w; + let reverse = pass % 2 == 1; + let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| { + if (self.ctl)() { + return; + } + let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40)); + let y0 = bi * BAND; + let rows = nn.len() / w; + let step: i32 = if reverse { -1 } else { 1 }; + for ry in 0..rows { + let ly = if reverse { rows - 1 - ry } else { ry }; + let y = y0 + ly; + for rx in 0..w { + let x = if reverse { w - 1 - rx } else { rx }; + let li = ly * w + x; + if !self.targets[y * w + x] { + continue; + } + let t = (x as i32, y as i32); + let (mut best, mut bc) = (nn[li], co[li]); + // Propagation from the already-visited neighbours (within this band). + let nx = x as i32 - step; + if nx >= 0 && (nx as usize) < w { + let c = nn[ly * w + nx as usize]; + let cand = (c.0 + step, c.1); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + } + let nly = ly as i32 - step; + if nly >= 0 && (nly as usize) < rows { + let c = nn[nly as usize * w + x]; + let cand = (c.0, c.1 + step); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + } + // Random search in exponentially shrinking windows. + let mut rad = search_radius; + while rad >= 1 { + let cand = (best.0 + rng.range(-rad, rad), best.1 + rng.range(-rad, rad)); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + rad /= 2; + } + nn[li] = best; + co[li] = bc; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if w * self.h >= PAR_MIN { + use rayon::prelude::*; + nnf.par_chunks_mut(BAND * w).zip(cost.par_chunks_mut(BAND * w)).enumerate().for_each(|(bi, (nn, co))| band_fn(bi, nn, co)); + return; + } + for (bi, (nn, co)) in nnf.chunks_mut(BAND * w).zip(cost.chunks_mut(BAND * w)).enumerate() { + band_fn(bi, nn, co); + } + } + + /// Re-estimate hole pixels by a similarity-weighted vote of all overlapping patch matches. + fn vote(&mut self, nnf: &[(i32, i32)], cost: &[f32]) { + let (w, h, ch, r) = (self.w, self.h, self.ch, self.r); + // σ² from the 75th percentile of match costs (Wexler et al. use a robust scale like this). + let mut cs: Vec = cost.iter().zip(&self.targets).filter(|(c, t)| **t && c.is_finite()).map(|(c, _)| *c).collect(); + let sigma2 = if cs.is_empty() { + 1.0 + } else { + let k = (cs.len() * 3 / 4).min(cs.len() - 1); + let (_, v, _) = cs.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); + v.max(1e-6) + }; + let img = &*self.img; + let row = |y: usize| -> Vec<(usize, Vec)> { + let mut out = Vec::new(); + if (self.ctl)() { + return out; + } + for x in 0..w { + if !self.hole[y * w + x] { + continue; + } + let mut acc = vec![0.0f32; ch]; + let mut wsum = 0.0f32; + for dy in -r..=r { + let ty = y as i32 + dy; + if ty < 0 || ty as usize >= h { + continue; + } + for dx in -r..=r { + let tx = x as i32 + dx; + if tx < 0 || tx as usize >= w { + continue; + } + let ti = ty as usize * w + tx as usize; + if !self.targets[ti] || !cost[ti].is_finite() { + continue; + } + let s = nnf[ti]; + let (sx, sy) = (s.0 - dx, s.1 - dy); + let wt = (-cost[ti] / (2.0 * sigma2)).exp().max(1e-8); + let si = (sy as usize * w + sx as usize) * ch; + for c in 0..ch { + acc[c] += img[si + c] * wt; + } + wsum += wt; + } + } + if wsum > 0.0 { + acc.iter_mut().for_each(|v| *v /= wsum); + out.push((x, acc)); + } + } + out + }; + #[cfg(not(target_arch = "wasm32"))] + let rows: Vec)>> = if w * h >= PAR_MIN { + use rayon::prelude::*; + (0..h).into_par_iter().map(row).collect() + } else { + (0..h).map(row).collect() + }; + #[cfg(target_arch = "wasm32")] + let rows: Vec)>> = (0..h).map(row).collect(); + for (y, r) in rows.into_iter().enumerate() { + for (x, v) in r { + self.img[(y * w + x) * ch..(y * w + x + 1) * ch].copy_from_slice(&v); + } + } + } +} + +/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known +/// patch to copy from (the caller can fall back to [`membrane_fill`]). +pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option> { + // Never cancelled, so never `Err`. + complete_with(w, h, ch, img, hole, p, &|| false).unwrap_or(None) +} + +/// [`complete`] that can be cancelled (checked per EM iteration, PatchMatch pass and row band) +/// (`cancelled` is polled per EM iteration, PatchMatch pass and row band): `Err(Cancelled)` once it +/// returned `true`. `Ok(None)` also for mismatched buffer sizes or `ch == 0`. +pub fn complete_with( + w: usize, + h: usize, + ch: usize, + img: &[f32], + hole: &[bool], + p: &CompleteParams, + cancelled: &(dyn Fn() -> bool + Sync), +) -> Result>, Cancelled> { + let check = || if cancelled() { Err(Cancelled) } else { Ok(()) }; + if ch == 0 || Some(hole.len()) != w.checked_mul(h) || Some(img.len()) != hole.len().checked_mul(ch) { + return Ok(None); + } + let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) }; + let r = p.patch_radius.max(1); + let psz = 2 * r + 1; + // Pyramid: shrink until the hole is a few patches across. + let mut levels = vec![Level { w, h, img: img.to_vec(), hole: hole.to_vec() }]; + let mut ext = (bx1 - bx0).max(by1 - by0); + let mut exts = vec![ext]; + while let Some(l) = levels.last() { + check()?; + if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { + break; + } + let d = downsample(l, ch); + levels.push(d); + ext = ext.div_ceil(2); + exts.push(ext); + } + let nlev = levels.len(); + let mut nnf: Vec<(i32, i32)> = Vec::new(); + let mut prev: Option<(usize, usize, Vec)> = None; + for li in (0..nlev).rev() { + check()?; + let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone(); + // Initialise the hole: membrane at the coarsest level, upsampled result elsewhere. + match &prev { + None => limg = membrane_fill(lw, lh, ch, &limg, &lhole), + Some((pw, ph, pimg)) => { + for y in 0..lh { + for x in 0..lw { + if lhole[y * lw + x] { + let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); + let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch); + limg[d..d + ch].copy_from_slice(&pimg[s..s + ch]); + } + } + } + } + } + let ri = r as i32; + let hs = integral(lw, lh, &lhole); + let mut valid = vec![false; lw * lh]; + let mut valid_list = Vec::new(); + let mut targets = vec![false; lw * lh]; + for y in 0..lh as i32 { + if y % 32 == 0 { + check()?; + } + for x in 0..lw as i32 { + let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1); + let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32; + let i = y as usize * lw + x as usize; + if inside && cnt == 0 { + valid[i] = true; + valid_list.push((x, y)); + } + targets[i] = cnt > 0; + } + } + if valid_list.is_empty() { + if li == 0 { + return Ok(None); + } + prev = Some((lw, lh, limg)); + nnf.clear(); + continue; + } + let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: cancelled }; + // NNF init: upsampled from the coarser level where possible, random otherwise. + let mut rng = Rng::new(p.seed ^ (li as u64) << 20); + let pw_prev = prev.as_ref().map_or(0, |p| p.0); + let old = std::mem::take(&mut nnf); + let mut new_nnf = vec![(0i32, 0i32); lw * lh]; + for y in 0..lh { + if y % 32 == 0 { + check()?; + } + for x in 0..lw { + if !solver.targets[y * lw + x] { + continue; + } + let mut cand = None; + if !old.is_empty() && pw_prev > 0 { + let pi = (y / 2) * pw_prev + (x / 2); + if let Some(&(sx, sy)) = old.get(pi) { + let c = (sx * 2 + (x % 2) as i32, sy * 2 + (y % 2) as i32); + if solver.is_valid(c.0, c.1) { + cand = Some(c); + } + } + } + new_nnf[y * lw + x] = cand.unwrap_or_else(|| solver.valid_list[(rng.next() % solver.valid_list.len() as u64) as usize]); + } + } + nnf = new_nnf; + let refine_only = li + 1 < nlev && exts[li] > p.refine_extent; + let em = if li + 1 == nlev { + p.em_iters + } else if refine_only { + 1 + } else { + (p.em_iters >> (nlev - 1 - li)).max(p.min_em_iters) + }; + let pm_iters = if refine_only { 1 } else { p.pm_iters }; + let full_search = (lw.max(lh)) as i32; + let mut cost = vec![f32::INFINITY; lw * lh]; + for it in 0..em { + // Costs change as hole pixels are re-estimated: refresh before each PatchMatch. + for (i, c) in cost.iter_mut().enumerate() { + if i % (lw * 32).max(1) == 0 { + check()?; + } + if solver.targets[i] { + *c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY); + } + } + // Wide random search on coarse levels / first iteration, local refinement afterwards. + let radius = if refine_only { + 2 + } else if li + 1 == nlev || it == 0 { + full_search + } else { + (4 * psz as i32).min(full_search) + }; + for pass in 0..pm_iters { + check()?; + solver.patchmatch(&mut nnf, &mut cost, pass + it * pm_iters, p.seed.wrapping_add((li * 1000 + it) as u64), radius); + } + check()?; + solver.vote(&nnf, &cost); + check()?; + } + prev = Some((lw, lh, limg)); + } + Ok(prev.map(|(_, _, img)| img)) +} + +/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the +/// ring of `ring` pixels around the hole, with the displaced hole fully inside the grid and outside the +#[cfg(test)] +mod tests { + use super::*; + + /// Smooth gradient plus deterministic fine noise. + fn texture(w: usize, h: usize) -> Vec { + let mut rng = Rng::new(7); + (0..w * h) + .flat_map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let n = (rng.next() % 1000) as f32 / 1000.0 * 0.04 - 0.02; + [0.3 + 0.004 * x + n, 0.5 + 0.002 * y + n, 0.4 + n] + }) + .collect() + } + + fn disc(w: usize, h: usize, cx: f32, cy: f32, r: f32) -> Vec { + (0..w * h).map(|i| ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r).collect() + } + + fn hole_rmse(a: &[f32], b: &[f32], hole: &[bool], ch: usize) -> f32 { + let (mut e, mut n) = (0.0f32, 0); + for (i, hh) in hole.iter().enumerate() { + if *hh { + for c in 0..ch { + e += (a[i * ch + c] - b[i * ch + c]).powi(2); + n += 1; + } + } + } + (e / n as f32).sqrt() + } + + #[test] + fn completion_fills_blemish_from_texture() { + let (w, h, ch) = (96, 96, 3); + let clean = texture(w, h); + let hole = disc(w, h, 48.0, 48.0, 9.0); + let mut dirty = clean.clone(); + for (i, hh) in hole.iter().enumerate() { + if *hh { + dirty[i * ch..i * ch + ch].copy_from_slice(&[0.02, 0.02, 0.02]); + } + } + let out = complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap(); + let before = hole_rmse(&dirty, &clean, &hole, ch); + let after = hole_rmse(&out, &clean, &hole, ch); + assert!(after < 0.06 && after < before * 0.15, "rmse before {before} after {after}"); + // Outside the hole untouched. + assert!(out.iter().zip(&dirty).zip(hole.iter().flat_map(|h| [*h; 3])).all(|((a, b), h)| h || a == b)); + // Deterministic. + assert_eq!(out, complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap()); + } + + #[test] + fn completion_continues_structure() { + // Vertical stripes: the filled hole should keep the stripes (not a blur). + let (w, h, ch) = (80, 80, 1); + let clean: Vec = (0..w * h).map(|i| if (i % w / 4) % 2 == 0 { 0.2 } else { 0.8 }).collect(); + let hole = disc(w, h, 40.0, 40.0, 7.0); + let dirty: Vec = clean.iter().zip(&hole).map(|(c, h)| if *h { 0.5 } else { *c }).collect(); + let out = complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap(); + let err = hole_rmse(&out, &clean, &hole, ch); + assert!(err < 0.15, "{err}"); + } +} diff --git a/crates/lensdb/Cargo.toml b/crates/lensdb/Cargo.toml new file mode 100644 index 000000000..0896c2dcc --- /dev/null +++ b/crates/lensdb/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lightcraft-lensdb" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Lens correction profiles: distortion, TCA and vignetting models, lensfun-format database loader (L2)" + +[lints] +workspace = true + +[dependencies] +lightcraft-develop = { workspace = true } +lightcraft-geom = { workspace = true } +quick-xml = { workspace = true } diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs new file mode 100644 index 000000000..fca9f9dca --- /dev/null +++ b/crates/lensdb/src/lib.rs @@ -0,0 +1,458 @@ +//! Lens correction profiles: distortion, transverse chromatic aberration (TCA) and vignetting. +//! +//! - [`model`]: the radial models (distortion poly3 / poly5 / PTLens, TCA linear / poly3, vignetting `pa`), and the +//! least-squares conversion to the DNG opcode polynomials (`WarpRectilinear`, `FixVignetteRadial`) the pipeline +//! already applies in its single geometry resample. +//! - [`LensDb`]: profiles read from lensfun-format XML files in a directory the user supplies. The lensfun database +//! is CC-BY-SA; it is never bundled with the code or the binary, only read at run time from where the user put it. +//! - [`LensDb::find_camera`] / [`LensDb::find_lens`]: identification from EXIF make / model / lens model strings, +//! with mount and crop-factor handling. +//! - [`Profile::correction`]: the profile at a shot's focal length (and aperture / distance for vignetting), +//! interpolated between calibrated points, expressed as [`lightcraft_develop::EmbeddedLens`] for a given image. +//! +//! Sources: the lensfun database format and model formulas as described in lensfun's published documentation +//! (database file format and lens calibration pages; no lensfun code was read), Panorama Tools' distortion model +//! (a, b, c) as documented by the PanoTools wiki, Adobe DNG specification 1.6 (opcodes `WarpRectilinear`, +//! `FixVignetteRadial`). Interpolation and fitting: own design. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod model; +mod xml; + +use std::path::Path; + +use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; +use lightcraft_geom::Point; +pub use model::{Distortion, Tca, Vignetting}; + +/// Largest database file read (lensfun's biggest file is well under 1 MB). +const MAX_FILE_BYTES: u64 = 16 << 20; +/// Most files read from one directory. +const MAX_FILES: usize = 2000; +/// Largest radial error (half-diagonal units) accepted when converting a distortion to the DNG polynomial. +pub const MAX_WARP_ERROR: f64 = 0.001; +/// Largest relative error accepted when converting vignetting to the DNG polynomial. +pub const MAX_VIGNETTE_ERROR: f64 = 0.01; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + Io(String), + Xml(String), + TooLarge, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(e) => write!(f, "lens database: {e}"), + Error::Xml(e) => write!(f, "lens database: invalid XML: {e}"), + Error::TooLarge => write!(f, "lens database: file too large or too deeply nested"), + } + } +} + +impl std::error::Error for Error {} + +/// A camera body: what is needed to pick lenses (mount) and scale profiles (crop factor). +#[derive(Clone, Debug, PartialEq)] +pub struct Camera { + pub maker: String, + pub model: String, + pub mount: String, + pub crop_factor: f64, +} + +/// One calibrated vignetting point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VignettingPoint { + pub focal: f64, + pub aperture: f64, + /// Focus distance, metres (lensfun uses 1000 for infinity). + pub distance: f64, + pub model: Vignetting, +} + +/// A lens profile: names, mounts, the calibration frame and the calibrated points. +#[derive(Clone, Debug, PartialEq)] +pub struct Profile { + pub maker: String, + /// Every name the lens goes by (EXIF variants, translations). + pub models: Vec, + pub mounts: Vec, + /// Crop factor of the camera the calibration was made on. + pub crop_factor: f64, + /// Long / short side of the calibration frame. + pub aspect_ratio: f64, + /// False for fisheye and other non-rectilinear projections: distortion is then not applied. + pub rectilinear: bool, + /// Sorted by focal length. + pub distortion: Vec<(f64, Distortion)>, + pub tca: Vec<(f64, Tca)>, + pub vignetting: Vec, +} + +/// The shot a correction is wanted for. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shot { + /// Focal length, mm. + pub focal: f64, + pub aperture: Option, + /// Focus distance, metres. + pub distance: Option, + /// Crop factor of the camera (from [`LensDb::find_camera`]); the profile's own when unknown. + pub crop_factor: Option, + /// Image size in pixels (the frame the correction is expressed for; the optical centre is its centre). + pub width: f64, + pub height: f64, +} + +/// A set of lens and camera profiles. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LensDb { + pub cameras: Vec, + pub lenses: Vec, +} + +impl LensDb { + /// Read every `*.xml` file in `dir` (lensfun database format). Unreadable or invalid files are skipped and + /// reported in the second value; only a directory that can't be listed is an error. + pub fn load_dir(dir: &Path) -> Result<(LensDb, Vec<(String, Error)>), Error> { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| Error::Io(format!("{}: {e}", dir.display())))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("xml"))) + .collect(); + entries.sort(); + entries.truncate(MAX_FILES); + let mut db = LensDb::default(); + let mut errors = Vec::new(); + for path in entries { + let name = path.display().to_string(); + match read_capped(&path).and_then(|s| LensDb::parse(&s)) { + Ok(part) => db.extend(part), + Err(e) => errors.push((name, e)), + } + } + Ok((db, errors)) + } + + /// Parse one lensfun-format XML document. + pub fn parse(xml: &str) -> Result { + let root = xml::parse(xml)?; + let Some(top) = root.kid("lensdatabase") else { return Err(Error::Xml("no element".into())) }; + let cameras = top.kids("camera").filter_map(camera).collect(); + let lenses = top.kids("lens").flat_map(lens).collect(); + Ok(LensDb { cameras, lenses }) + } + + pub fn extend(&mut self, other: LensDb) { + self.cameras.extend(other.cameras); + self.lenses.extend(other.lenses); + } + + pub fn is_empty(&self) -> bool { + self.cameras.is_empty() && self.lenses.is_empty() + } + + /// The camera with this EXIF make and model. + pub fn find_camera(&self, make: &str, model: &str) -> Option<&Camera> { + let (mk, md) = (tokens(make), tokens(model)); + if md.is_empty() { + return None; + } + self.cameras.iter().find(|c| tokens(&c.model) == md && maker_matches(&tokens(&c.maker), &mk)) + } + + /// The best profile for an EXIF lens model (and lens make, when the file has one), preferring lenses for the + /// camera's mount. `None` when no name is a convincing match. + pub fn find_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>) -> Option<&Profile> { + let exif = tokens(lens_model); + if exif.is_empty() { + return None; + } + let make = lens_make.map(tokens).unwrap_or_default(); + let mut best: Option<(f64, &Profile)> = None; + for lens in &self.lenses { + let maker = tokens(&lens.maker); + if !make.is_empty() && !maker.is_empty() && !maker_matches(&maker, &make) { + continue; + } + let mut score = lens.models.iter().map(|m| name_score(&tokens(m), &exif, &maker)).fold(0.0, f64::max); + if let Some(cam) = camera + && !lens.mounts.is_empty() + && !lens.mounts.iter().any(|m| m.eq_ignore_ascii_case(&cam.mount)) + { + score *= 0.8; + } + if score >= 0.6 && best.is_none_or(|(s, _)| score > s) { + best = Some((score, lens)); + } + } + best.map(|(_, l)| l) + } +} + +fn read_capped(path: &Path) -> Result { + let len = std::fs::metadata(path).map_err(|e| Error::Io(e.to_string()))?.len(); + if len > MAX_FILE_BYTES { + return Err(Error::TooLarge); + } + let bytes = std::fs::read(path).map_err(|e| Error::Io(e.to_string()))?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Lower-case word tokens; letters and digits are split ("24-105mm" → 24, 105, mm), decimals kept ("2.8"). +fn tokens(s: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut cur = String::new(); + let mut prev_digit: Option = None; + for c in s.chars().flat_map(char::to_lowercase) { + let digit = c.is_ascii_digit() || (c == '.' && prev_digit == Some(true)); + if c.is_alphanumeric() || digit { + if prev_digit.is_some_and(|p| p != digit) && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + cur.push(c); + prev_digit = Some(digit); + } else { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + prev_digit = None; + } + } + if !cur.is_empty() { + out.push(cur); + } + for t in &mut out { + // "4.0" and "4" are the same aperture + if t.contains('.') && t.starts_with(|c: char| c.is_ascii_digit()) { + let trimmed = t.trim_end_matches('0').trim_end_matches('.'); + *t = trimmed.to_string(); + } + } + out.retain(|t| !t.is_empty()); + out +} + +fn is_number(t: &str) -> bool { + t.starts_with(|c: char| c.is_ascii_digit()) +} + +/// Makers match when one's first word appears in the other ("NIKON CORPORATION" / "Nikon"). +fn maker_matches(a: &[String], b: &[String]) -> bool { + match (a.first(), b.first()) { + (Some(x), Some(y)) => b.contains(x) || a.contains(y), + _ => true, + } +} + +/// How well a database name matches the EXIF name, 0..1. Every number in the database name (focal lengths, +/// apertures) must appear in the EXIF name; the maker's words are ignored on both sides (EXIF often omits them). +fn name_score(db: &[String], exif: &[String], maker: &[String]) -> f64 { + let db: Vec<&String> = db.iter().filter(|t| !maker.contains(t)).collect(); + let exif: Vec<&String> = exif.iter().filter(|t| !maker.contains(t)).collect(); + if db.is_empty() || exif.is_empty() { + return 0.0; + } + if db.iter().any(|t| is_number(t) && !exif.contains(t)) || exif.iter().any(|t| is_number(t) && !db.contains(t)) { + return 0.0; + } + let common = db.iter().filter(|t| exif.contains(t)).count() as f64; + common / db.len().max(exif.len()) as f64 +} + +fn camera(n: &xml::Node) -> Option { + Some(Camera { + maker: n.kid_text("maker")?.to_string(), + model: n.kid_text("model")?.to_string(), + mount: n.kid_text("mount").unwrap_or_default().to_string(), + crop_factor: crop_of(n.kid_text("cropfactor")).unwrap_or(1.0), + }) +} + +fn crop_of(s: Option<&str>) -> Option { + s.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite() && (0.05..=20.0).contains(v)) +} + +fn aspect_of(s: Option<&str>) -> Option { + let s = s?.trim(); + let v = match s.split_once(':') { + Some((a, b)) => a.trim().parse::().ok()? / b.trim().parse::().ok()?, + None => s.parse::().ok()?, + }; + let v = if v < 1.0 { 1.0 / v } else { v }; + (v.is_finite() && (1.0..=10.0).contains(&v)).then_some(v) +} + +fn positive(v: Option) -> Option { + v.filter(|v| *v > 0.0 && *v < 1e6) +} + +/// One `` element → one profile per `` (newer files give each its own crop factor and aspect). +fn lens(n: &xml::Node) -> Vec { + let Some(maker) = n.kid_text("maker") else { return Vec::new() }; + let models: Vec = n.kids("model").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect(); + if models.is_empty() { + return Vec::new(); + } + let crop = crop_of(n.kid_text("cropfactor")).unwrap_or(1.0); + let aspect = aspect_of(n.kid_text("aspect-ratio")).unwrap_or(1.5); + let rectilinear = n.kid_text("type").is_none_or(|t| t.eq_ignore_ascii_case("rectilinear")); + let mounts = n.kids("mount").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect::>(); + n.kids("calibration") + .map(|cal| { + let mut p = Profile { + maker: maker.to_string(), + models: models.clone(), + mounts: mounts.clone(), + crop_factor: crop_of(cal.attr("cropfactor")).unwrap_or(crop), + aspect_ratio: aspect_of(cal.attr("aspect-ratio")).unwrap_or(aspect), + rectilinear, + distortion: Vec::new(), + tca: Vec::new(), + vignetting: Vec::new(), + }; + for c in &cal.children { + let Some(focal) = positive(c.num("focal")) else { continue }; + let k = |name: &str| c.num(name).unwrap_or(0.0); + match (c.name.as_str(), c.attr("model")) { + ("distortion", Some("poly3")) => p.distortion.push((focal, Distortion::Poly3 { k1: k("k1") })), + ("distortion", Some("poly5")) => p.distortion.push((focal, Distortion::Poly5 { k1: k("k1"), k2: k("k2") })), + ("distortion", Some("ptlens")) => p.distortion.push((focal, Distortion::PtLens { a: k("a"), b: k("b"), c: k("c") })), + ("tca", Some("linear")) => { + let one = |name: &str| c.num(name).unwrap_or(1.0); + p.tca.push((focal, Tca::Linear { kr: one("kr"), kb: one("kb") })); + } + ("tca", Some("poly3")) => { + let one = |name: &str| c.num(name).unwrap_or(1.0); + p.tca.push((focal, Tca::Poly3 { vr: one("vr"), vb: one("vb"), cr: k("cr"), cb: k("cb"), br: k("br"), bb: k("bb") })); + } + ("vignetting", Some("pa")) => { + let (Some(aperture), distance) = (positive(c.num("aperture")), positive(c.num("distance")).unwrap_or(1000.0)) else { + continue; + }; + let model = Vignetting { k1: k("k1"), k2: k("k2"), k3: k("k3") }; + p.vignetting.push(VignettingPoint { focal, aperture, distance, model }); + } + _ => {} + } + } + p.distortion.sort_by(|a, b| a.0.total_cmp(&b.0)); + p.tca.sort_by(|a, b| a.0.total_cmp(&b.0)); + p + }) + .filter(|p| !p.distortion.is_empty() || !p.tca.is_empty() || !p.vignetting.is_empty()) + .collect() +} + +/// Linear interpolation weights in a sorted key list: the bracketing indices and the weight of the upper one. +fn bracket(keys: &[f64], x: f64) -> Option<(usize, usize, f64)> { + let last = keys.len().checked_sub(1)?; + let hi = keys.iter().position(|&k| k >= x).unwrap_or(last); + let lo = hi.saturating_sub(1); + let (a, b) = (keys.get(lo).copied()?, keys.get(hi).copied()?); + if x <= a || b <= a { + return Some((lo, lo, 0.0)); + } + if x >= b { + return Some((hi, hi, 0.0)); + } + Some((lo, hi, (x - a) / (b - a))) +} + +/// A value interpolated in focal length between calibrated points, by evaluating both neighbours. +fn by_focal(points: &[(f64, T)], focal: f64, eval: impl Fn(&T) -> f64) -> Option { + let keys: Vec = points.iter().map(|p| p.0).collect(); + let (lo, hi, t) = bracket(&keys, focal)?; + let (a, b) = (eval(&points.get(lo)?.1), eval(&points.get(hi)?.1)); + Some(a + (b - a) * t) +} + +impl Profile { + /// Distortion ratio `r_d / r_u` at lensfun radius `r` for `focal` (1 when the profile has none). + pub fn distortion_ratio(&self, focal: f64, r: f64) -> f64 { + if !self.rectilinear { + return 1.0; + } + by_focal(&self.distortion, focal, |d| d.ratio(r)).unwrap_or(1.0) + } + + /// TCA ratio for channel `ch` at lensfun radius `r` for `focal`. + pub fn tca_ratio(&self, focal: f64, ch: usize, r: f64) -> f64 { + by_focal(&self.tca, focal, |t| t.ratio(ch, r)).unwrap_or(1.0) + } + + /// Vignetting attenuation at radius `r` (half-diagonal units): linear in focal length, in stops of aperture and + /// in 1 / distance between the calibrated points. + pub fn vignetting_attenuation(&self, focal: f64, aperture: Option, distance: Option, r: f64) -> Option { + let mut focals: Vec = self.vignetting.iter().map(|v| v.focal).collect(); + focals.sort_by(f64::total_cmp); + focals.dedup(); + let (lo, hi, t) = bracket(&focals, focal)?; + let at = |f: f64| -> Option { + let pts: Vec<&VignettingPoint> = self.vignetting.iter().filter(|v| v.focal == f).collect(); + // the calibrated distance nearest the shot's (in 1/d; infinity when unknown) + let inv = |d: f64| 1.0 / d.max(0.01); + let want = distance.map_or(0.0, inv); + let near = pts.iter().map(|v| v.distance).min_by(|a, b| (inv(*a) - want).abs().total_cmp(&(inv(*b) - want).abs()))?; + let mut at_d: Vec<&&VignettingPoint> = pts.iter().filter(|v| v.distance == near).collect(); + at_d.sort_by(|a, b| a.aperture.total_cmp(&b.aperture)); + let stops: Vec = at_d.iter().map(|v| v.aperture.log2()).collect(); + // unknown aperture: the widest calibrated one (the strongest falloff) + let n = aperture.filter(|a| *a > 0.0).map_or(stops.first().copied()?, f64::log2); + let (i, j, u) = bracket(&stops, n)?; + let (a, b) = (at_d.get(i)?.model.attenuation(r), at_d.get(j)?.model.attenuation(r)); + Some(a + (b - a) * u) + }; + let (a, b) = (at(*focals.get(lo)?)?, at(*focals.get(hi)?)?); + Some(a + (b - a) * t) + } + + /// The correction for `shot`, as the DNG opcode polynomials the pipeline applies, centred on the image. + /// Distortion and TCA are combined per plane (R, G, B); a model the polynomial can't follow closely + /// ([`MAX_WARP_ERROR`], [`MAX_VIGNETTE_ERROR`]) or that would fold the image is left out. + pub fn correction(&self, shot: &Shot) -> Option { + let (w, h) = (shot.width, shot.height); + if !(w >= 2.0 && h >= 2.0 && w.is_finite() && h.is_finite() && shot.focal.is_finite() && shot.focal > 0.0) { + return None; + } + let cam = shot.crop_factor.filter(|c| c.is_finite() && *c > 0.0).unwrap_or(self.crop_factor); + // DNG radius (1 = image half diagonal) → lensfun radii + let to_vig = self.crop_factor / cam; + let to_dist = to_vig * (1.0 + self.aspect_ratio * self.aspect_ratio).sqrt(); + let center = Point::new(0.5, 0.5); + let radius = w.hypot(h) / 2.0 / w.max(h); + let f = shot.focal; + let mut lens = EmbeddedLens::default(); + let has_dist = self.rectilinear && !self.distortion.is_empty(); + if has_dist || !self.tca.is_empty() { + let plane = |ch: usize| -> Option<[f64; 6]> { + let ratio = |r: f64| { + let rl = r * to_dist; + let d = self.distortion_ratio(f, rl); + d * self.tca_ratio(f, ch, rl * d) + }; + let (k, err) = model::fit_even4(ratio)?; + (err <= MAX_WARP_ERROR).then_some([k[0], k[1], k[2], k[3], 0.0, 0.0]) + }; + if let (Some(r), Some(g), Some(b)) = (plane(0), plane(1), plane(2)) { + lens.warp = Some(EmbeddedWarp { planes: [r, g, b], center, radius }); + } + } + if !self.vignetting.is_empty() { + let att = |r: f64| self.vignetting_attenuation(f, shot.aperture, shot.distance, r * to_vig).unwrap_or(1.0); + if let Some((k, err)) = model::fit_gain5(|r| 1.0 / att(r).max(0.05)) + && err <= MAX_VIGNETTE_ERROR + { + lens.vignette = Some(EmbeddedVignette { k, center, radius }); + } + } + (lens.warp.is_some() || lens.vignette.is_some()).then_some(lens) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/lensdb/src/model.rs b/crates/lensdb/src/model.rs new file mode 100644 index 000000000..b7ade6b43 --- /dev/null +++ b/crates/lensdb/src/model.rs @@ -0,0 +1,192 @@ +//! The correction models and their conversion to the DNG-style polynomials the pipeline applies. +//! +//! All models are functions of a normalized radius `r`. Following the lensfun database documentation, distortion and +//! TCA use `r = 1` at half the shorter side of the calibration frame, vignetting `r = 1` at half its diagonal (the +//! corner). Distortion and TCA map a corrected (undistorted) radius to the radius where the light actually landed, +//! which is the inverse map a resampler needs. + +/// Radial distortion: `r_d = r_u · ratio(r_u)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Distortion { + /// `r_d = r_u · (1 − k1 + k1·r_u²)`. + Poly3 { k1: f64 }, + /// `r_d = r_u · (1 + k1·r_u² + k2·r_u⁴)`. + Poly5 { k1: f64, k2: f64 }, + /// Panorama Tools: `r_d = r_u · (a·r_u³ + b·r_u² + c·r_u + 1 − a − b − c)`. + PtLens { a: f64, b: f64, c: f64 }, +} + +impl Distortion { + /// `r_d / r_u` at `r_u`. + pub fn ratio(&self, r: f64) -> f64 { + match *self { + Distortion::Poly3 { k1 } => 1.0 - k1 + k1 * r * r, + Distortion::Poly5 { k1, k2 } => { + let r2 = r * r; + 1.0 + k1 * r2 + k2 * r2 * r2 + } + Distortion::PtLens { a, b, c } => ((a * r + b) * r + c) * r + 1.0 - a - b - c, + } + } +} + +/// Transverse (lateral) chromatic aberration of red and blue relative to green: `r_ch = r_g · ratio(ch, r_g)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Tca { + /// `r_ch = r_g · k`. + Linear { kr: f64, kb: f64 }, + /// `r_ch = r_g · (b·r_g² + c·r_g + v)`, per channel. + Poly3 { vr: f64, vb: f64, cr: f64, cb: f64, br: f64, bb: f64 }, +} + +impl Tca { + /// `r_ch / r_g` for `ch` 0 (red), 2 (blue); 1 for green. + pub fn ratio(&self, ch: usize, r: f64) -> f64 { + match (*self, ch) { + (_, 1) => 1.0, + (Tca::Linear { kr, .. }, 0) => kr, + (Tca::Linear { kb, .. }, _) => kb, + (Tca::Poly3 { vr, cr, br, .. }, 0) => (br * r + cr) * r + vr, + (Tca::Poly3 { vb, cb, bb, .. }, _) => (bb * r + cb) * r + vb, + } + } +} + +/// Vignetting, `pa` model: the recorded brightness is the true brightness times `1 + k1·r² + k2·r⁴ + k3·r⁶`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Vignetting { + pub k1: f64, + pub k2: f64, + pub k3: f64, +} + +impl Vignetting { + pub fn attenuation(&self, r: f64) -> f64 { + let r2 = r * r; + 1.0 + r2 * (self.k1 + r2 * (self.k2 + r2 * self.k3)) + } +} + +/// Least squares `Σ w (Σ_j c_j·basis_j(x) − y)²` for small dense systems (normal equations, partial pivoting). +/// `None` when the system is singular or a value isn't finite. +pub(crate) fn least_squares(samples: &[([f64; N], f64)]) -> Option<[f64; N]> { + let mut a = [[0.0; N]; N]; + let mut b = [0.0; N]; + for (basis, y) in samples { + for i in 0..N { + b[i] += basis[i] * y; + for j in 0..N { + a[i][j] += basis[i] * basis[j]; + } + } + } + for col in 0..N { + let pivot = (col..N).max_by(|&x, &y| a[x][col].abs().total_cmp(&a[y][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-14 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in 0..N { + if row != col { + let f = a[row][col] / a[col][col]; + for k in col..N { + a[row][k] -= f * a[col][k]; + } + b[row] -= f * b[col]; + } + } + } + let mut x = [0.0; N]; + for i in 0..N { + x[i] = b[i] / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// Fit `ratio(r) ≈ k0 + k1·r² + k2·r⁴ + k3·r⁶` over `r ∈ [0, 1]` (the DNG `WarpRectilinear` radial form). +/// Returns the coefficients and the largest radial error `max |r·(fit − ratio)|`, in the same units as `r`. +pub fn fit_even4(ratio: impl Fn(f64) -> f64) -> Option<([f64; 4], f64)> { + let pts: Vec = (0..=SAMPLES).map(|i| i as f64 / SAMPLES as f64).collect(); + let samples: Vec<([f64; 4], f64)> = pts + .iter() + .map(|&r| { + let r2 = r * r; + // weight by r: the error that matters is the displacement r·Δratio + ([r, r * r2, r * r2 * r2, r * r2 * r2 * r2], r * ratio(r)) + }) + .collect(); + let k = least_squares(&samples)?; + let eval = |r: f64| { + let r2 = r * r; + k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3])) + }; + let err = pts.iter().map(|&r| (r * (eval(r) - ratio(r))).abs()).fold(0.0, f64::max); + // the warp must stay monotonic (no folding): r·fit(r) increasing + let mono = pts.windows(2).all(|w| w[1] * eval(w[1]) > w[0] * eval(w[0])); + (err.is_finite() && mono).then_some((k, err)) +} + +/// Fit `gain(r) ≈ 1 + k0·r² + … + k4·r¹⁰` over `r ∈ [0, 1]` (the DNG `FixVignetteRadial` form). +/// Returns the coefficients and the largest relative gain error. +pub fn fit_gain5(gain: impl Fn(f64) -> f64) -> Option<([f64; 5], f64)> { + let pts: Vec = (0..=SAMPLES).map(|i| i as f64 / SAMPLES as f64).collect(); + let samples: Vec<([f64; 5], f64)> = pts + .iter() + .map(|&r| { + let r2 = r * r; + ([r2, r2 * r2, r2.powi(3), r2.powi(4), r2.powi(5)], gain(r) - 1.0) + }) + .collect(); + let k = least_squares(&samples)?; + let eval = |r: f64| { + let r2 = r * r; + 1.0 + r2 * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * (k[3] + r2 * k[4])))) + }; + let err = pts.iter().map(|&r| ((eval(r) - gain(r)) / gain(r).abs().max(1e-9)).abs()).fold(0.0, f64::max); + err.is_finite().then_some((k, err)) +} + +const SAMPLES: usize = 64; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn models_are_identity_at_centre_and_documented_at_one() { + assert_eq!(Distortion::Poly3 { k1: 0.1 }.ratio(1.0), 1.0, "poly3 keeps r = 1"); + assert!((Distortion::Poly5 { k1: 0.1, k2: 0.01 }.ratio(1.0) - 1.11).abs() < 1e-12); + assert!((Distortion::PtLens { a: 0.01, b: -0.02, c: 0.03 }.ratio(1.0) - 1.0).abs() < 1e-12, "ptlens keeps r = 1"); + assert_eq!(Tca::Linear { kr: 1.001, kb: 0.999 }.ratio(0, 0.5), 1.001); + assert_eq!(Tca::Poly3 { vr: 1.0, vb: 1.0, cr: 0.0, cb: 0.0, br: 0.001, bb: 0.0 }.ratio(1, 0.7), 1.0); + assert!((Vignetting { k1: -0.3, k2: 0.0, k3: 0.0 }.attenuation(1.0) - 0.7).abs() < 1e-12); + } + + #[test] + fn even_fit_is_exact_for_even_models_and_close_for_ptlens() { + let (k, err) = fit_even4(|r| Distortion::Poly5 { k1: -0.05, k2: 0.01 }.ratio(r)).unwrap(); + assert!(err < 1e-9, "{err}"); + assert!((k[0] - 1.0).abs() < 1e-9 && (k[1] + 0.05).abs() < 1e-9 && (k[2] - 0.01).abs() < 1e-9); + let (_, err) = fit_even4(|r| Distortion::PtLens { a: 0.01, b: -0.03, c: 0.0 }.ratio(r)).unwrap(); + assert!(err < 2e-4, "{err}"); + } + + #[test] + fn folding_maps_are_refused() { + assert!(fit_even4(|r| 1.0 - 2.0 * r * r).is_none()); + } + + #[test] + fn gain_fit_matches_inverse_attenuation() { + let v = Vignetting { k1: -0.4, k2: 0.1, k3: -0.02 }; + let (_, err) = fit_gain5(|r| 1.0 / v.attenuation(r)).unwrap(); + assert!(err < 1e-3, "{err}"); + } + + #[test] + fn singular_systems_are_none() { + assert!(least_squares::<2>(&[([1.0, 1.0], 1.0), ([2.0, 2.0], 2.0)]).is_none()); + assert!(least_squares::<1>(&[]).is_none()); + } +} diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs new file mode 100644 index 000000000..d15106931 --- /dev/null +++ b/crates/lensdb/src/tests.rs @@ -0,0 +1,245 @@ +use super::*; + +/// A synthetic database in the lensfun format (made-up coefficients, not lensfun data). +const DB: &str = r#" + + Test Mount + + Testcam Corporation + TC-1 + Test Mount + 1.5 + + + Testcam + TC-FF + Test Mount + 1 + + + Testcam + Testcam 24-70mm f/2.8 & Co + Testcam 24-70mm f/2.8 Zoom + Test Mount + 1 + + + + + + + + + + + + Testcam + Testcam 50mm f/1.8 + Other Mount + + + + + + Fishco + Fishco 8mm f/3.5 Fisheye + equisolid + + + + + BrokenBroken 1mm +"#; + +fn db() -> LensDb { + LensDb::parse(DB).unwrap() +} + +fn shot(focal: f64) -> Shot { + Shot { focal, aperture: Some(2.8), distance: None, crop_factor: Some(1.0), width: 6000.0, height: 4000.0 } +} + +/// The DNG `WarpRectilinear` source position (the pipeline's formula, radial part). +fn dng_source(wp: &EmbeddedWarp, ch: usize, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { + let (cx, cy) = (wp.center.x * w, wp.center.y * h); + let m = wp.radius * w.max(h); + let (dx, dy) = ((x - cx) / m, (y - cy) / m); + let r2 = dx * dx + dy * dy; + let [k0, k1, k2, k3, ..] = wp.planes[ch]; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + (cx + dx * f * m, cy + dy * f * m) +} + +#[test] +fn parses_cameras_lenses_and_skips_invalid_entries() { + let db = db(); + assert_eq!(db.cameras.len(), 2); + assert_eq!(db.lenses.len(), 3, "the lens without valid calibration points is dropped"); + let z = &db.lenses[0]; + assert_eq!(z.models[0], "Testcam 24-70mm f/2.8 & Co", "entities decoded"); + assert_eq!(z.distortion.len(), 2); + assert_eq!(z.tca.len(), 2); + assert_eq!(z.vignetting.len(), 3); + assert_eq!(z.aspect_ratio, 1.5); + assert!(!db.lenses[2].rectilinear); +} + +#[test] +fn malformed_documents_are_errors_not_panics() { + assert!(LensDb::parse("").is_err()); + assert!(LensDb::parse("").is_ok_and(|d| d.lenses.is_empty())); + assert!(LensDb::parse("").is_err()); + let deep = "".repeat(100); + assert_eq!(LensDb::parse(&deep), Err(Error::TooLarge)); +} + +#[test] +fn finds_cameras_by_exif_strings() { + let db = db(); + assert_eq!(db.find_camera("TESTCAM CORPORATION", "TC-1").unwrap().crop_factor, 1.5); + assert_eq!(db.find_camera("Testcam", "tc-ff").unwrap().crop_factor, 1.0); + assert!(db.find_camera("Testcam", "TC-2").is_none()); + assert!(db.find_camera("Other", "TC-1").is_none()); +} + +#[test] +fn finds_lenses_by_exif_model_with_or_without_the_maker() { + let db = db(); + let cam = db.find_camera("Testcam", "TC-FF"); + let l = db.find_lens(None, "24-70mm F2.8 Zoom", cam).unwrap(); + assert_eq!(l.maker, "Testcam"); + assert!(db.find_lens(Some("Testcam"), "Testcam 50mm F1.8", cam).is_some()); + assert!(db.find_lens(None, "24-105mm F4", cam).is_none(), "focal lengths must agree"); + assert!(db.find_lens(Some("Otherco"), "24-70mm f/2.8 Zoom", cam).is_none(), "lens maker must agree"); + assert!(db.find_lens(None, "", cam).is_none()); +} + +#[test] +fn distortion_matches_the_model_within_half_a_pixel_on_a_grid() { + let db = db(); + let lens = &db.lenses[0]; + let (w, h) = (6000.0, 4000.0); + for focal in [24.0, 40.0, 70.0] { + let wp = lens.correction(&shot(focal)).unwrap().warp.unwrap(); + // lensfun normalisation: r = 1 at half the shorter side (full frame, 3:2) + let n = h / 2.0; + let mut worst: f64 = 0.0; + for gy in 0..=20 { + for gx in 0..=30 { + let (x, y) = (gx as f64 * w / 30.0, gy as f64 * h / 20.0); + let (sx, sy) = dng_source(&wp, 1, x, y, w, h); + let (dx, dy) = ((x - w / 2.0) / n, (y - h / 2.0) / n); + let r = dx.hypot(dy); + let k = lens.distortion_ratio(focal, r); + let (ex, ey) = (w / 2.0 + dx * k * n, h / 2.0 + dy * k * n); + worst = worst.max((sx - ex).hypot(sy - ey)); + } + } + assert!(worst < 0.5, "{focal} mm: {worst} px"); + } +} + +#[test] +fn straight_lines_stay_straight_after_correction() { + // A synthetic barrel-distorted chart: the camera recorded straight scene lines at r_d = r_u·ratio(r_u). + // Correcting means: for each output point of a straight line, the warp fetches the recorded point, which must + // be that line's recorded (curved) image. So sample the curved recorded line by inverting the model, then + // check the warp sends straight output points onto it. + let db = db(); + let lens = &db.lenses[0]; + let (w, h) = (6000.0, 4000.0); + let wp = lens.correction(&shot(24.0)).unwrap().warp.unwrap(); + let n = h / 2.0; + let y_line = 300.0; // a horizontal line near the top edge + let mut dev: f64 = 0.0; + for i in 0..=60 { + let x = i as f64 * w / 60.0; + let (sx, sy) = dng_source(&wp, 1, x, y_line, w, h); + // undo the recorded distortion of (sx, sy): find r_u with r_u·ratio(r_u) = r_d (bisection) + let (dx, dy) = ((sx - w / 2.0) / n, (sy - h / 2.0) / n); + let rd = dx.hypot(dy); + let (mut lo, mut hi) = (0.0, 3.0); + for _ in 0..80 { + let mid = (lo + hi) / 2.0; + if mid * lens.distortion_ratio(24.0, mid) < rd { + lo = mid; + } else { + hi = mid; + } + } + let s = if rd > 0.0 { lo / rd } else { 1.0 }; + let uy = h / 2.0 + dy * s * n; + dev = dev.max((uy - y_line).abs()); + } + assert!(dev < 0.5, "line bent by {dev} px"); +} + +#[test] +fn tca_scales_red_and_blue_planes() { + let db = db(); + let wp = db.lenses[0].correction(&shot(24.0)).unwrap().warp.unwrap(); + let (w, h) = (6000.0, 4000.0); + let (rx, _) = dng_source(&wp, 0, w, h / 2.0, w, h); + let (gx, _) = dng_source(&wp, 1, w, h / 2.0, w, h); + let (bx, _) = dng_source(&wp, 2, w, h / 2.0, w, h); + assert!(((rx - w / 2.0) / (gx - w / 2.0) - 1.0004).abs() < 1e-5, "{rx} {gx}"); + assert!(((bx - w / 2.0) / (gx - w / 2.0) - 0.9997).abs() < 1e-5, "{bx} {gx}"); +} + +#[test] +fn vignetting_interpolates_in_aperture_and_focal() { + let db = db(); + let l = &db.lenses[0]; + let at = |f: f64, n: f64| l.vignetting_attenuation(f, Some(n), None, 1.0).unwrap(); + assert!((at(24.0, 2.8) - 0.55).abs() < 1e-12); + assert!((at(24.0, 8.0) - 0.8).abs() < 1e-12); + let mid = at(24.0, 2.8 * 2f64.powf((8f64 / 2.8).log2() / 2.0)); + assert!((mid - 0.675).abs() < 1e-9, "{mid}"); + assert!((at(47.0, 2.8) - 0.625).abs() < 1e-9); + // the gain undoes the falloff at the corner + let v = l.correction(&shot(24.0)).unwrap().vignette.unwrap(); + let g: f64 = 1.0 + v.k.iter().enumerate().map(|(i, k)| k * 1f64.powi(2 * (i as i32 + 1))).sum::(); + assert!((g * 0.55 - 1.0).abs() < 0.01, "{g}"); +} + +#[test] +fn crop_factor_scales_the_profile() { + // On a 1.5× body the image covers the centre of the full-frame calibration: much less distortion at its corner. + let db = db(); + let l = &db.lenses[1]; + let ff = l.correction(&shot(50.0)).unwrap().warp.unwrap(); + let aps = l.correction(&Shot { crop_factor: Some(1.5), ..shot(50.0) }).unwrap().warp.unwrap(); + let corner = |wp: &EmbeddedWarp| dng_source(wp, 1, 0.0, 0.0, 6000.0, 4000.0); + let (a, b) = (corner(&ff), corner(&aps)); + let r = |(x, y): (f64, f64)| (x - 3000.0).hypot(y - 2000.0); + let rc = 3000f64.hypot(2000.0); + assert!((r(a) - rc).abs() > (r(b) - rc).abs() * 1.5, "{} {}", r(a) - rc, r(b) - rc); +} + +#[test] +fn fisheyes_get_no_rectilinear_warp_and_bad_shots_none() { + let db = db(); + assert!(db.lenses[2].correction(&shot(8.0)).is_none()); + assert!(db.lenses[0].correction(&Shot { width: f64::NAN, ..shot(24.0) }).is_none()); + assert!(db.lenses[0].correction(&Shot { focal: 0.0, ..shot(24.0) }).is_none()); +} + +#[test] +fn load_dir_reads_xml_files_and_reports_bad_ones() { + let dir = std::env::temp_dir().join(format!("lensdb-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.xml"), DB).unwrap(); + std::fs::write(dir.join("b.xml"), ", + pub text: String, + pub children: Vec, +} + +impl Node { + pub fn attr(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) + } + pub fn num(&self, k: &str) -> Option { + self.attr(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + } + pub fn kids<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children.iter().filter(move |c| c.name == name) + } + pub fn kid(&self, name: &str) -> Option<&Node> { + self.children.iter().find(|c| c.name == name) + } + pub fn kid_text(&self, name: &str) -> Option<&str> { + self.kid(name).map(|n| n.text.trim()).filter(|t| !t.is_empty()) + } +} + +fn entity(name: &str) -> Option { + Some(match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let n = name.strip_prefix('#')?; + let v = match n.strip_prefix('x') { + Some(h) => u32::from_str_radix(h, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(v)? + } + }) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + match tail.find(';').and_then(|j| entity(tail.get(1..j)?).map(|c| (c, j))) { + Some((c, j)) => { + out.push(c); + rest = tail.get(j + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + +pub(crate) fn parse(s: &str) -> Result { + let mut reader = quick_xml::Reader::from_str(s); + reader.config_mut().check_end_names = false; + let mut stack = vec![Node { name: "#root".into(), ..Default::default() }]; + let mut count = 0usize; + loop { + let ev = reader.read_event().map_err(|e| Error::Xml(e.to_string()))?; + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + count += 1; + if count > MAX_NODES || stack.len() > MAX_DEPTH { + return Err(Error::TooLarge); + } + let attrs = e + .attributes() + .with_checks(false) + .flatten() + .map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), unescape(&String::from_utf8_lossy(&a.value)))) + .collect(); + let node = Node { name: String::from_utf8_lossy(e.name().as_ref()).into_owned(), attrs, ..Default::default() }; + if matches!(ev, Event::Empty(_)) { + if let Some(p) = stack.last_mut() { + p.children.push(node); + } + } else { + stack.push(node); + } + } + Event::End(_) => close(&mut stack), + Event::Text(t) => { + if let Some(top) = stack.last_mut() { + top.text.push_str(&t.decode().map_err(|e| Error::Xml(e.to_string()))?); + } + } + Event::CData(t) => { + if let Some(top) = stack.last_mut() { + top.text.push_str(&String::from_utf8_lossy(&t)); + } + } + Event::GeneralRef(r) => { + if let (Some(top), Some(c)) = (stack.last_mut(), entity(&String::from_utf8_lossy(&r))) { + top.text.push(c); + } + } + Event::Eof => break, + _ => {} + } + } + while stack.len() > 1 { + close(&mut stack); + } + stack.pop().ok_or(Error::Xml("empty document".into())) +} + +fn close(stack: &mut Vec) { + if stack.len() > 1 + && let Some(n) = stack.pop() + && let Some(p) = stack.last_mut() + { + p.children.push(n); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d939002b9..a20dabba0 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,9 +32,8 @@ impl Drop for Headless { impl Default for Headless { fn default() -> Self { - Self::new( - Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(), - ) + let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(session.with_default_sam3_model().with_default_slide_templates()) } } @@ -45,14 +44,8 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { - Self::new( - Session::with_demo() - .with_fs() - .with_default_denoise_models() - .with_system_clock() - .with_default_face_models() - .with_default_slide_templates(), - ) + let session = Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(session.with_default_sam3_model().with_default_slide_templates()) } fn photo_or_active(&self, p: &Value) -> Result { @@ -140,7 +133,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } @@ -210,6 +203,20 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619). + #[test] + fn headless_sessions_get_the_sam3_model_folder() { + let dir = lightcraft_engine::config::default_sam3_dir(); + assert_eq!(Headless::default().session.segmenter.dir, dir); + assert_eq!(Headless::demo().session.segmenter.dir, dir); + let dir = std::env::temp_dir().join("lc-mcp-sam3-dir"); + let mut session = Session::new(); + session.segmenter.dir = Some(dir.clone()); + let mut h = Headless::new(session); + let r = h.call("engine.execute", json!({"command": "segment.model.status", "params": {}})).unwrap(); + assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}"); + } + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. #[test] fn headless_uses_the_system_clock() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index dd52da402..d20ae093f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, "destination": {"type": "string", "description": "Copy or move: destination folder"}, "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, - "onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"}, + "onDeleted": {"type": "string", "enum": ["restore", "skip", "fresh"], "description": "A file that is in Recently Deleted: restore (default) = bring the photo back with its edits (`restored` lists it; relinked to the file when its own is gone); skip = leave it there (the duplicate is reported with existingDeleted); fresh = delete the trashed record and import the file as a new photo"}, "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["paths"], @@ -258,8 +258,8 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, - "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, - "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} + "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit and PNG as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], ), diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs index 5fa6437bc..55b3ae6a6 100644 --- a/crates/meta/src/cr3.rs +++ b/crates/meta/src/cr3.rs @@ -226,7 +226,8 @@ impl Cr3<'_> { } const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; -const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; +/// The `uuid` of the top-level box holding the XMP packet. +pub const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; const MAX_DEPTH: usize = 12; const MAX_BOXES: usize = 4096; /// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. @@ -237,7 +238,8 @@ pub fn is_cr3(bytes: &[u8]) -> bool { bytes.get(4..12) == Some(b"ftypcrx ".as_slice()) } -/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. +/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. Of the top-level boxes only `moov` +/// and the XMP `uuid` are read (`lightcraft_raw::sparse` relies on it to skip the others). pub fn parse_cr3(bytes: &[u8]) -> Option> { if !is_cr3(bytes) { return None; diff --git a/crates/meta/src/xmp_merge.rs b/crates/meta/src/xmp_merge.rs index 6cbb6bdfc..022f962ec 100644 --- a/crates/meta/src/xmp_merge.rs +++ b/crates/meta/src/xmp_merge.rs @@ -19,7 +19,7 @@ //! by the removals is dropped, so repeated saves don't accumulate. The result is checked by //! parsing it again: every written property must read back as in the fresh packet. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use quick_xml::events::Event; @@ -114,9 +114,14 @@ fn scan(s: &str) -> Result { let empty = matches!(ev, Event::Empty(_)); let mut scope = Vec::new(); let mut raw_attrs = Vec::new(); - for a in e.attributes() { + // repeated names are refused with a set, in linear time (quick-xml's own check is quadratic) + let mut seen = HashSet::new(); + for a in e.attributes().with_checks(false) { let a = a.map_err(|e| XmpError::Xml(e.to_string()))?; let k = String::from_utf8_lossy(a.key.as_ref()).into_owned(); + if !seen.insert(k.clone()) { + return Err(XmpError::Xml(format!("repeated attribute {k}"))); + } let v = String::from_utf8_lossy(&a.value).into_owned(); if k == "xmlns" { scope.push((String::new(), v.clone())); @@ -394,10 +399,44 @@ mod tests { assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()])); } + #[test] + fn many_attributes_scan_in_linear_time() { + // quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on + // one description took over 20 s to scan, about 30 ms with the set + let n = 200_000; + let mut s = String::from( + "\ + "); + let t = std::time::Instant::now(); + let found = scan(&s).unwrap(); + let took = t.elapsed(); + assert_eq!(found.descs[0].attrs.len(), n + 2, "every attribute is read"); + assert!(took < std::time::Duration::from_secs(5), "{n} attributes took {took:?}"); + } + #[test] fn refuses_what_it_cannot_read() { for bad in ["", "not xml at all", "", ""] { assert!(merge_xmp(bad, &fresh(1, &[]), RULES).is_err(), "{bad:?}"); } } + + #[test] + fn refuses_a_repeated_attribute() { + // not well-formed XML (strict readers such as expat refuse it): no merge, so the caller keeps a backup. + // An owned name, a foreign one and one on a nested element (a merge would keep both copies of the last two). + for (once, twice) in [ + ("xmp:Rating=\"2\"", "xmp:Rating=\"2\" xmp:Rating=\"3\""), + ("foo:Thing=\"a & b\"", "foo:Thing=\"a & b\" foo:Thing=\"c\""), + ("stEvt:action=\"saved\"", "stEvt:action=\"saved\" stEvt:action=\"saved\""), + ] { + let ex = foreign().replace(once, twice); + assert!(merge_xmp(&ex, &fresh(1, &[]), RULES).is_err(), "{twice}"); + } + } } diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index c8219f025..197020e45 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -14,6 +14,7 @@ lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } lightcraft-raw = { workspace = true } +lightcraft-inpaint = { workspace = true } rayon = { workspace = true } serde = { workspace = true } diff --git a/crates/pipeline/src/blemish.rs b/crates/pipeline/src/blemish.rs new file mode 100644 index 000000000..61b3f2639 --- /dev/null +++ b/crates/pipeline/src/blemish.rs @@ -0,0 +1,288 @@ +//! Blemish detection on faces: small, round spots that are darker or redder than the skin around +//! them. Classical, in the spirit of [`crate::dust`]: a blurred copy gives the surrounding skin, +//! a spot has to stand out from both the surroundings and the local texture, and each candidate +//! blob must be small and roughly round. The face boxes and landmarks come from a face detector +//! (YuNet) so eyes, brows, nostrils and lips are never taken for blemishes. +//! +//! Deliberately conservative: a face with many candidates has stubble, freckles or pores, not +//! blemishes, and gets none; the rest are capped per face. Removing freckles is a different +//! feature and never a default. + +use lightcraft_raster::{Plane, Rgba8, blur::gaussian}; + +/// A face in fractions of the image (0..1, y down): its box and the five landmarks a YuNet +/// detector gives, in its order (right eye, left eye, nose tip, right and left mouth corner, +/// from the subject's point of view). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceBox { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, + pub landmarks: [(f64, f64); 5], +} + +/// A found blemish: which face, centre (0..1 of the image's width / height), radius (fraction +/// of the long edge) and how much it stands out (0..1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Blemish { + pub face: usize, + pub x: f64, + pub y: f64, + pub radius: f64, + pub strength: f32, +} + +/// Faces narrower than this (in pixels of the analysed image) are left alone: a blemish is not +/// resolved below it, and what is found there is lash shadow and nostril edges. +pub const MIN_FACE_PX: f64 = 160.0; +/// More candidates than this on one face means texture (stubble, freckles), not blemishes. +pub const MAX_CANDIDATES_PER_FACE: usize = 12; +/// Most blemishes kept per face, strongest first. +pub const MAX_PER_FACE: usize = 6; + +/// Pixels of `img` inside the box that are skin: an ellipse over the face, gated by skin colour, +/// with the eyes, brows, nostrils and mouth cut out around the landmarks. +fn skin_mask(img: &Rgba8, f: &FaceBox, x0: usize, y0: usize, cw: usize, ch: usize) -> Vec { + let (w, h) = (img.width as f64, img.height as f64); + let (fx, fy, fw, fh) = (f.x0 * w, f.y0 * h, (f.x1 - f.x0) * w, (f.y1 - f.y0) * h); + let (cx, cy) = (fx + fw / 2.0, fy + fh * 0.55); + let (rx, ry) = (fw * 0.55, fh * 0.70); + let lm = |i: usize| (f.landmarks[i].0 * w, f.landmarks[i].1 * h); + let (re, le, nose, mr, ml) = (lm(0), lm(1), lm(2), lm(3), lm(4)); + let mouth = ((mr.0 + ml.0) / 2.0, (mr.1 + ml.1) / 2.0); + let eye_y = (re.1 + le.1) / 2.0; + let mut out = vec![false; cw * ch]; + for yy in 0..ch { + for xx in 0..cw { + let (px, py) = ((x0 + xx) as f64 + 0.5, (y0 + yy) as f64 + 0.5); + let in_ellipse = ((px - cx) / rx).powi(2) + ((py - cy) / ry).powi(2) <= 1.0; + if !in_ellipse { + continue; + } + // brows and eyes: a band from above the brows to just below the eyes + if py > eye_y - fh * 0.32 && py < eye_y + fh * 0.05 { + continue; + } + let near = |(lx, ly): (f64, f64), r: f64| (px - lx).powi(2) + (py - ly).powi(2) <= r * r; + if near(re, fw * 0.12) || near(le, fw * 0.12) || near(mouth, fw * 0.16) { + continue; + } + // nostrils: an ellipse at and below the nose tip + if ((px - nose.0) / (fw * 0.20)).powi(2) + ((py - nose.1 - fw * 0.02) / (fw * 0.16)).powi(2) <= 1.0 { + continue; + } + let Some(p) = img.data.get((y0 + yy) * img.width + x0 + xx) else { continue }; + let (r, g, b) = (f64::from(p[0]), f64::from(p[1]), f64::from(p[2])); + let cr = 128.0 + 0.5 * r - 0.4187 * g - 0.0813 * b; + let cb = 128.0 - 0.1687 * r - 0.3313 * g + 0.5 * b; + if let Some(o) = out.get_mut(yy * cw + xx) { + *o = (133.0..180.0).contains(&cr) && (77.0..130.0).contains(&cb); + } + } + } + out +} + +/// Blemishes on the given faces of a rendered image; `sensitivity` 0..100 (higher finds fainter +/// spots). Faces narrower than [`MIN_FACE_PX`] get none; a face with more than +/// [`MAX_CANDIDATES_PER_FACE`] candidates gets none; at most [`MAX_PER_FACE`] per face. +pub fn detect(img: &Rgba8, faces: &[FaceBox], sensitivity: f32) -> Vec { + let (w, h) = (img.width, img.height); + if w < 32 || h < 32 { + return Vec::new(); + } + let long = w.max(h) as f64; + let thr = 0.09 - 0.05 * (sensitivity / 100.0).clamp(0.0, 1.0); + let mut out = Vec::new(); + for (fi, f) in faces.iter().enumerate() { + let fw = (f.x1 - f.x0) * w as f64; + let fh = (f.y1 - f.y0) * h as f64; + if !(fw.is_finite() && fh.is_finite()) || fw < MIN_FACE_PX || fh <= 0.0 { + continue; + } + // the face with a margin, clipped to the image + let clamp = |v: f64, hi: usize| (v.max(0.0) as usize).min(hi); + let x0 = clamp(f.x0 * w as f64 - fw * 0.15, w); + let y0 = clamp(f.y0 * h as f64 - fh * 0.15, h); + let x1 = clamp(f.x1 * w as f64 + fw * 0.15, w); + let y1 = clamp(f.y1 * h as f64 + fh * 0.15, h); + let (cw, ch) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + if cw < 16 || ch < 16 { + continue; + } + let skin = skin_mask(img, f, x0, y0, cw, ch); + let px = |xx: usize, yy: usize| img.data.get((y0 + yy) * w + x0 + xx).copied().unwrap_or([0; 4]); + let lum = Plane::from_fn(cw, ch, |xx, yy| { + let p = px(xx, yy); + (0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2])) / 255.0 + }); + // redness: how much red exceeds green, which a pimple does and even skin does not + let red = Plane::from_fn(cw, ch, |xx, yy| { + let p = px(xx, yy); + (f32::from(p[0]) - f32::from(p[1])) / 255.0 + }); + let sigma = (0.035 * fw as f32).max(2.0); + let around_l = gaussian(&lum, sigma); + let around_r = gaussian(&red, sigma); + let detail = Plane::from_fn(cw, ch, |xx, yy| { + let i = yy * cw + xx; + (lum.data.get(i).copied().unwrap_or(0.0) - around_l.data.get(i).copied().unwrap_or(0.0)).abs() + }); + let texture = gaussian(&detail, (0.06 * fw as f32).max(3.0)); + let resp: Vec = (0..cw * ch) + .map(|i| { + let dark = around_l.data.get(i).copied().unwrap_or(0.0) - lum.data.get(i).copied().unwrap_or(0.0); + let redder = red.data.get(i).copied().unwrap_or(0.0) - around_r.data.get(i).copied().unwrap_or(0.0); + dark.max(redder).max(0.0) + }) + .collect(); + let candidate = |i: usize| { + skin.get(i).copied().unwrap_or(false) + && resp.get(i).copied().unwrap_or(0.0) > thr + && resp.get(i).copied().unwrap_or(0.0) > 2.5 * texture.data.get(i).copied().unwrap_or(0.0) + }; + // blobs of candidate pixels (4-connected), small and roundish + let mut seen = vec![false; cw * ch]; + let mut found: Vec = Vec::new(); + let (rmin, rmax) = ((0.004 * fw).max(1.5), 0.05 * fw); + for start in 0..cw * ch { + if seen.get(start).copied().unwrap_or(true) || !candidate(start) { + continue; + } + let mut stack = vec![start]; + if let Some(s) = seen.get_mut(start) { + *s = true; + } + let (mut n, mut sx, mut sy, mut peak) = (0usize, 0.0f64, 0.0f64, 0.0f32); + let (mut bx0, mut bx1, mut by0, mut by1) = (cw, 0, ch, 0); + while let Some(i) = stack.pop() { + let (xx, yy) = (i % cw, i / cw); + n += 1; + sx += xx as f64; + sy += yy as f64; + peak = peak.max(resp.get(i).copied().unwrap_or(0.0)); + (bx0, bx1, by0, by1) = (bx0.min(xx), bx1.max(xx), by0.min(yy), by1.max(yy)); + if n as f64 > rmax * rmax * 4.0 { + break; // too big to be a blemish: a shadow or a feature + } + for j in [i.wrapping_sub(1), i + 1, i.wrapping_sub(cw), i + cw] { + let ok = j < cw * ch && !seen.get(j).copied().unwrap_or(true) && (j % cw).abs_diff(xx) <= 1; + if ok && candidate(j) { + if let Some(s) = seen.get_mut(j) { + *s = true; + } + stack.push(j); + } + } + } + let (bw, bh) = ((bx1 - bx0 + 1) as f64, (by1 - by0 + 1) as f64); + let r = bw.max(bh) / 2.0; + let round = bw.max(bh) / bw.min(bh) < 3.0 && n as f64 >= 0.35 * bw * bh && n >= 3; + if r >= rmin && r <= rmax && round { + found.push(Blemish { + face: fi, + x: (x0 as f64 + sx / n as f64 + 0.5) / w as f64, + y: (y0 as f64 + sy / n as f64 + 0.5) / h as f64, + radius: (r / long * 1.6).max(fw * 0.012 / long), + strength: peak, + }); + } + } + if found.len() > MAX_CANDIDATES_PER_FACE { + continue; // texture, not blemishes + } + found.sort_by(|a, b| b.strength.total_cmp(&a.strength)); + found.truncate(MAX_PER_FACE); + out.extend(found); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A grey frame with one skin-coloured face ellipse `fw` wide at the centre, landmarks where + /// a face has them, and `dots` dark spots on the cheeks. + fn scene(w: usize, h: usize, fw: f64, dots: &[(f64, f64)]) -> (Rgba8, FaceBox) { + let (cx, cy, fh) = (w as f64 / 2.0, h as f64 / 2.0, fw * 1.3); + let mut img = Rgba8::filled(w, h, [120, 120, 120, 255]); + for y in 0..h { + for x in 0..w { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + if ((px - cx) / (fw * 0.5)).powi(2) + ((py - cy) / (fh * 0.5)).powi(2) <= 1.0 { + img.data[y * w + x] = [222, 180, 160, 255]; // skin + } + } + } + for &(dx, dy) in dots { + let (sx, sy) = (cx + dx * fw, cy + dy * fh); + for y in 0..h { + for x in 0..w { + if (x as f64 + 0.5 - sx).powi(2) + (y as f64 + 0.5 - sy).powi(2) <= (0.012 * fw).max(1.5).powi(2) { + img.data[y * w + x] = [150, 90, 85, 255]; // a dark red spot + } + } + } + } + let n = |x: f64, y: f64| (x / w as f64, y / h as f64); + let face = FaceBox { + x0: (cx - fw / 2.0) / w as f64, + y0: (cy - fh / 2.0) / h as f64, + x1: (cx + fw / 2.0) / w as f64, + y1: (cy + fh / 2.0) / h as f64, + landmarks: [ + n(cx - fw * 0.2, cy - fh * 0.12), + n(cx + fw * 0.2, cy - fh * 0.12), + n(cx, cy + fh * 0.08), + n(cx - fw * 0.15, cy + fh * 0.28), + n(cx + fw * 0.15, cy + fh * 0.28), + ], + }; + (img, face) + } + + #[test] + fn finds_spots_on_the_cheeks_and_nothing_else() { + let dots = [(-0.28, 0.08), (0.3, 0.12), (-0.2, 0.3)]; + let (img, face) = scene(800, 600, 300.0, &dots); + let found = detect(&img, &[face], 50.0); + assert_eq!(found.len(), 3, "{found:?}"); + for (dx, dy) in dots { + let (ex, ey) = (0.5 + dx * 300.0 / 800.0, 0.5 + dy * 390.0 / 600.0); + assert!(found.iter().any(|b| (b.x - ex).abs() < 0.01 && (b.y - ey).abs() < 0.01), "{found:?} lacks {ex},{ey}"); + } + assert!(found.iter().all(|b| b.face == 0 && b.radius > 0.0 && b.radius < 0.02 && b.strength > 0.0)); + let (clean, face) = scene(800, 600, 300.0, &[]); + assert!(detect(&clean, &[face], 50.0).is_empty(), "a clean face has no blemishes"); + } + + #[test] + fn features_and_small_faces_are_left_alone() { + // spots on the eyes, the nose tip and the mouth: all inside the cut-outs + let (img, face) = scene(800, 600, 300.0, &[(-0.2, -0.12), (0.2, -0.12), (0.0, 0.1), (0.0, 0.28)]); + assert!(detect(&img, &[face], 80.0).is_empty(), "{:?}", detect(&img, &[face], 80.0)); + // the same cheek spots on a face below the size floor + let (img, face) = scene(800, 600, 120.0, &[(-0.28, 0.08), (0.3, 0.12)]); + assert!(detect(&img, &[face], 50.0).is_empty()); + } + + #[test] + fn a_speckled_face_is_texture_not_blemishes() { + // 24 spots on the cheeks, all outside the eye, nose and mouth cut-outs + let dots: Vec<(f64, f64)> = + [-0.42, -0.32, -0.22, 0.22, 0.32, 0.42].iter().flat_map(|&dx| (0..4).map(move |j| (dx, 0.0 + 0.08 * j as f64))).collect(); + let (img, face) = scene(800, 600, 300.0, &dots); + assert!(detect(&img, &[face], 50.0).is_empty(), "more than {MAX_CANDIDATES_PER_FACE} candidates means stubble or freckles"); + } + + #[test] + fn hostile_faces_do_not_panic() { + let (img, _) = scene(64, 48, 20.0, &[]); + let weird = FaceBox { x0: -2.0, y0: 0.5, x1: 9.0, y1: f64::NAN, landmarks: [(f64::INFINITY, 0.0); 5] }; + let inverted = FaceBox { x0: 0.9, y0: 0.9, x1: 0.1, y1: 0.1, landmarks: [(0.5, 0.5); 5] }; + assert!(detect(&img, &[weird, inverted], 100.0).is_empty()); + assert!(detect(&Rgba8::new(0, 0), &[inverted], 50.0).is_empty()); + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 886ec9eee..eb65d51a2 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -280,7 +280,8 @@ impl FinishParams { pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { - Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) { + // V2 changes the Highlights/Shadows base (`local::tone_base`), not the tone map + Process::V1 | Process::V2 => match info.camera_tone.as_ref().filter(|_| info.raw) { Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak), None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), @@ -584,6 +585,13 @@ pub(crate) fn finish_with( // soft proofing maps into the proof space first and shows that in the output space let mut warn = None; let mut r = match &fp.proof { + Some(pp) if pp.table.is_some() => { + let (q, dist) = pp.table.as_ref().map_or((d, 0.0), |t| t.sample(d)); + if pp.dest_warning && dist > crate::output::PROOF_GAMUT_TOLERANCE { + warn = Some(crate::output::PROOF_DEST_WARNING); + } + mul3(to_out, q) + } Some(pp) => { let q0 = mul3(&pp.to_proof, d); let (q, t) = gamut_map_to(q0, pp.luma, peak); @@ -595,7 +603,7 @@ pub(crate) fn finish_with( None => mul3(to_out, d), }; let (mapped, t) = gamut_map_to(r, *out_luma, peak); - if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { + if fp.proof.as_ref().is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { warn = Some(crate::output::PROOF_DISPLAY_WARNING); } r = mapped; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index f7c33ee9e..88e39778d 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -24,6 +24,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod auto; +pub mod blemish; pub mod colorops; pub mod cull; pub mod dust; @@ -43,7 +44,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof}; +pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -78,6 +79,10 @@ pub struct SourceInfo { pub as_shot_tint: f64, /// No measured camera illuminant: WB adjustments are relative to the camera's rendered look. pub relative_wb: bool, + /// The Kelvin scale comes from matrices measured for the camera model, not from the file: before those were + /// used for white balance, this source was edited on the relative scale (6500 K / 0 = as shot), so settings + /// without a [`lightcraft_develop::WbScale`] are read that way ([`local::legacy_to_kelvin`]). + pub legacy_relative_wb: bool, /// The camera's colour model (raw files with a colour matrix); `None`: white balance adapts /// the developed pixels (Bradford, linear Rec.2020). pub camera_color: Option>, @@ -97,6 +102,7 @@ impl Default for SourceInfo { as_shot_tint: 0.0, lens: None, relative_wb: false, + legacy_relative_wb: false, camera_color: None, camera_tone: None, mattes: None, @@ -358,6 +364,9 @@ pub struct Plan<'a> { pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). pub lin_key: u64, + /// [`Self::lin_key`] without the noise-reduction settings: draft renders reuse the smooth + /// planes of an image that differs from this one only in its noise reduction. + pub pre_nr_key: u64, /// Red eye / pet eye corrections with their detected pupils, in output pixels. pub eyes: Vec, /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). @@ -418,7 +427,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let (wb_t, wb_tint) = local::effective_wb(info, s); let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long); let d = &s.detail; - let lin_key = hash_of(( + let pre_nr_key = hash_of(( geo, [wb_t, wb_tint, info.as_shot_temp, info.as_shot_tint].map(f64::to_bits), format!("{:?}", s.spots), @@ -427,10 +436,10 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R format!("{:?}", s.optics), // and the local tone mapping local_tone::enabled(info, s), - [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } + let lin_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits))); + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -521,7 +530,12 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("wb/spots/nr", &mut t); let mut planes = match cached.map(|e| e.planes) { Some(p) if p.key == lin_key => p, - _ => local::Planes { key: lin_key, ..Default::default() }, + // A draft while noise reduction changes (a slider drag): the wide, edge-aware planes of the + // image before the change stand in (noise reduction barely moves a blur that wide); the + // per-pixel log luminance and the fine texture band are redone. Keyed so a full-quality + // render never takes them. + Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(), + _ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() }, }; let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref()); if let Some(a) = plan.fixed_air { @@ -645,4 +659,6 @@ mod tests_hdr; #[cfg(test)] mod tests_local; #[cfg(test)] +mod tests_tone; +#[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index fd7fefd34..ce3994cd8 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -1,9 +1,9 @@ //! Scene-linear preparation: white balance + exposure, and the spatial planes the per-pixel stage //! needs (edge-aware base layer for highlights/shadows, clarity/texture bands, dehaze veil). -use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix}; +use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix, xy_to_temp_tint}; use lightcraft_color::{REC2020, luminance_2020}; -use lightcraft_develop::DevelopSettings; +use lightcraft_develop::{DevelopSettings, Process}; use lightcraft_raster::blur::gaussian; use std::sync::Arc; @@ -37,7 +37,7 @@ pub fn wb_matrix_for(info: &SourceInfo, s: &DevelopSettings) -> Option<[[f32; 3] { return Some(m.to_f32()); } - let set = wb_matrix(&REC2020, temp_tint_to_xy(t, tint)); + let set = wb_matrix(&REC2020, effective_white(info, s)); let shot = wb_matrix(&REC2020, temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint)); let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY)); // Normalize so neutral luminance is preserved (WB shouldn't change exposure). @@ -64,16 +64,102 @@ fn wb_gain(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings, gain: f32) }); } -/// The white balance actually in effect (presets resolve to their Kelvin values for raw files). +/// The white balance actually in effect (presets resolve to their Kelvin values for raw files; settings written on +/// the relative scale before a raw's camera matrices reached its white balance give the same colour change on the +/// Kelvin scale, see [`legacy_to_kelvin`]). pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) { use lightcraft_develop::WbMode; match s.wb.mode { WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint), + _ if legacy_wb(info, s) => legacy_to_kelvin(s.wb.temp, s.wb.tint, (info.as_shot_temp, info.as_shot_tint)), m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)), _ => (s.wb.temp, s.wb.tint), } } +/// The source white the render adapts from the as-shot white to: [`effective_wb`] as a chromaticity, except that a +/// white balance still on the relative scale is composed exactly ([`legacy_white`]) rather than read back through +/// its rounded Kelvin / tint. +fn effective_white(info: &SourceInfo, s: &DevelopSettings) -> lightcraft_color::Xy { + if legacy_wb(info, s) + && s.wb.mode != lightcraft_develop::WbMode::AsShot + && let Some(xy) = legacy_white(s.wb.temp, s.wb.tint, (info.as_shot_temp, info.as_shot_tint)) + { + return xy; + } + let (t, tint) = effective_wb(info, s); + temp_tint_to_xy(t, tint) +} + +/// Whether `s` holds a white balance written on the relative scale for a source that is edited in Kelvin today. +fn legacy_wb(info: &SourceInfo, s: &DevelopSettings) -> bool { + info.raw && !info.relative_wb && info.legacy_relative_wb && s.wb.scale == lightcraft_develop::WbScale::Legacy +} + +/// The white that renders exactly as `(temp, tint)` did on the relative scale, where 6500 K / 0 meant *as shot* and +/// the source's real as-shot white is `as_shot`. +/// +/// The relative render adapted the picture from 6500 K / 0 to `(temp, tint)` (Bradford, [`wb_matrix_for`]); the +/// Kelvin render adapts it from `as_shot` to the result. Bradford adaptations are von Kries scalings in one cone +/// space, so they compose exactly: the new white's cone response is the old target's times the as-shot white's +/// over the old reference's, and the two renders give the same matrix (up to the scale the luminance +/// normalisation removes). `None` when the arithmetic has nowhere to go (a non-finite input, a white outside the +/// chromaticity plane). +pub fn legacy_white(temp: f64, tint: f64, as_shot: (f64, f64)) -> Option { + use lightcraft_color::{BRADFORD, Xy}; + if !(temp.is_finite() && tint.is_finite() && as_shot.0.is_finite() && as_shot.1.is_finite()) { + return None; + } + let cones = |t: f64, tint: f64| BRADFORD.apply(temp_tint_to_xy(t, tint).to_xyz()); + let (old, reference, shot) = (cones(temp, tint), cones(6500.0, 0.0), cones(as_shot.0, as_shot.1)); + let inverse = BRADFORD.inverse()?; + let mut new = [0.0; 3]; + for i in 0..3 { + if reference[i].abs() < 1e-12 { + return None; + } + new[i] = old[i] * shot[i] / reference[i]; + } + let xy = Xy::from_xyz(inverse.apply(new)); + (xy.x.is_finite() && xy.y.is_finite() && xy.y > 0.0).then_some(xy) +} + +/// The Kelvin / tint of [`legacy_white`]: what a white balance written on the relative scale reads as, and becomes +/// when it is next edited ([`normalize_wb`]). Exact wherever the white has a Kelvin value (1000–50000 K after the +/// composition; the relative slider reached 2000–13550 K around 6500 K, so only a far-blue as-shot white pushed +/// to the slider's blue end lands beyond, and clamps), to the precision of the Kelvin / tint parametrisation +/// (better than 0.01 % away from its 4000 K seam). Unchanged when the arithmetic has nowhere to go. +pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64) { + if temp == 6500.0 && tint == 0.0 && as_shot.0.is_finite() && as_shot.1.is_finite() { + // the relative scale's as-shot reference, exactly (a Custom value left where As Shot was stays as shot) + return as_shot; + } + match legacy_white(temp, tint, as_shot).map(xy_to_temp_tint) { + Some((t, tint)) if t.is_finite() && tint.is_finite() => (t, tint), + _ => (temp, tint), + } +} + +/// Bring the white balance of `d` onto the scale `info` is edited on, before a new value is written into it: on a +/// raw with a Kelvin scale the values are marked Kelvin, and ones still on the relative scale become the Custom +/// Kelvin / tint that renders the same ([`legacy_to_kelvin`]; a preset mode's look is kept as that custom +/// value, since the preset's Kelvin value would be a different look). Elsewhere the scale is cleared, so values +/// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them. +pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) { + use lightcraft_develop::{WbMode, WbScale}; + if !(info.raw && !info.relative_wb) { + d.wb.scale = WbScale::Legacy; + return; + } + if legacy_wb(info, d) && d.wb.mode != WbMode::AsShot { + let (t, tint) = legacy_to_kelvin(d.wb.temp, d.wb.tint, (info.as_shot_temp, info.as_shot_tint)); + d.wb.temp = t; + d.wb.tint = tint; + d.wb.mode = WbMode::Custom; + } + d.wb.scale = WbScale::Kelvin; +} + /// Self-guided filter (He et al.) on a single plane with Gaussian windows of `sigma` px. pub fn guided(p: &Plane, sigma: f32, eps: f32) -> Plane { let (ma, mb) = guided_coeffs(p, sigma, eps); @@ -120,6 +206,86 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { guided_apply(p, &up(&ma), &up(&mb)) } +/// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the +/// steps between regions where they are and removes features thinner than about `r` px. +pub fn median_hv(p: &Plane, r: usize) -> Plane { + if r == 0 || p.data.is_empty() { + return p.clone(); + } + let rows = |src: &Plane| { + let mut out = Plane::new(src.width, src.height); + for_rows(&mut out.data, src.width, |y, row| median_row(src.row(y), row, r)); + out + }; + transpose(&rows(&transpose(&rows(p)))) +} + +/// Running median of `src` over `2r+1` taps (clamped edges) into `out`: a sorted window that +/// drops the leaving value and inserts the entering one. +fn median_row(src: &[f32], out: &mut [f32], r: usize) { + let n = src.len(); + if n == 0 { + return; + } + let at = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; + let r_ = r as isize; + let mut win: Vec = (-r_..=r_).map(at).collect(); + win.sort_unstable_by(f32::total_cmp); + for (x, o) in out.iter_mut().enumerate() { + *o = win[r]; + let (old, new) = (at(x as isize - r_), at(x as isize + r_ + 1)); + if old.to_bits() != new.to_bits() { + let i = win.binary_search_by(|v| v.total_cmp(&old)).unwrap_or_else(|i| i.min(win.len() - 1)); + win.remove(i); + let j = win.binary_search_by(|v| v.total_cmp(&new)).unwrap_or_else(|j| j); + win.insert(j, new); + } + } +} + +fn transpose(p: &Plane) -> Plane { + let (w, h) = (p.width, p.height); + let mut t = Plane::new(h, w); + for_rows(&mut t.data, h, |x, row| { + for (y, v) in row.iter_mut().enumerate() { + *v = p.data[y * w + x]; + } + }); + t +} + +/// Detail scale of the highlights/shadows base from process V2, as a fraction of the long edge: +/// the base is the edge-aware filter of the [`median_hv`] of log luminance at this radius, so +/// twigs, lines and fine texture brighter or darker than their surroundings take their +/// surroundings' shift (and keep their contrast, as with Exposure) while region boundaries stay +/// sharp (no halos). V1's base is the edge-aware filter of log luminance itself (issue #632). +pub const BASE_DETAIL: f64 = 0.0015; +/// Largest [`base_detail_radius`] (px). +pub const BASE_DETAIL_MAX: usize = 24; + +/// Median radius (px) of the highlights/shadows base at `px_per_long` (see [`BASE_DETAIL`]). +pub fn base_detail_radius(px_per_long: f64) -> usize { + ((BASE_DETAIL * px_per_long).round().max(0.0) as usize).min(BASE_DETAIL_MAX) +} + +/// Median radius (px) of the highlights/shadows base of `s`'s rendering process at `px_per_long`: +/// 0 (no median) under V1, [`base_detail_radius`] from V2. +pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize { + match s.process.process() { + Process::V1 => 0, + Process::V2 => base_detail_radius(px_per_long), + } +} + +/// The highlights/shadows base layer of log luminance `l`: the fast guided filter ([`BASE_EPS`]) +/// of its [`median_hv`] at `detail` px ([`tone_base_detail`]); at 0, of `l` itself (V1). +pub fn tone_base(l: &Plane, sigma: f32, detail: usize) -> Plane { + if detail == 0 { + return guided_fast(l, sigma, BASE_EPS); + } + guided_fast(&median_hv(l, detail), sigma, BASE_EPS) +} + /// Subsampling step of [`guided_fast`] for `sigma` (1 = no subsampling: the plain guided filter). pub fn guided_fast_step(sigma: f32) -> usize { (sigma / 3.0).floor().clamp(1.0, 16.0) as usize @@ -210,8 +376,11 @@ pub fn log_lum(c: [f32; 3]) -> f32 { #[derive(Clone, Default)] pub(crate) struct Planes { pub key: u64, + /// The image's key without its noise reduction (`Plan::pre_nr_key`). + pub pre_nr: u64, pub log_l: Option>, - pub base: Option<(u32, Arc)>, + /// Keyed by its radius, process and median radius ([`BaseKey`]). + pub base: Option<(BaseKey, Arc)>, pub clarity: Option<(u32, Arc)>, pub texture: Option<(u32, Arc)>, /// Dark channel and its airlight. @@ -220,13 +389,33 @@ pub(crate) struct Planes { pub chroma: Option<(u32, Arc)>, } -/// Reuse `slot` if it was computed at `sigma`, else compute and store it. -fn plane_at(slot: &mut Option<(u32, Arc)>, sigma: f32, f: impl FnOnce() -> Plane) -> Arc { +impl Planes { + /// The planes that still fit an image differing from this one only in noise reduction, close + /// enough for a draft: the wide blurs (base, clarity, dehaze, chromaticity). Its `key` matches + /// no image, so only another draft takes them. + pub fn for_other_nr(self) -> Planes { + Planes { key: 0, pre_nr: self.pre_nr, log_l: None, texture: None, ..self } + } +} + +/// What the highlights/shadows base plane was computed with: its radius (`f32` bits), the +/// rendering process and the median radius ([`tone_base_detail`]). The base differs between +/// processes, so a photo moved to another one (Update to Current Process, undo) recomputes it. +pub type BaseKey = (u32, Process, usize); + +/// The [`BaseKey`] of a base at `sigma` for `s` (`detail`: [`PlaneSigmas::base_detail`]). +pub fn base_key(s: &DevelopSettings, sigma: f32, detail: usize) -> BaseKey { + (sigma.to_bits(), s.process.process(), detail) +} + +/// Reuse `slot` if it was computed with `key` (a radius's `f32` bits, or a [`BaseKey`]), else +/// compute and store it. +pub fn plane_at(slot: &mut Option<(K, Arc

)>, key: K, f: impl FnOnce() -> P) -> Arc

{ match slot { - Some((k, p)) if *k == sigma.to_bits() => p.clone(), + Some((k, p)) if *k == key => p.clone(), _ => { let p = Arc::new(f()); - *slot = Some((sigma.to_bits(), p.clone())); + *slot = Some((key, p.clone())); p } } @@ -248,8 +437,11 @@ pub const CHROMA_SIGMA: f32 = 0.004; /// Radii (px) of the spatial planes the settings need (`None` = not needed). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PlaneSigmas { - /// Edge-aware base for highlights/shadows (fast guided filter, [`BASE_EPS`]). + /// Edge-aware base for highlights/shadows ([`tone_base`]: fast guided filter, [`BASE_EPS`]; from + /// process V2 of the [`BASE_DETAIL`] median). pub base: Option, + /// Median radius (px) of that base ([`tone_base_detail`]: 0 under V1). + pub base_detail: usize, /// Clarity band (fast guided filter, [`CLARITY_EPS`]). pub clarity: Option, /// Texture / sharpening band (Gaussian). @@ -265,7 +457,9 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); let tone_active = s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); - // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). + // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved) + // From V2 it is taken of the median at 0.15%: lines and texture finer than that are detail, + // not tone (#632). let base = tone_active.then(|| { let sigma = (0.015 * ppl).max(1.0); if q == Quality::Draft { sigma.min(24.0) } else { sigma } @@ -281,7 +475,8 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS .then(|| (0.0018 * ppl).max(0.6)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); - PlaneSigmas { base, clarity, texture, dark, chroma } + let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; + PlaneSigmas { base, base_detail, clarity, texture, dark, chroma } } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), @@ -298,7 +493,7 @@ pub(crate) fn prepare( mattes: Option<&masks::Mattes>, ) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); - let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = + let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = plane_sigmas(s, px_per_long, q); let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes; @@ -312,13 +507,13 @@ pub(crate) fn prepare( }); let l = &log_l; let (base, (clarity_blur, (texture_blur, dark))) = par_join( - || base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))), + || base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail)))), || { par_join( - || clarity_sigma.map(|sg| plane_at(sc, sg, || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))), + || clarity_sigma.map(|sg| plane_at(sc, sg.to_bits(), || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))), || { par_join( - || texture_sigma.map(|sg| plane_at(st, sg, || timed("texture", || gaussian(l, sg)))), + || texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))), || { dark_sigma.map(|sg| match sd { Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air), diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index e6508c084..0628a6a6f 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -454,21 +454,12 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa // long the button is held: a sample where the pointer hasn't moved (a stationary hold records // one per frame) lays no dab, so waiting in place adds nothing (#518). let mut dabs: Vec = Vec::new(); - let mut prev: Option = None; + let mut prev = None; for p in &s.points { let q = to_out.apply(*p); - if let Some(prev) = prev { - let dist = prev.dist(q); - if dist.is_nan() || dist <= STATIONARY_PX { - continue; - } - let n = (dist / (r / 4.0).max(0.5)).ceil() as usize; - for k in 1..n { - dabs.push(prev.lerp(q, k as f64 / n as f64)); - } + if push_dabs(&mut dabs, prev, q, r) { + prev = Some(q); } - dabs.push(q); - prev = Some(q); } BrushDabs { dabs, @@ -481,6 +472,60 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa } } +/// Distance between consecutive dabs of a brush of radius `r` (pixels): a quarter of the radius, +/// so neighbouring dabs overlap and a stroke reads as continuous however fast it was painted. +#[inline] +pub fn dab_spacing(r: f64) -> f64 { + (r / 4.0).max(0.5) +} + +/// Append the dabs of one path segment, from the previous sample `prev` (already stamped) to `q`, +/// for a brush of radius `r`: evenly spaced at most [`dab_spacing`] apart, ending on `q`. The +/// committed stroke ([`brush_dabs`]) and the live one the UI draws while the button is held place +/// their dabs with this, so they cover the same pixels (issue #517). A sample where the pointer +/// hasn't moved (a stationary hold records one per frame) or that is not a finite point lays +/// nothing, so holding the brush still adds no coverage (#518). Returns whether `q` was stamped; +/// callers move `prev` on only then. +pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) -> bool { + if !(q.x.is_finite() && q.y.is_finite()) { + return false; + } + if let Some(prev) = prev { + let dist = prev.dist(q); + if dist.is_nan() || dist <= STATIONARY_PX { + return false; + } + let n = (dist / dab_spacing(r)).ceil(); + // a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge + // allocation: 1 M dabs is far more than any on-screen stroke needs + let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 }; + for k in 1..n { + dabs.push(prev.lerp(q, k as f64 / n as f64)); + } + } + dabs.push(q); + true +} + +/// How much one dab paints at distance `dd` from its centre: 1 inside the hard core `hard`, +/// smoothly falling to 0 at the radius `r`. +#[inline] +pub fn dab_alpha(dd: f64, r: f64, hard: f64) -> f32 { + if dd > r { + 0.0 + } else if dd <= hard { + 1.0 + } else { + 1.0 - smooth(hard as f32, r as f32, dd as f32) + } +} + +/// A stroke's coverage `v` after another dab paints `a` there: flow accumulates up to density. +#[inline] +pub fn accumulate(v: f32, a: f32, flow: f32, density: f32) -> f32 { + (v + a * flow * (1.0 - v)).min(density) +} + /// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour /// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`). pub const AUTO_TOL_EV: f32 = 0.5; @@ -564,14 +609,14 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i if dd > r { continue; } - let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; + let mut a = dab_alpha(dd, r, hard); let i = y * w + x; if let Some((rl, rch)) = refc { a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch); } let v = &mut stroke_alpha.data[i]; // flow accumulates within a stroke up to density - *v = (*v + a * flow * (1.0 - *v)).min(dens); + *v = accumulate(*v, a, flow, dens); } } } @@ -706,6 +751,47 @@ mod tests { assert!(e[0].alpha.get(50, 50) < 0.01); } + /// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at + /// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once + /// (the committed one). + #[test] + fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { + let r = 12.0; + let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; + let mut live = Vec::new(); + let mut prev = None; + for q in pts { + if push_dabs(&mut live, prev, q, r) { + prev = Some(q); + } + } + assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); + assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); + assert_eq!(live.first(), pts.first()); + assert_eq!(live.last(), pts.last()); + // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) + let (w, h) = (1000, 500); + let f = frame(w, h); + let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; + let committed = brush_dabs(&stroke, &f, w, h); + let mut again = Vec::new(); + let mut prev = None; + for p in &stroke.points { + let q = f.norm_to_out(w, h).apply(*p); + if push_dabs(&mut again, prev, q, committed.r) { + prev = Some(q); + } + } + assert_eq!(committed.dabs, again); + assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // a hostile segment places no in-between dabs + let mut v = Vec::new(); + assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); + assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); + push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); + assert!(v.len() <= 1_000_002, "{}", v.len()); + } + #[test] fn brush_and_subtract() { let f = frame(200, 100); diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index f99a3cd80..843f1e822 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -11,13 +11,14 @@ //! dimensionless scales), so a 400 px preview and a full-size export are warped identically. //! //! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`]. -//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the -//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion). +//! Profile corrections use lens data embedded in the files (DNG `WarpRectilinear` / `WarpRectilinear2` / +//! `FixVignetteRadial`, and the raw readers' equivalent of a camera's own correction, e.g. Panasonic / Leica RW2 +//! distortion), or a lens database profile in the same form (`lightcraft_lensdb`). use std::sync::Mutex; use lightcraft_color::perceptual::{oklab_from_2020, oklab_to_2020}; -use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; +use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage}; use lightcraft_geom::{Affine, Homography, Interval, Orientation, Point, Real}; use lightcraft_raster::{Plane, Rgb32f}; @@ -103,7 +104,13 @@ impl Warp { self.persp != Homography::IDENTITY || self.k1 != 0.0 || self.ca.iter().any(|c| *c != 0.0) - || (self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.warp.is_some())) + || (self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.warp.is_some() || l.has_stages())) + } + + /// Whether the embedded lens warp is a `WarpRectilinear2` chain, whose degree-14 polynomials are ill-conditioned + /// in `f32` (terms of 1e5 cancelling to 1): a renderer that works in `f32` must sample on the CPU. + pub fn needs_f64(&self) -> bool { + self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.has_stages()) } /// Whether the colour planes are sampled at different positions. @@ -111,6 +118,8 @@ impl Warp { self.ca[0] != self.ca[1] || self.ca[1] != self.ca[2] || (self.lens_dist != 0.0 && self.lens.and_then(|l| l.warp).is_some_and(|w| w.planes[0] != w.planes[1] || w.planes[1] != w.planes[2])) + || (self.lens_dist != 0.0 + && self.lens.is_some_and(|l| l.stages.iter().flatten().any(|s| s.planes[0] != s.planes[1] || s.planes[1] != s.planes[2]))) } pub fn has_gain(&self) -> bool { @@ -170,6 +179,16 @@ impl Warp { let (sx, sy) = embedded_warp_real(&wp, qx, qy, ch, self.w, self.h); (qx, qy) = (qx + (sx - qx) * self.lens_dist, qy + (sy - qy) * self.lens_dist); } + if self.lens_dist != 0.0 + && let Some(l) = self.lens.filter(|l| l.has_stages()) + { + // each opcode warps the image the one before it made, so the last one maps the output position first + let (mut sx, mut sy) = (qx, qy); + for st in l.stages.iter().rev().flatten() { + (sx, sy) = embedded_stage_real(st, sx, sy, ch, self.w, self.h); + } + (qx, qy) = (qx + (sx - qx) * self.lens_dist, qy + (sy - qy) * self.lens_dist); + } let k = self.ca[ch]; if k != 0.0 { (qx, qy) = ((qx - c.x) * (1.0 + k) + c.x, (qy - c.y) * (1.0 + k) + c.y); @@ -280,6 +299,33 @@ fn embedded_warp_real(wp: &EmbeddedWarp, x: T, y: T, ch: usize, w: f64, (sx * m + cx, sy * m + cy) } +/// DNG `WarpRectilinear2` source position for corrected position `p` (px) and plane `ch`. +pub fn embedded_stage(st: &EmbeddedWarpStage, p: Point, ch: usize, w: f64, h: f64) -> Point { + let (x, y) = embedded_stage_real(st, p.x, p.y, ch, w, h); + Point::new(x, y) +} + +/// [`embedded_stage`] for any [`Real`]: DNG specification 1.6.0.0, "WarpRectilinear2" — the radial term is +/// `f(clamp(r, min_valid_radius, max_valid_radius))` with `f(r) = Σ krₚ rᵖ` (p = 0..14), used as `d·f` or, with +/// `reciprocalRadial`, `d/f`; the tangential terms are those of `WarpRectilinear` (with the unclamped `r`). +fn embedded_stage_real(st: &EmbeddedWarpStage, x: T, y: T, ch: usize, w: f64, h: f64) -> (T, T) { + let (cx, cy) = (st.center.x * w, st.center.y * h); + let m = (st.radius * w.max(h)).max(1e-9); + let k = &st.planes[ch.min(2)]; + let dx = (x - cx) / m; + let dy = (y - cy) / m; + let r2 = dx * dx + dy * dy; + let r = r2.sqrt_nonneg().clamp_range(k[17], k[18]); + let mut f = r * k[14] + k[13]; + for &c in k[..13].iter().rev() { + f = f * r + c; + } + let (rx, ry) = if st.reciprocal { (dx / f, dy / f) } else { (dx * f, dy * f) }; + let sx = rx + dx * (k[15] * 2.0) * dy + (r2 + dx * 2.0 * dx) * k[16]; + let sy = ry + dx * (k[16] * 2.0) * dy + (r2 + dy * 2.0 * dy) * k[15]; + (sx * m + cx, sy * m + cy) +} + /// DNG `FixVignetteRadial` gain at source position `p` (px). pub fn embedded_vignette_gain(v: &EmbeddedVignette, p: Point, w: f64, h: f64) -> f64 { let (cx, cy) = (v.center.x * w, v.center.y * h); @@ -316,7 +362,20 @@ pub fn reorient_lens(lens: &EmbeddedLens, o: Orientation, w: f64, h: f64) -> Emb EmbeddedWarp { planes, center: map_pt(wp.center), radius: wp.radius * long_ratio } }); let vignette = lens.vignette.map(|v| EmbeddedVignette { k: v.k, center: map_pt(v.center), radius: v.radius * long_ratio }); - EmbeddedLens { warp, vignette } + let stages = lens.stages.map(|st| { + st.map(|st| { + let c = Point::new(st.center.x * w, st.center.y * h); + let c2 = o.map(c.x, c.y, w, h); + let planes = st.planes.map(|mut k| { + // same rotation of the tangential vector as for `WarpRectilinear` + let e = o.map(c.x + k[16], c.y + k[15], w, h); + (k[15], k[16]) = (e.1 - c2.1, e.0 - c2.0); + k + }); + EmbeddedWarpStage { planes, center: map_pt(st.center), radius: st.radius * long_ratio, reciprocal: st.reciprocal } + }) + }); + EmbeddedLens { warp, vignette, stages } } // ------------------------------------------------------------------------------------------ lateral CA @@ -646,7 +705,7 @@ mod tests { }; let planes = [plane(), plane(), plane()]; let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; - wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None }); + wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); wp.lens_dist = r.maybe(0.0, 2.0); } wp @@ -758,6 +817,7 @@ mod tests { radius: 0.6, }), vignette: None, + ..Default::default() }; let mut cases: Vec = Vec::new(); let mut s = DevelopSettings::default(); @@ -863,6 +923,7 @@ mod tests { let lens = EmbeddedLens { warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), + ..Default::default() }; let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); // rotate 90° cw: (x, y) → (h − y, x) @@ -879,6 +940,140 @@ mod tests { assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); } + /// A `WarpRectilinear2` stage with the same coefficients on every plane. + fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { + let mut k = [0.0; 19]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } + } + + fn chain(stages: &[EmbeddedWarpStage]) -> Warp { + let mut wp = Warp::identity(300.0, 200.0); + let mut lens = EmbeddedLens::default(); + for (slot, st) in lens.stages.iter_mut().zip(stages) { + *slot = Some(*st); + } + wp.lens = Some(lens); + wp.lens_dist = 1.0; + wp + } + + #[test] + fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { + let c = Point::new(0.5, 0.5); + let p = Point::new(40.0, 25.0); + // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels + for reciprocal in [false, true] { + let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); + assert!(wp.moves_pixels() && !wp.per_channel()); + assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); + } + // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) + let m = 0.6 * 300.0; + let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); + let r = d.x.hypot(d.y); + let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); + assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); + let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); + assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); + // list order: [A, B] maps an output position through B first, then A + let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); + let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); + let ab = chain(&[a, b]); + let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); + assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); + assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); + // strength scales the correction; a lens without a chain or warp does not move pixels + let mut half = ab.clone(); + half.lens_dist = 0.5; + let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); + assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); + assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); + // planes that differ (lateral CA) are sampled separately + let mut st = stage(&[(0, 1.0)], c, 0.6, false); + st.planes[0][0] = 1.01; + assert!(chain(&[st]).per_channel()); + } + + #[test] + fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { + let c = Point::new(0.5, 0.5); + let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); + st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); + let wp = chain(&[st]); + let m = 0.5 * 300.0; + // far out (r > 0.4): f stays f(0.4) = 1.4 + let p = Point::new(10.0, 100.0); + let d = (p.x - 150.0) / m; + assert!(d.abs() > 0.4); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); + // near the centre (r < 0.2): f stays f(0.2) = 1.2 + let p = Point::new(150.0 + 0.1 * m, 100.0); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); + } + + #[test] + fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { + let mut r = Rng(11); + for _ in 0..200 { + let mut stages = Vec::new(); + for _ in 0..2 { + let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); + for k in st.planes.iter_mut() { + k[0] = 1.0 + r.range(-0.05, 0.05); + for v in k[1..15].iter_mut() { + *v = r.maybe(-0.5, 0.5); + } + (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); + (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); + } + stages.push(st); + } + let wp = chain(&stages); + let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for _ in 0..20 { + let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); + let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + for i in 0..=4 { + for j in 0..=4 { + let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!( + sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), + "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" + ); + } + } + } + } + } + + #[test] + fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { + let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); + st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); + let mut lens = EmbeddedLens::default(); + lens.stages[0] = Some(st); + let p = Point::new(50.0, 30.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); + let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); + assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); + } + #[test] fn defringe_removes_purple_at_edges_only() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 5acae0921..09f2bbe6a 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -191,6 +191,81 @@ pub struct Proof { pub space: OutputSpace, pub dest_warning: bool, pub display_warning: bool, + /// An output-device profile to proof against instead of `space` (a printer / paper profile): + /// the id [`register_proof_profile`] gave its table; 0 = none. An id with no table is ignored. + #[serde(skip_serializing_if = "is_zero_id")] + pub profile: u64, +} + +fn is_zero_id(v: &u64) -> bool { + *v == 0 +} + +/// A soft-proof table for an output-device profile, built by the caller's CMS: `n³` entries over +/// working-space (linear Rec.2020) RGB encoded with the sRGB curve, index `(r * n + g) * n + b`, +/// each the colour the device reproduces (linear Rec.2020, paper white already applied when the +/// proof simulates paper and ink) and, in `[3]`, how far outside the device gamut the colour is +/// (0 inside; above [`PROOF_GAMUT_TOLERANCE`] counts as outside). +#[derive(Clone, Debug, PartialEq)] +pub struct ProofLut { + n: usize, + data: Vec<[f32; 4]>, +} + +/// Gamut distance (sRGB-curve code values, 0..1) above which a colour is out of the proof gamut. +pub const PROOF_GAMUT_TOLERANCE: f32 = 0.02; + +impl ProofLut { + /// `None` unless `data` holds `n³` finite entries with `2 ≤ n ≤ 129`. + pub fn new(n: usize, data: Vec<[f32; 4]>) -> Option { + let ok = (2..=129).contains(&n) && data.len() == n * n * n && data.iter().flatten().all(|v| v.is_finite()); + ok.then_some(ProofLut { n, data }) + } + + /// The proofed colour and gamut distance of working-space `rgb` (trilinear). + pub fn sample(&self, rgb: [f32; 3]) -> ([f32; 3], f32) { + let m = (self.n - 1) as f32; + let f = rgb.map(|v| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * m); + let i0 = f.map(|v| (v.floor() as usize).min(self.n - 2)); + let t = [f[0] - i0[0] as f32, f[1] - i0[1] as f32, f[2] - i0[2] as f32]; + let mut acc = [0.0f32; 4]; + for corner in 0..8usize { + let b = [corner >> 2 & 1, corner >> 1 & 1, corner & 1]; + let w: f32 = (0..3).map(|k| if b[k] == 1 { t[k] } else { 1.0 - t[k] }).product(); + let idx = ((i0[0] + b[0]) * self.n + i0[1] + b[1]) * self.n + i0[2] + b[2]; + if let Some(e) = self.data.get(idx) { + for k in 0..4 { + acc[k] += e[k] * w; + } + } + } + ([acc[0], acc[1], acc[2]], acc[3]) + } +} + +/// Proof tables by id (a few: the profiles a session proofs against). +static PROOF_PROFILES: std::sync::Mutex)>> = std::sync::Mutex::new(Vec::new()); +const PROOF_PROFILES_KEPT: usize = 8; + +/// Make `lut` available to renders as [`Proof::profile`] `id` (any nonzero number that identifies +/// the profile and its options, e.g. a hash). Returns `id` (`1` for `0`). +pub fn register_proof_profile(id: u64, lut: ProofLut) -> u64 { + let id = id.max(1); + let mut v = PROOF_PROFILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + v.retain(|(k, _)| *k != id); + v.push((id, std::sync::Arc::new(lut))); + let extra = v.len().saturating_sub(PROOF_PROFILES_KEPT); + v.drain(..extra); + id +} + +/// The table registered as `id`. +pub fn proof_profile(id: u64) -> Option> { + if id == 0 { + return None; + } + let v = PROOF_PROFILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + v.iter().find(|(k, _)| *k == id).map(|(_, l)| l.clone()) } /// Warning colours (sRGB-encoded): destination gamut red, display gamut blue. @@ -198,8 +273,10 @@ pub const PROOF_DEST_WARNING: [f32; 3] = [1.0, 0.0, 0.0]; pub const PROOF_DISPLAY_WARNING: [f32; 3] = [0.0, 0.25, 1.0]; /// [`Proof`] as the per-pixel stage uses it. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct ProofParams { + /// The output-device table (replaces `to_proof` / `proof_to_out` when present). + pub table: Option>, /// Linear Rec.2020 → linear proof RGB, its luminance weights, linear proof → linear output RGB. pub to_proof: [[f32; 3]; 3], pub luma: [f32; 3], @@ -210,11 +287,12 @@ pub struct ProofParams { impl Proof { pub fn key(self) -> u64 { - (self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9 + ((self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9) ^ self.profile.rotate_left(16) } pub fn params(self, out: OutputSpace) -> ProofParams { ProofParams { + table: proof_profile(self.profile), to_proof: self.space.from_working(), luma: self.space.luma(), proof_to_out: self.space.rgb_space().to_space(&out.rgb_space()).to_f32(), diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index c50bcf799..483f83f3c 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -5,6 +5,10 @@ //! (frequency separation — a fast, stable approximation of gradient-domain healing). //! - **Remove** is Heal with an automatically chosen source: candidate offsets on rings around the //! spot are scored by how well the source's surrounding annulus matches the target's. +//! - **Fill** (content-aware) rebuilds the spot from patches of its surroundings instead of one +//! source: PatchMatch-based completion ([`lightcraft_inpaint::complete`]), coarse to fine, so the +//! structure is chosen at a scale set by the spot's size (the same at preview and export size). +//! Deterministic; the source offset is ignored. use lightcraft_develop::{Spot, SpotMode}; use lightcraft_geom::Point; @@ -235,6 +239,19 @@ pub fn window_for_reads_checked( ) } +/// Content-aware fill of the box: `src` becomes `tgt` with every pixel under the stroke (`alpha > 0`) +/// rebuilt from the rest of the box. Falls back to a smooth membrane fill when the box holds no whole +/// patch to copy from. +fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh: usize) { + let hole: Vec = alpha.iter().map(|a| *a > 0.0).collect(); + let flat: Vec = tgt.iter().flatten().copied().collect(); + let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) + .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); + for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { + *d = [v[0], v[1], v[2]]; + } +} + /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { if spots.is_empty() { @@ -253,7 +270,9 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { }) .collect(); let Some(&first) = pts.first() else { continue }; + let fill = spot.mode == SpotMode::Fill; let offset = match spot.source_offset { + _ if fill => (0.0, 0.0), Some(o) => { let a = to_out.apply(Point::new(0.0, 0.0)); let b = to_out.apply(o); @@ -269,7 +288,8 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { x1 = x1.max(p.0 + r); y1 = y1.max(p.1 + r); } - let pad = r; + // content-aware fill reads the spot's surroundings (the window holds 2 radii around it) + let pad = if fill { 2.0 * r } else { r }; let (bx0, by0) = (((x0 - pad).floor().max(0.0)) as usize, ((y0 - pad).floor().max(0.0)) as usize); let (bx1, by1) = (((x1 + pad).ceil() as usize).min(w), ((y1 + pad).ceil() as usize).min(h)); if bx1 <= bx0 || by1 <= by0 { @@ -292,7 +312,10 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { tgt[y * bw + x] = img.get(bx0 + x, by0 + y); } } - let heal = spot.mode != SpotMode::Clone; + if fill { + fill_box(&mut src, &tgt, &alpha, bw, bh); + } + let heal = matches!(spot.mode, SpotMode::Remove | SpotMode::Heal); let low = if heal { // Tone correction from the spot's *surroundings* only: normalized convolution of // (target − source) over pixels outside the stroke (a membrane-like interpolation). @@ -361,6 +384,79 @@ mod tests { assert!((c[0] - 0.26).abs() < 0.03, "{c:?}"); } + /// Vertical stripes (period `period` px) with a black blot over the middle. + fn striped_with_blot(w: usize, h: usize, period: usize) -> Rgb32f { + let mut img = Rgb32f::from_fn(w, h, |x, _| if (x / period).is_multiple_of(2) { [0.2; 3] } else { [0.7; 3] }); + let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, w as f32 * 0.04); + for y in 0..h { + for x in 0..w { + if (x as f32 + 0.5 - cx).hypot(y as f32 + 0.5 - cy) < r { + img.set(x, y, [0.0; 3]); + } + } + } + img + } + + fn fill_spot() -> Spot { + // a source offset is ignored by Fill + Spot { + mode: SpotMode::Fill, + points: vec![Point::new(0.5, 0.5)], + size: 0.06, + feather: 0.0, + source_offset: Some(Point::new(0.3, 0.0)), + ..Default::default() + } + } + + #[test] + fn fill_rebuilds_structure_deterministically() { + let (w, h) = (200, 160); + let mut img = striped_with_blot(w, h, 8); + let frame = Frame::new(w, h, &DevelopSettings::default(), true); + apply(&mut img, &[fill_spot()], &frame, frame.px_per_long(w)); + // the blot is gone and the stripes continue through it + let mut err = 0.0f32; + for x in 90..110 { + let want = if (x / 8) % 2 == 0 { 0.2 } else { 0.7 }; + err += (img.get(x, 80)[0] - want).abs(); + } + assert!(err / 20.0 < 0.12, "mean error {}", err / 20.0); + let mut again = striped_with_blot(w, h, 8); + apply(&mut again, &[fill_spot()], &frame, frame.px_per_long(w)); + assert_eq!(img.data, again.data); + } + + #[test] + fn fill_matches_between_preview_and_export_size() { + // the same photo at two sizes: the half-size fill matches the full-size one, downsampled + let (mut big, mut small) = (striped_with_blot(320, 240, 16), striped_with_blot(160, 120, 8)); + let (fb, fs) = (Frame::new(320, 240, &DevelopSettings::default(), true), Frame::new(160, 120, &DevelopSettings::default(), true)); + apply(&mut big, &[fill_spot()], &fb, fb.px_per_long(320)); + apply(&mut small, &[fill_spot()], &fs, fs.px_per_long(160)); + let (mut err, mut n) = (0.0f32, 0); + for y in 50..70 { + for x in 70..90 { + let b: f32 = [(0, 0), (1, 0), (0, 1), (1, 1)].iter().map(|(dx, dy)| big.get(2 * x + dx, 2 * y + dy)[0]).sum::() / 4.0; + err += (b - small.get(x, y)[0]).abs(); + n += 1; + } + } + assert!(err / n as f32 <= 0.08, "mean difference {}", err / n as f32); + } + + #[test] + fn fill_survives_hostile_spots() { + let frame = Frame::new(40, 30, &DevelopSettings::default(), true); + for (p, size) in [((0.0, 0.0), 2.0), ((-5.0, 9.0), 0.1), ((0.5, 0.5), 0.0), ((f64::NAN, 0.5), 0.1), ((0.5, 0.5), f64::INFINITY)] { + let mut img = Rgb32f::from_fn(40, 30, |x, _| [x as f32 / 40.0; 3]); + let spot = Spot { mode: SpotMode::Fill, points: vec![Point::new(p.0, p.1)], size, ..Default::default() }; + apply(&mut img, &[spot], &frame, frame.px_per_long(40)); + assert!(img.data.iter().flatten().all(|v| v.is_finite())); + } + } + #[test] fn clone_copies_source() { let mut img = Rgb32f::from_fn(100, 100, |x, _| if x < 50 { [0.1; 3] } else { [0.9; 3] }); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 185d96b31..2398feece 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -206,7 +206,13 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { for req in [&full, &draft] { let a = render_cached(&src, &info, &s, req, &cache).image; let b = render(&src, &info, &s, req).image; - assert_eq!(a, b, "step {i} {:?}", req.quality); + if req.quality == Quality::Draft && i == 6 { + // a draft while noise reduction changes keeps the wide planes of the image before it + let d = max_diff(&a, &b); + assert!(d <= 3, "step {i} draft: {d}"); + } else { + assert_eq!(a, b, "step {i} {:?}", req.quality); + } } } assert_eq!(cache.len(), 2); @@ -220,6 +226,35 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { assert_eq!(render_cached(&other, &info, &s, &full, &cache).image, render(&other, &info, &s, &full).image); } +#[test] +fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_not() { + use crate::{Quality, StageCache, render_cached}; + use std::sync::Arc; + let src = Arc::new(scene()); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(160, 160) }; + let full = RenderRequest { quality: Quality::Full, ..draft }; + let mut s = typical_edits(); + s.effects.clarity = 60.0; + render_cached(&src, &info, &s, &draft, &cache); + let clarity_plane = |c: &StageCache| c.entries.lock().unwrap().first().and_then(|e| e.planes.clarity.clone()).unwrap().1; + let first = clarity_plane(&cache); + s.detail.nr_luminance = 50.0; + render_cached(&src, &info, &s, &draft, &cache); + assert!(Arc::ptr_eq(&first, &clarity_plane(&cache)), "a draft redid the clarity plane for a noise-reduction change"); + for v in [10.0, 45.0, 80.0] { + s.detail.nr_luminance = v; + s.detail.nr_color = v / 2.0; + let a = render_cached(&src, &info, &s, &draft, &cache).image; + let d = max_diff(&a, &render(&src, &info, &s, &draft).image); + // close to the exact draft (a few levels at edges, where clarity is strong) + assert!(d <= 8, "nr {v}: {d}"); + // a full-quality render after the drafts is exact (it never takes the stand-in planes) + assert_eq!(render_cached(&src, &info, &s, &full, &cache).image, render(&src, &info, &s, &full).image, "nr {v}"); + } +} + #[test] fn exposure_after_spatial_filters_equals_exposing_the_source() { // The pipeline filters the image before exposure and applies exposure per pixel; filters on @@ -334,8 +369,13 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image; let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; assert_eq!(plain, same); - let warned = - render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image; + let warned = render( + &src, + &info, + &s, + &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 })), + ) + .image; let n = red(&warned); assert!(n > 0 && n < warned.len(), "{n}"); // …and nothing is flagged on an unsaturated edit of a neutral ramp @@ -344,7 +384,10 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { &grey, &info, &DevelopSettings::default(), - &RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) }, + &RenderRequest { + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true, profile: 0 }), + ..RenderRequest::fit(64, 8) + }, ); assert_eq!(red(&g.image) + blue(&g.image), 0); @@ -353,9 +396,13 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; assert_ne!(wide, proofed); // proofing a wider space than the display: what the display can't show is flagged blue - let pro = - render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true }))) - .image; + let pro = render( + &src, + &info, + &s, + &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true, profile: 0 })), + ) + .image; assert!(blue(&pro) > 0); assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } @@ -384,7 +431,7 @@ fn display_space_renders_like_the_matching_output_space() { // soft proofing on a display: the display gamut warning is about *this* display let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count(); - let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true }); + let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true, profile: 0 }); let on_srgb = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Srgb, 2).unwrap()), pro)).image); let on_wide = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Rec2020, 3).unwrap()), pro)).image); assert!(on_srgb > 0 && on_wide < on_srgb, "{on_srgb} {on_wide}"); @@ -392,6 +439,41 @@ fn display_space_renders_like_the_matching_output_space() { assert_eq!(plain, on_srgb, "an sRGB display warns like no display profile"); } +/// Proofing against an output-device table: the table's colours are what the render shows, and its +/// gamut distances drive the destination warning. +#[test] +fn soft_proof_through_a_device_table() { + use crate::{OutputSpace, Proof, ProofLut, register_proof_profile}; + let n = 5; + // a "paper" that dims everything to 80 % and calls every non-grey colour out of gamut + let data: Vec<[f32; 4]> = (0..n * n * n) + .map(|i| { + let c = [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)); + let grey = c[0] == c[1] && c[1] == c[2]; + [c[0] * 0.8, c[1] * 0.8, c[2] * 0.8, if grey { 0.0 } else { 1.0 }] + }) + .collect(); + assert!(ProofLut::new(n, data[1..].to_vec()).is_none()); + assert!(ProofLut::new(n, vec![[f32::NAN; 4]; n * n * n]).is_none()); + let id = register_proof_profile(0xfeed, ProofLut::new(n, data).unwrap()); + let info = SourceInfo { raw: true, ..Default::default() }; + let white = Rgb32f::from_fn(16, 4, |_, _| [100.0; 3]); + let req = |proof| RenderRequest { space: OutputSpace::Srgb, proof, ..RenderRequest::fit(16, 4) }; + let plain = render(&white, &info, &DevelopSettings::default(), &req(None)).image; + let pr = Proof { profile: id, dest_warning: true, ..Default::default() }; + let paper = render(&white, &info, &DevelopSettings::default(), &req(Some(pr))).image; + assert!(plain.data[0][0] > 250 && paper.data[0][0] < 240 && paper.data[0][0] > 200, "{:?} {:?}", plain.data[0], paper.data[0]); + // an unknown id falls back to the space proof (sRGB on sRGB: no change) + let none = render(&white, &info, &DevelopSettings::default(), &req(Some(Proof { profile: 12345, ..Default::default() }))).image; + assert_eq!(none, plain); + // saturated colours are flagged + let src = scene(); + let mut s = DevelopSettings::default(); + s.color.saturation = 100.0; + let warned = render(&src, &info, &s, &req(Some(pr))).image; + assert!(warned.data.iter().any(|p| p[..3] == [255, 0, 0])); +} + #[test] fn display_space_rejects_degenerate_matrices() { use crate::DisplaySpace; @@ -427,6 +509,114 @@ fn tint_negative_is_green_and_positive_is_magenta() { } } +/// Settings written on the relative scale (6500 K / 0 = as shot, no `WbScale`) for a raw whose camera matrices now +/// give it a Kelvin scale render exactly as they did: the same white-balance matrix, and the same pixels, as the +/// relative render; the Kelvin / tint they convert to renders the same again (issue #730, the class of bug of #510). +#[test] +fn legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw() { + use crate::local::{effective_wb, legacy_to_kelvin, wb_matrix_for}; + use lightcraft_develop::{WbMode, WbScale}; + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let src = Rgb32f::filled(16, 16, [0.3, 0.18, 0.09]); + // (as-shot whites the ILCE-7M3 table of issue #730 reads, and the relative slider's whole reach: −100..+100 is + // 2000..13550 K around 6500 K; a white bluer than 50000 K after the composition has no Kelvin and clamps, so + // the blue end is paired with as-shot whites up to 7000 K only) + for (as_t, as_tint) in [(4986.0, -2.4), (2707.0, -4.0), (9338.0, 8.9), (6500.0, 0.0), (3157.0, 40.0), (7000.0, -20.0)] { + let new_info = + SourceInfo { raw: true, relative_wb: false, legacy_relative_wb: true, as_shot_temp: as_t, as_shot_tint: as_tint, ..Default::default() }; + let blue_end = as_t <= 7000.0; + for (temp, tint) in [(6500.0, 0.0), (5800.0, 3.0), (7682.0, 10.0), (4000.0, -30.0), (2000.0, 150.0), (12000.0, 55.0), (13550.0, -150.0)] { + if temp > 10000.0 && !blue_end { + continue; + } + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, temp, tint); + assert_eq!(s.wb.scale, WbScale::Legacy); + let old = wb_matrix_for(&old_info, &s); + let new = wb_matrix_for(&new_info, &s); + let near = |a: Option<[[f32; 3]; 3]>, b: Option<[[f32; 3]; 3]>, tol: f32| match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol * (1.0 + x.abs())), + _ => false, + }; + // the same matrix to f32 precision: the white is composed, not read back through Kelvin / tint + assert!(near(old, new, 2e-6), "as shot {as_t}/{as_tint}, {temp}/{tint}: {old:?} vs {new:?}"); + // the converted Kelvin values, once written, render the same again to the precision of the Kelvin / + // tint parametrisation (whose seam at 4000 K costs up to 0.01 %) + let (kt, ktint) = legacy_to_kelvin(temp, tint, (as_t, as_tint)); + assert_eq!(effective_wb(&new_info, &s), (kt, ktint)); + let mut k = s.clone(); + (k.wb.temp, k.wb.tint, k.wb.scale) = (kt, ktint, WbScale::Kelvin); + assert!(near(old, wb_matrix_for(&new_info, &k), 3e-4), "as shot {as_t}/{as_tint}, {temp}/{tint} → {kt}/{ktint}"); + // and the rendered pixels agree + let a = render(&src, &old_info, &s, &RenderRequest::fit(16, 16)).image; + let b = render(&src, &new_info, &s, &RenderRequest::fit(16, 16)).image; + let c = render(&src, &new_info, &k, &RenderRequest::fit(16, 16)).image; + for (x, (y, z)) in a.data.iter().zip(b.data.iter().zip(&c.data)) { + assert_eq!(x, y, "as shot {as_t}/{as_tint}, {temp}/{tint}"); + assert!(x.iter().zip(z).all(|(p, q)| p.abs_diff(*q) <= 1), "{x:?} {z:?}"); + } + } + // as shot on the relative scale is as shot on the Kelvin scale, exactly + assert_eq!(legacy_to_kelvin(6500.0, 0.0, (as_t, as_tint)), (as_t, as_tint)); + } + // a preset stored on the relative scale keeps its old look, not the preset's Kelvin value + let new_info = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() }; + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0); + assert_eq!(effective_wb(&new_info, &s), legacy_to_kelvin(s.wb.temp, s.wb.tint, (4986.0, -2.4))); + s.wb.scale = WbScale::Kelvin; + assert_eq!(effective_wb(&new_info, &s), WbMode::Cloudy.preset().unwrap()); + // a raw whose file carries the matrices (DNG) never had a relative scale: its old values are Kelvin + let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() }; + s.wb.scale = WbScale::Legacy; + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0); + assert_eq!(effective_wb(&dng, &s), (3200.0, 10.0)); + // hostile numbers come back unchanged + assert!(legacy_to_kelvin(f64::NAN, 0.0, (5000.0, 0.0)).0.is_nan()); + assert_eq!(legacy_to_kelvin(5000.0, f64::INFINITY, (5000.0, 0.0)).1, f64::INFINITY); + assert_eq!(legacy_to_kelvin(5000.0, 0.0, (f64::NAN, 0.0)), (5000.0, 0.0)); + assert_eq!(legacy_to_kelvin(6500.0, 0.0, (f64::NAN, 0.0)), (6500.0, 0.0)); + assert_eq!(crate::local::legacy_white(f64::NAN, 0.0, (5000.0, 0.0)), None); + let hostile = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: f64::NAN, as_shot_tint: 0.0, ..Default::default() }; + let _ = render(&Rgb32f::filled(4, 4, [0.2; 3]), &hostile, &s, &RenderRequest::fit(4, 4)); +} + +/// Before an edit, the settings' white balance is brought onto the source's scale: Kelvin raws mark it (converting +/// relative values once, into a Custom value with the same look), everything else clears it. +#[test] +fn normalize_wb_marks_kelvin_raws_and_converts_relative_values_once() { + use crate::local::{legacy_to_kelvin, normalize_wb}; + use lightcraft_develop::{WbMode, WbScale}; + let kelvin = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() }; + // as shot: marked, values untouched + let mut s = DevelopSettings::default(); + normalize_wb(&mut s, &kelvin); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::AsShot, 6500.0, 0.0, WbScale::Kelvin)); + // a relative custom value: converted, Custom, marked; a second pass changes nothing + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 7682.0, 10.0); + normalize_wb(&mut s, &kelvin); + let (t, tint) = legacy_to_kelvin(7682.0, 10.0, (4986.0, -2.4)); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, t, tint, WbScale::Kelvin)); + let again = s.clone(); + normalize_wb(&mut s, &kelvin); + assert_eq!(s, again); + // a DNG's old values are Kelvin already: marked, untouched + let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() }; + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0); + normalize_wb(&mut s, &dng); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, 3200.0, 10.0, WbScale::Kelvin)); + // a rendered photo and a raw without matrices: the scale is cleared + for info in [SourceInfo::default(), SourceInfo { raw: true, relative_wb: true, ..Default::default() }] { + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.scale) = (WbMode::Custom, 5800.0, WbScale::Kelvin); + normalize_wb(&mut s, &info); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.scale), (WbMode::Custom, 5800.0, WbScale::Legacy)); + } +} + /// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`. fn camera_color(temp: f64, tint: f64) -> std::sync::Arc { use lightcraft_color::Mat3; diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index 99c8a1408..d7105acfc 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -140,6 +140,7 @@ fn profile_corrections_use_embedded_lens_only_when_enabled() { let lens = EmbeddedLens { warp: Some(EmbeddedWarp { planes: [[1.0, -0.08, 0.0, 0.0, 0.0, 0.0]; 3], center: Point::new(0.5, 0.5), radius: 0.6 }), vignette: Some(EmbeddedVignette { k: [0.5, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() }; let info = SourceInfo { lens: Some(lens), ..SourceInfo::default() }; let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/tests_tone.rs b/crates/pipeline/src/tests_tone.rs new file mode 100644 index 000000000..a6ec70eb5 --- /dev/null +++ b/crates/pipeline/src/tests_tone.rs @@ -0,0 +1,245 @@ +//! Highlights / Shadows keep the local contrast of the tones they move (issue #632), from process +//! V2; V1 keeps the base it always had (`docs/process-versions.md`). + +use std::sync::Arc; + +use lightcraft_develop::{DevelopSettings, Process, ProcessVersion, controls}; +use lightcraft_raster::{Plane, Rgb32f, Rgba8}; + +use crate::{Quality, RenderRequest, SourceInfo, StageCache, local, render, render_cached}; + +const W: usize = 1200; +const H: usize = 600; + +/// Left half: a bright sky crossed by 1 px twigs (≈ 2.5 EV darker) and a little texture; right +/// half: a dark ground with 1 px light stalks. The two halves meet at x = W/2 (a strong edge). +fn scene() -> Rgb32f { + Rgb32f::from_fn(W, H, |x, y| { + let t = 1.0 + 0.06 * (((x * 7 + y * 13) % 5) as f32 - 2.0) / 2.0; + let line = on_line(x, y); + let v = if x < W / 2 { + if line { 0.14 } else { 0.85 } + } else if line { + 0.06 + } else { + 0.011 + }; + [v * t; 3] + }) +} + +/// Thin lines in a chequer of 48 px cells: twigs where a branch crosses the sky, open sky between. +fn on_line(x: usize, y: usize) -> bool { + (x / 48 + y / 48).is_multiple_of(2) && (x % 4 == 1 || y % 5 == 2) +} + +/// log2 of (mean luma off the lines / mean luma on them) over columns `x0..x1`: the twigs' (or +/// stalks') contrast against their surroundings. +fn line_contrast(img: &Rgba8, x0: usize, x1: usize) -> f32 { + let (mut on, mut off) = ((0.0f64, 0.0f64), (0.0f64, 0.0f64)); + for y in 40..H - 40 { + for x in x0..x1 { + let v = luma(img.data[y * W + x]) as f64; + let acc = if on_line(x, y) { &mut on } else { &mut off }; + acc.0 += v; + acc.1 += 1.0; + } + } + ((off.0 / off.1) / (on.0 / on.1)).log2().abs() as f32 +} + +fn shot(s: &DevelopSettings) -> Rgba8 { + render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +/// Settings on `process`. +fn on(process: Process) -> DevelopSettings { + DevelopSettings { process: process.version(), ..DevelopSettings::default() } +} + +/// V2 settings with control `id` at `v`. +fn with(id: &str, v: f64) -> DevelopSettings { + with_on(Process::V2, id, v) +} + +fn with_on(process: Process, id: &str, v: f64) -> DevelopSettings { + let mut s = on(process); + controls::set(&mut s, id, v); + s +} + +fn luma(p: [u8; 4]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 +} + +/// (mean, rel. local contrast: std of luma minus its 9×9 mean, over the mean) of the block +/// `x0..x1 × 40..H-40`. +fn stats(img: &Rgba8, x0: usize, x1: usize) -> (f32, f32) { + let l = |x: usize, y: usize| luma(img.data[y * W + x]); + let (mut sum, mut hp, mut n) = (0.0f64, Vec::new(), 0.0f64); + for y in 40..H - 40 { + for x in x0..x1 { + let mut m = 0.0; + for dy in 0..9 { + for dx in 0..9 { + m += l(x + dx - 4, y + dy - 4); + } + } + sum += l(x, y) as f64; + hp.push(l(x, y) - m / 81.0); + n += 1.0; + } + } + let mean = (sum / n) as f32; + let var = hp.iter().map(|v| v * v).sum::() / hp.len() as f32; + (mean, var.sqrt() / mean) +} + +#[test] +fn highlights_keep_the_local_contrast_of_bright_areas() { + let (plain, hl, exp) = (shot(&on(Process::V2)), shot(&with("light.highlights", -100.0)), shot(&with("light.exposure", -1.0))); + let (bright, flat_dark) = ((40, 540), (700, 1160)); + let ((m0, c0), (mh, ch), (_, ce)) = (stats(&plain, bright.0, bright.1), stats(&hl, bright.0, bright.1), stats(&exp, bright.0, bright.1)); + let (k0, kh, ke) = (line_contrast(&plain, bright.0, bright.1), line_contrast(&hl, bright.0, bright.1), line_contrast(&exp, bright.0, bright.1)); + // the sky comes down as much as with −1 EV or more… + assert!(mh < m0 - 25.0, "highlights −100 mean {mh} vs {m0}"); + // …and keeps its twigs as −1 EV does (the edge-aware base alone followed them: 0.73 of + // Exposure's twig contrast and 0.79 of its local contrast were left) + assert!(kh > 0.9 * ke && kh > 0.9 * k0, "highlights −100 twig contrast {kh} EV vs exposure −1 {ke}, plain {k0}"); + assert!(ch > 0.9 * ce, "highlights −100 local contrast {ch} vs exposure −1 {ce}, plain {c0}"); + // the dark half is not touched + let (d0, dh) = (stats(&plain, flat_dark.0, flat_dark.1).0, stats(&hl, flat_dark.0, flat_dark.1).0); + assert!((dh - d0).abs() < 1.5, "dark half {dh} vs {d0}"); +} + +#[test] +fn shadows_keep_the_local_contrast_of_dark_areas() { + let (plain, sh, exp) = (shot(&on(Process::V2)), shot(&with("light.shadows", 100.0)), shot(&with("light.exposure", 1.0))); + let dark = (640, 1160); + let ((m0, _), (ms, cs), (_, ce)) = (stats(&plain, dark.0, dark.1), stats(&sh, dark.0, dark.1), stats(&exp, dark.0, dark.1)); + let (k0, ks, ke) = (line_contrast(&plain, dark.0, dark.1), line_contrast(&sh, dark.0, dark.1), line_contrast(&exp, dark.0, dark.1)); + assert!(ms > m0 + 10.0, "shadows +100 mean {ms} vs {m0}"); + // (the edge-aware base alone: 0.75 of Exposure's stalk contrast, 0.72 of its local contrast) + assert!(ks > 0.9 * ke, "shadows +100 stalk contrast {ks} EV vs exposure +1 {ke}, plain {k0}"); + assert!(cs > 0.9 * ce, "shadows +100 local contrast {cs} vs exposure +1 {ce}"); + // the bright half is not lifted + let (b0, bs) = (stats(&plain, 40, 540).0, stats(&sh, 40, 540).0); + assert!((bs - b0).abs() < 1.5, "bright half {bs} vs {b0}"); +} + +#[test] +fn highlights_add_no_halo_at_a_strong_edge() { + // a flat bright field against a flat dark one: the bright pixels next to the edge come down + // (nearly) as much as those far from it, the dark ones stay where they are (the guided + // filter's own softness leaves up to 4 levels within a few px of the edge, as before) + let src = Rgb32f::from_fn(W, H, |x, _| if x < W / 2 { [0.85; 3] } else { [0.011; 3] }); + let shot = |s: &DevelopSettings| render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image; + let (plain, hl) = (shot(&on(Process::V2)), shot(&with("light.highlights", -100.0))); + let at = |img: &Rgba8, x: usize| luma(img.data[(H / 2) * W + x]); + let far = at(&hl, 100) - at(&plain, 100); + for x in (W / 2 - 40..W / 2).chain(W / 2..W / 2 + 40) { + let d = at(&hl, x) - at(&plain, x); + let want = if x < W / 2 { far } else { 0.0 }; + assert!((d - want).abs() <= 5.0, "x {x}: change {d}, far from the edge {want}"); + } +} + +#[test] +fn zero_highlights_and_shadows_render_without_the_base() { + let mut s = on(Process::V2); + s.light.highlights = 0.0; + s.light.shadows = 0.0; + controls::set(&mut s, "effects.clarity", 40.0); + // no base plane is computed or read (the per-pixel stage sees log luminance): the render is + // the one it always was + assert_eq!(local::plane_sigmas(&s, 1200.0, Quality::Full).base, None); + assert!(local::plane_sigmas(&with("light.shadows", 1.0), 1200.0, Quality::Full).base.is_some()); +} + +#[test] +fn median_keeps_steps_and_drops_thin_lines() { + let p = Plane::from_fn(40, 30, |x, y| if x < 20 { -2.0 } else { 2.0 } + if y == 15 || x == 8 { -3.0 } else { 0.0 }); + let m = local::median_hv(&p, 2); + for y in 0..30 { + for x in 0..40 { + let want = if x < 20 { -2.0 } else { 2.0 }; + assert_eq!(m.get(x, y), want, "({x}, {y})"); + } + } + assert_eq!(local::median_hv(&p, 0).data, p.data); + assert_eq!(local::base_detail_radius(5568.0), 8); + assert_eq!(local::base_detail_radius(300.0), 0); + assert_eq!(local::base_detail_radius(1e9), local::BASE_DETAIL_MAX); +} + +#[test] +fn median_matches_a_plain_per_pixel_median() { + let p = + Plane::from_fn(37, 23, |x, y| (((x * 31 + y * 17) * 2_654_435_761usize) % 1000) as f32 / 250.0 - 2.0 + if x % 5 == 0 { 0.5 } else { 0.0 }); + for r in [1, 2, 4, 9] { + let med = |v: &mut Vec| { + v.sort_by(f32::total_cmp); + v[r] + }; + let rows = Plane::from_fn(37, 23, |x, y| med(&mut (0..=2 * r).map(|k| p.get((x + k).saturating_sub(r).min(36), y)).collect())); + let want = Plane::from_fn(37, 23, |x, y| med(&mut (0..=2 * r).map(|k| rows.get(x, (y + k).saturating_sub(r).min(22))).collect())); + assert_eq!(local::median_hv(&p, r).data, want.data, "r {r}"); + } +} + +#[test] +fn v1_keeps_the_base_it_always_had() { + // V1's base is the edge-aware filter of log luminance itself, as before process V2: no median + // radius, and `tone_base` at 0 is that filter bit for bit + let v1 = with_on(Process::V1, "light.highlights", -100.0); + assert_eq!(ProcessVersion::legacy().process(), Process::V1); + for ppl in [300.0, 1200.0, 5568.0, 1e6] { + for q in [Quality::Full, Quality::Draft] { + let sig = local::plane_sigmas(&v1, ppl, q); + assert!(sig.base.is_some()); + assert_eq!(sig.base_detail, 0, "V1 at {ppl} px"); + assert_eq!(local::tone_base_detail(&v1, ppl), 0); + } + } + let l = + Plane::from_fn(301, 157, |x, y| (((x * 31 + y * 17) * 2_654_435_761usize) % 1000) as f32 / 250.0 - 2.0 + if x % 5 == 0 { 0.5 } else { 0.0 }); + for sg in [1.0f32, 4.5, 18.0] { + assert_eq!(local::tone_base(&l, sg, 0).data, local::guided_fast(&l, sg, local::BASE_EPS).data, "sigma {sg}"); + } + // V2 takes the median wherever the image is big enough for one + let v2 = with("light.highlights", -100.0); + assert_eq!(local::plane_sigmas(&v2, 5568.0, Quality::Full).base_detail, local::base_detail_radius(5568.0)); + assert_eq!(local::plane_sigmas(&v2, 300.0, Quality::Full).base_detail, 0); +} + +#[test] +fn only_v2_keeps_the_twigs_under_highlights() { + let bright = (40, 540); + let (p1, p2) = (shot(&on(Process::V1)), shot(&on(Process::V2))); + // without Highlights / Shadows the processes render alike + assert!(p1 == p2, "plain renders differ between V1 and V2"); + let (h1, h2, e) = + (shot(&with_on(Process::V1, "light.highlights", -100.0)), shot(&with("light.highlights", -100.0)), shot(&with("light.exposure", -1.0))); + let (k1, k2, ke) = (line_contrast(&h1, bright.0, bright.1), line_contrast(&h2, bright.0, bright.1), line_contrast(&e, bright.0, bright.1)); + // V1 is the base from before (≈ 0.73 of Exposure's twig contrast), V2 keeps them + assert!(k1 < 0.85 * ke && k2 > 0.9 * ke, "twig contrast V1 {k1} EV, V2 {k2} EV, exposure −1 {ke} EV"); +} + +#[test] +fn the_base_plane_is_cached_per_process() { + let s1 = with_on(Process::V1, "light.highlights", -100.0); + let s2 = with("light.highlights", -100.0); + // moving a photo between processes (Update to Current Process, then undo) with warm caches + // renders what a fresh render does + let src = Arc::new(scene()); + let (info, req) = (SourceInfo::default(), RenderRequest::fit(W, H)); + let cache = StageCache::default(); + for s in [&s1, &s2, &s1, &s2] { + let warm = render_cached(&src, &info, s, &req, &cache).image; + assert!(warm == render(&src, &info, s, &req).image, "process {:?}", s.process); + } + let sig = local::plane_sigmas(&s2, W as f64, Quality::Full); + let sg = sig.base.unwrap(); + assert_ne!(local::base_key(&s1, sg, 0), local::base_key(&s2, sg, 0), "the process is part of the key"); + assert_ne!(local::base_key(&s2, sg, 0), local::base_key(&s2, sg, sig.base_detail)); +} diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 0ed5e81e1..1d5b15c65 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -204,6 +204,40 @@ fn mask_edge_refinement_does_not_depend_on_the_window() { check_noisy("refine", &s, MID, 3, 0.2); } +// Given a mask overlay (the Masking panel's view of the selected mask), a window of it matches the +// same pixels of the whole render with that overlay, for every view and for drawn, AI and +// refined shapes: the zoomed loupe can show the overlay as a window (issue #547) +#[test] +fn a_mask_overlay_window_matches_the_whole_render() { + use crate::{MaskView, Overlay}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shapes = [ + ("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), + ("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0), + ("sky", MaskShape::Sky, 0.0), + ]; + for (name, shape, refine) in shapes { + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 3, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, + refine, + ..Default::default() + }); + for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] { + let overlay = Overlay::Mask { id: 3, view, color: [255, 0, 0], opacity: 70 }; + let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image; + let w = + render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, MID); + // (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`) + assert!(max <= 3 && mean <= 0.25, "{name} {view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)"); + } + } +} + // ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- /// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index d738bb1fe..a7cea813d 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -134,6 +134,11 @@ pub fn as_shot_white_xy_of(info: &crate::RawInfo) -> Xy { as_shot_white(&info.color, info.wb_multipliers) } +/// The as-shot white from colour tags and vendor multipliers alone (see [`as_shot_white_xy`]). +pub fn as_shot_white_of(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { + as_shot_white(c, wb_multipliers) +} + fn as_shot_white(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { if let Some(xy) = c.as_shot_white_xy { return xy; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2d7171fe2..139c80d48 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -35,11 +35,13 @@ pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod ljpeg; +pub(crate) mod llvc; pub mod opcodes; mod preview; pub mod profile; mod saturation; pub mod semantic; +pub mod sparse; pub mod spectral; mod tiffraw; mod unpack; @@ -103,6 +105,8 @@ pub enum RawFormat { /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): /// Hasselblad 3FR and FFF. Read by the DNG reader. CfaTiff, + /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). + KodakDcs, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -123,6 +127,7 @@ impl RawFormat { | RawFormat::Pef | RawFormat::CfaTiff | RawFormat::Srw + | RawFormat::KodakDcs ) } } @@ -194,6 +199,9 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("SAMSUNG") && samsung_raw { return Some(RawFormat::Srw); } + if vendor::kodak::raw_ifd(&t, bytes).is_some() { + return Some(RawFormat::KodakDcs); + } if dng::is_plain_cfa_tiff(&t, bytes) { return Some(RawFormat::CfaTiff); } @@ -324,10 +332,13 @@ pub fn decode(bytes: &[u8]) -> Result { /// [`RawImage::info`] of the decoded image. Black and white levels are not included (some formats /// measure them from the samples). /// -/// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon -/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA -/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for -/// those the samples are read (unpacked, nothing to decompress) and dropped. +/// A few vendor formats derive part of this from the samples themselves (Nikon NEF: optically +/// masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA phase of files +/// without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders). Those read the +/// samples they need and drop them: ORF and PEF unpack their uncompressed data; NEF reads only the +/// first 128 rows (`vendor::nef::MASK_ROWS`), which both the probe and the full decode judge the masked +/// columns from, so a compressed NEF probes in a few milliseconds instead of decoding its whole +/// Huffman stream (issue #708). pub fn probe_info(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Header).map(RawImage::into_info) } @@ -347,7 +358,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Dng => dng::decode(bytes, mode).map(lift_clipped), RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), RawFormat::Cr3 => vendor::cr3::decode(bytes, mode), - RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), + RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes, mode), RawFormat::Arw => vendor::arw::decode(bytes, mode), RawFormat::Raf => vendor::raf::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), @@ -355,6 +366,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Orf => vendor::orf::decode(bytes, mode), RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), + RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs new file mode 100644 index 000000000..0e2cc39cc --- /dev/null +++ b/crates/raw/src/llvc.rs @@ -0,0 +1,471 @@ +//! RDD 34:2015 LLVC codec core as Sony's ARW6 uses it; see docs/arw6-compression.md. +//! +//! Clean-room: written from SMPTE RDD 34:2015 §6.3 (Figures 6.5-6.8) and checked against its worked +//! example (Figure 6.9) and Phase 0 oracle vectors. No other raw decoder's source was consulted. + +use crate::vendor::pef::Bits; +use crate::{RawError, Result}; + +/// Largest coefficient bit depth (DPT) a stream may select; keeps `Bits::get` in range. +pub(crate) const MAX_DPT: u32 = 24; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("ARW6 VLD: {why}")) +} + +/// One bit, failing when the reader has run past the `source_bits` of its half. +fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { + let b = bits.get(1); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + Ok(b) +} + +/// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. +fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result { + let mut n = 0; + while bit(bits, source_bits)? == 0 { + n += 1; + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); + } + } + Ok(n) +} + +/// Decode one coefficient line of `width.div_ceil(4)` sets (RDD 34 §6.3.6) and return exactly `width` values. +pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: usize) -> Result> { + if width == 0 { + return Ok(Vec::new()); // no sets: the state machine below would never terminate + } + let sets = width.div_ceil(4); + let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); + let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); + let mut out: Vec = Vec::new(); + loop { + // DPT value decoding (Figure 6.6). + if cnt == 0 { + dpt_v = false; + } + if cnt == 1 { + dpt_e = true; + } + cnt = cnt.saturating_sub(1); + zr = zr.saturating_sub(1); + if dpt_v { + match dpts { + 0 => { + if bit(bits, source_bits)? == 0 { + // Sony deviation 1, oracle-verified in Phase 0: a zero code at DPT 0 is a zero set entering run mode. + if dpt == 0 { + dpts = 1; + } + } else if bit(bits, source_bits)? == 0 { + // "1 0 0^n 1": DPT up by n+1. + dpt = dpt.saturating_add(zeros_then_one(bits, source_bits)? + 1); + } else { + // "1 1 0^n 1" (DPT down by n+1) or "1 1 0^(DPT-1)" (DPT to 0). + let mut n = 0; + while n + 1 < dpt && bit(bits, source_bits)? == 0 { + n += 1; + } + if n + 1 >= dpt { + dpt = 0; + dpts = 1; + } else { + dpt -= n + 1; + } + } + } + 1 => { + if bit(bits, source_bits)? == 1 { + // "1 0^n 1": leave run mode at DPT n+1. + dpt = zeros_then_one(bits, source_bits)? + 1; + dpts = 0; + } else { + // Run code of ceil(log2(Cnt+2)) bits (Figure 6.6). + let lim = (cnt as u64 + 2).next_power_of_two().trailing_zeros(); + if lim > 32 { + return Err(corrupt("line too wide")); + } + let mut m = 1; + while m < lim && bit(bits, source_bits)? == 0 { + m += 1; + } + if m == lim { + // "0^lim": the rest of the line is zero. + zr = cnt as u64 + 2; + } else { + zr = 1; + for _ in 0..m { + zr = (zr << 1) | bit(bits, source_bits)? as u64; + } + } + dpts = 2; + } + } + _ => { + // Sony deviation 2, oracle-verified in Phase 0: the run ends at ZR == 1 (RDD 34 says 0). + if zr == 1 { + dpt = zeros_then_one(bits, source_bits)? + 1; + dpts = 0; + } + } + } + if dpt > MAX_DPT { + return Err(corrupt("DPT exceeds MAX_DPT")); + } + } + // SGN value decoding (Figure 6.7): signs of the previous set. + if abs_v { + for v in abs { + let v = v as i32; + out.push(if v != 0 && bit(bits, source_bits)? == 1 { -v } else { v }); + } + } + if abs_e { + out.truncate(width); + return Ok(out); + } + abs_v = dpt_v; + abs_e = dpt_e; + // ABS value decoding (Figure 6.8). + if dpt_v { + for a in abs.iter_mut() { + *a = bits.get(dpt); + } + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + } + } +} + +/// Parity dequantiser, Phase 0 (oracle-verified): 0 -> 0, qi = 0 -> q, else +/// `sign(q) * (((2|q| + 1) << (qi - 1)) - (|q| & 1))`. Saturates instead of overflowing on hostile input. +pub(crate) fn dequant(q: i32, qi: u32) -> i32 { + if q == 0 || qi == 0 { + return q; + } + let a = i64::from(q).abs(); + let m = (((2 * a + 1) << (qi - 1).min(30)) - (a & 1)).min(i64::from(i32::MAX)); + (if q < 0 { -m } else { m }) as i32 +} + +/// A row-major plane of integer samples. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Plane { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +impl Plane { + pub fn zeros(width: usize, height: usize) -> Plane { + Plane { width, height, data: vec![0; width.saturating_mul(height)] } + } + pub fn row(&self, y: usize) -> Option<&[i32]> { + let start = y.checked_mul(self.width)?; + self.data.get(start..start.checked_add(self.width)?) + } + fn ok(&self) -> bool { + self.width.checked_mul(self.height) == Some(self.data.len()) + } +} + +/// `(low, high)` sample counts of an `n`-sample signal whose low-pass samples sit at indices = `phase` (mod 2). +pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { + let low = (n + 1).saturating_sub(usize::from(phase)) / 2; + (low, n - low) +} + +/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band. +fn at(v: &[i32], i: isize) -> i32 { + let last = v.len() as isize - 1; + if last < 0 { + return 0; + } + v.get(i.clamp(0, last) as usize).copied().unwrap_or(0) +} + +/// Saturating narrowing: hostile coefficients clamp instead of overflowing. +fn sat(v: i64) -> i32 { + v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + +/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes). +fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result> { + let (n, p) = (l.len() + h.len(), isize::from(phase)); + if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let even: Vec = l + .iter() + .enumerate() + .map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2))) + .collect(); + let mut x = vec![0; n]; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) { + *xv = e; + } + for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1)); + } + Ok(x) +} + +/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back. +fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result { + if !low.ok() || !high.ok() { + return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); + } + let (across_l, across_h) = if vertical { (low.width, high.width) } else { (low.height, high.height) }; + if across_l != across_h { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; + let n = nl + nh; + let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; + let mut out = Plane::zeros(w, h); + let get = |p: &Plane, i: usize| -> Vec { + if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() } + }; + for i in 0..across_l { + let x = inverse_1d(&get(low, i), &get(high, i), phase)?; + if vertical { + for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) { + *o = v; + } + } else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { + r.copy_from_slice(&x); + } + } + Ok(out) +} + +/// Vertical inverse 5/3 (along rows of samples): `low`/`high` are stacked bands of equal width. +pub(crate) fn inverse_53_1d_rows(low: &Plane, high: &Plane, phase: u8) -> Result { + lines(low, high, phase, true) +} + +/// Horizontal inverse 5/3: `low`/`high` are side-by-side bands of equal height. +pub(crate) fn inverse_53_1d_cols(low: &Plane, high: &Plane, phase: u8) -> Result { + lines(low, high, phase, false) +} + +/// 2-D inverse, RDD 34 §6.5 order: vertical IWT (phase `vphase`) on (ll, lh) and (hl, hh), then horizontal (phase 0). +pub(crate) fn inverse_53_2d(ll: &Plane, lh: &Plane, hl: &Plane, hh: &Plane, vphase: u8) -> Result { + let lo = inverse_53_1d_rows(ll, lh, vphase)?; + let hi = inverse_53_1d_rows(hl, hh, vphase)?; + inverse_53_1d_cols(&lo, &hi, 0) +} + +/// The ten bands of a 3-level decomposition (LL only at the deepest level). +pub(crate) struct Bands3 { + pub ll3: Plane, + pub hl3: Plane, + pub lh3: Plane, + pub hh3: Plane, + pub hl2: Plane, + pub lh2: Plane, + pub hh2: Plane, + pub hl1: Plane, + pub lh1: Plane, + pub hh1: Plane, +} + +/// Full inverse: level 3 uses `phases[2]`, level 2 `phases[1]`, level 1 `phases[0]`. +pub(crate) fn reconstruct3(b: &Bands3, phases: [u8; 3]) -> Result { + let ll2 = inverse_53_2d(&b.ll3, &b.lh3, &b.hl3, &b.hh3, phases[2])?; + let ll1 = inverse_53_2d(&ll2, &b.lh2, &b.hl2, &b.hh2, phases[1])?; + inverse_53_2d(&ll1, &b.lh1, &b.hl1, &b.hh1, phases[0]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::pef::Bits; + + fn bits_of(s: &str) -> Vec { + let mut out = vec![0u8; s.len().div_ceil(8)]; + for (i, c) in s.bytes().enumerate() { + if c == b'1' { + out[i / 8] |= 0x80 >> (i % 8); + } + } + out + } + fn line(s: &str, width: usize) -> (Vec, usize) { + let src = bits_of(s); + let mut b = Bits::new(&src); + let v = vld_decode_line(&mut b, src.len() * 8, width).unwrap(); + (v, b.consumed_bits()) + } + + #[test] + fn rdd34_figure_6_9_worked_example() { + // RDD 34:2015 §6.3.6, Figure 6.9: 8 sets, 91 bits + let s = "1000010110000010000100110000101000010100110100000000000011000010010001001010011100110000000"; + let (v, used) = line(s, 32); + assert_eq!(v, vec![6, 0, -8, 4, 0, 0, 0, 0, -9, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 91); + } + #[test] + fn sony_zero_at_dpt0_enters_run_state() { + // Phase 0 vector "dev1": a "0" at DPT 0 is a zero set that moves to DPTs = 1; the next set uses the "1 0^n 1" code + let (v, used) = line("01001101000011001010", 8); + assert_eq!(v, vec![0, 0, 0, 0, 5, 0, -3, 1]); + assert_eq!(used, 20); + } + #[test] + fn sony_run_value_means_minus_one_zero_sets() { + // Phase 0 vector "dev2": run code value 3 = two more zero sets, then "0^n 1" post-run DPT + let (v, used) = line("001101100000000", 16); + assert_eq!(v, vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0]); + assert_eq!(used, 15); + } + #[test] + fn end_of_line_zero_code() { + let (v, used) = line("1001110100101100100", 12); + assert_eq!(v, vec![3, -1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 19); + } + #[test] + fn dpt_moves_up_down_and_to_zero() { + let (v, used) = line("1011000100101111111110011101001011111100000", 20); + assert_eq!(v, vec![1, 0, 0, 0, 7, 7, -7, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 43); + } + #[test] + fn all_zero_line_is_two_bits_and_width_truncates() { + let (v, used) = line("00", 6); // L = 2 sets, width 6 keeps 6 of 8 values + assert_eq!(v, vec![0; 6]); + assert_eq!(used, 2); + } + #[test] + fn width_zero_returns_empty_line() { + let (v, used) = line("1111", 0); + assert!(v.is_empty()); + assert_eq!(used, 0); + } + #[test] + fn hostile_input_errors_instead_of_panicking() { + let mut climb = vec![0u8; 64]; + climb[0] = 0x80; // "1 0 0^n 1" with n > MAX_DPT + let mut b = Bits::new(&climb); + assert!(matches!(vld_decode_line(&mut b, 512, 16), Err(RawError::Corrupt(_)))); + let short = bits_of("10"); // needs more bits than it has + let mut b = Bits::new(&short); + assert!(matches!(vld_decode_line(&mut b, 2, 16), Err(RawError::Corrupt(_)))); + } + proptest::proptest! { + #[test] + fn random_bits_never_panic(src in proptest::collection::vec(proptest::num::u8::ANY, 0..256), width in 0usize..300) { + let mut b = Bits::new(&src); + let _ = vld_decode_line(&mut b, src.len() * 8, width); + } + } + + fn col(v: &[i32]) -> Plane { + Plane { width: 1, height: v.len(), data: v.to_vec() } + } + #[test] + fn dequant_parity_rule() { + for (qi, table) in + [(0u32, [-3, -2, -1, 0, 1, 2, 3]), (1, [-6, -5, -2, 0, 2, 5, 6]), (2, [-13, -10, -5, 0, 5, 10, 13]), (3, [-27, -20, -11, 0, 11, 20, 27])] + { + for (q, v) in (-3..=3).zip(table) { + assert_eq!(dequant(q, qi), v, "q={q} qi={qi}"); + } + } + } + #[test] + fn quantize_inverts_dequant() { + for qi in 0..6 { + for q in -50..=50 { + assert_eq!(crate::vendor::arw6_testenc::quantize(dequant(q, qi), qi), q, "q={q} qi={qi}"); + } + } + } + #[test] + fn dequant_does_not_overflow() { + assert_eq!(dequant(i32::MAX, 31), i32::MAX); + assert_eq!(dequant(i32::MIN, 40), -i32::MAX); + } + #[test] + fn extreme_coefficients_do_not_overflow() { + for v in [i32::MAX, i32::MIN] { + for (w, h) in [(7usize, 9usize), (16, 16), (9, 16)] { + let p = |w, h| Plane { width: w, height: h, data: vec![v; w * h] }; + for ph in 0..2u8 { + let (lh, lw) = (band_rows(h, ph), band_rows(w, 0)); + assert!(inverse_53_2d(&p(lw.0, lh.0), &p(lw.0, lh.1), &p(lw.1, lh.0), &p(lw.1, lh.1), ph).is_ok()); + } + let (w3, h3) = (w * 8, h * 8); + let mut sizes = vec![]; + let (mut cw, mut ch) = (w3, h3); + for ph in [0u8; 3] { + let (a, b) = (band_rows(cw, 0), band_rows(ch, ph)); + sizes.push((a, b)); + (cw, ch) = (a.0, b.0); + } + let q = |l: usize, xh: bool, yh: bool| { + let ((a, b), (c, d)) = sizes[l]; + p(if xh { b } else { a }, if yh { d } else { c }) + }; + let b = Bands3 { + ll3: q(2, false, false), + hl3: q(2, true, false), + lh3: q(2, false, true), + hh3: q(2, true, true), + hl2: q(1, true, false), + lh2: q(1, false, true), + hh2: q(1, true, true), + hl1: q(0, true, false), + lh1: q(0, false, true), + hh1: q(0, true, true), + }; + assert!(reconstruct3(&b, [0, 1, 0]).is_ok()); + } + } + } + #[test] + fn band_rows_counts() { + assert_eq!(band_rows(1093, 0), (547, 546)); + assert_eq!(band_rows(1093, 1), (546, 547)); + assert_eq!(band_rows(1668, 0), (834, 834)); + assert_eq!(band_rows(5, 1), (2, 3)); + assert_eq!(band_rows(1, 1), (0, 1)); + } + #[test] + fn inverse_53_hand_vectors() { + // computed by hand from RDD 34 §6.5 with X[-1] := X[1], X[n] := X[n-2]; checked against the Phase 0 model + assert_eq!(inverse_53_1d_rows(&col(&[10, 20]), &col(&[3, -4]), 0).unwrap().data, vec![8, 17, 20, 16]); + assert_eq!(inverse_53_1d_rows(&col(&[10, 20]), &col(&[3, -4]), 1).unwrap().data, vec![13, 10, 12, 22]); + assert_eq!(inverse_53_1d_rows(&col(&[10, 20, 30]), &col(&[3, -4]), 0).unwrap().data, vec![8, 17, 20, 22, 32]); + assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); + } + #[test] + fn inverse_53_rejects_mismatched_bands() { + assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); + assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); + assert!(inverse_53_1d_cols(&col(&[1, 2]), &col(&[1, 2, 3]), 0).is_err()); + } + proptest::proptest! { + #[test] + fn forward_then_inverse_is_identity(w in 1usize..24, h in 1usize..40, p1 in 0u8..2, p2 in 0u8..2, p3 in 0u8..2, seed in proptest::num::u64::ANY) { + use crate::vendor::arw6_testenc::{forward3, forward_53_2d}; + use proptest::prop_assert_eq; + let mut s = seed | 1; + let mut next = || { s ^= s << 13; s ^= s >> 7; s ^= s << 17; (s % 8191) as i32 - 4095 }; + let x = Plane { width: w, height: h, data: (0..w * h).map(|_| next()).collect() }; + let (ll, lh, hl, hh) = forward_53_2d(&x, p1); + prop_assert_eq!(inverse_53_2d(&ll, &lh, &hl, &hh, p1).unwrap(), x.clone()); + if w >= 8 && h >= 8 { + prop_assert_eq!(reconstruct3(&forward3(&x, [p1, p2, p3]), [p1, p2, p3]).unwrap(), x); + } + } + } +} diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index bca397748..1d3a2c546 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -1,8 +1,13 @@ //! DNG opcode lists (`OpcodeList1/2/3`, DNG 1.7 chapter 7). Opcode lists are always big-endian. //! //! Parsed: every opcode (unknown ones are kept as [`Opcode::Unknown`]). Applied: `WarpRectilinear`, -//! `FixVignetteRadial`, `FixBadPixelsConstant`, `FixBadPixelsList`, `MapTable`, `MapPolynomial`, `GainMap`, -//! `DeltaPerRow/Column`, `ScalePerRow/Column`. `TrimBounds` and `WarpFisheye` are recorded but not applied. +//! `WarpRectilinear2` (opcode 14, DNG 1.6; see [`Opcode::WarpRectilinear2`]), `FixVignetteRadial`, +//! `FixBadPixelsConstant`, `FixBadPixelsList`, `MapTable`, `MapPolynomial`, `GainMap`, `DeltaPerRow/Column`, +//! `ScalePerRow/Column`. `TrimBounds` and `WarpFisheye` are recorded but not applied. +//! +//! Opcodes run in list order, except for the DNG 1.6 skip rule ([`applied_ops`]): a `WarpRectilinear` or +//! `WarpFisheye` that immediately follows an *optional* `WarpRectilinear2` is skipped (the older opcode is the +//! fallback for readers that don't know opcode 14). //! //! Value convention: list 1 runs on raw sample values (16-bit scale), lists 2 and 3 on values normalised to //! [0, 1]; table/polynomial/delta opcodes are defined on the normalised range and are rescaled accordingly. @@ -23,6 +28,15 @@ pub struct Area { pub col_pitch: u32, } +/// Values per coefficient set of `WarpRectilinear2`: 15 radial (`kr0..kr14`), 2 tangential (`kt0`, `kt1`), +/// `min_valid_radius`, `max_valid_radius`. +pub const WR2_LEN: usize = 19; +/// Most coefficient sets (planes) a `WarpRectilinear2` may carry. +const WR2_MAX_PLANES: usize = 4; +/// Largest magnitude accepted for a `WarpRectilinear2` coefficient (a real lens fit is far below this; it keeps the +/// polynomial finite for any radius up to 1). +const WR2_MAX_COEF: f64 = 1e9; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Opcode { /// Per-plane radial (kr0..kr3) + tangential (kt0, kt1) coefficients and the relative optical centre. @@ -34,6 +48,16 @@ pub enum Opcode { planes: Vec<[f64; 4]>, center: [f64; 2], }, + /// DNG 1.6 `WarpRectilinear2` (opcode 14; DNG specification 1.6.0.0, chapter 7, "WarpRectilinear2"). Per plane + /// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]` ([`WR2_LEN`] values, see [`wr2_factor`]), + /// the relative optical centre, and `reciprocalRadial`. `optional` is opcode flag bit 0, which turns on the skip + /// rule for the next warp opcode. + WarpRectilinear2 { + planes: Vec<[f64; WR2_LEN]>, + center: [f64; 2], + reciprocal: bool, + optional: bool, + }, FixVignetteRadial { k: [f64; 5], center: [f64; 2], @@ -99,14 +123,37 @@ impl Opcode { !matches!(self, Opcode::Unknown { .. } | Opcode::TrimBounds { .. } | Opcode::WarpFisheye { .. }) } - /// Whether this is a lens correction (`WarpRectilinear`, `FixVignetteRadial`): from a DNG, or the equivalent of - /// a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these through the - /// "profile corrections" of the develop settings remove them before demosaicing. + /// Whether this is a lens correction (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`): from a DNG, or + /// the equivalent of a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these + /// through the "profile corrections" of the develop settings remove them before demosaicing. pub fn is_lens_correction(&self) -> bool { - matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. }) + matches!(self, Opcode::WarpRectilinear { .. } | Opcode::WarpRectilinear2 { .. } | Opcode::FixVignetteRadial { .. }) } } +/// The opcodes of a list that a reader applies, in order: all of them, minus the DNG 1.6 skip rule (specification +/// 1.6.0.0, chapter 7) — a `WarpRectilinear` or `WarpFisheye` immediately after a `WarpRectilinear2` that is marked +/// optional is not applied. A `WarpRectilinear2` that could not be parsed ([`Opcode::Unknown`]) triggers no skip. +pub fn applied_ops(list: &[Opcode]) -> impl Iterator { + let mut skip = false; + list.iter().filter(move |op| { + let skipped = skip && matches!(op, Opcode::WarpRectilinear { .. } | Opcode::WarpFisheye { .. }); + skip = matches!(op, Opcode::WarpRectilinear2 { optional: true, .. }); + !skipped + }) +} + +/// `WarpRectilinear2` radial factor `r_real / r_ideal` for one coefficient set `k` at ideal radius `r` +/// (specification 1.6.0.0, "WarpRectilinear2"): `f(r) = Σ kr_p · r^p` (p = 0..14) evaluated at `r` clamped to +/// `[min_valid_radius, max_valid_radius]`, so `r_real = r · f` — or `r / f` when `reciprocalRadial` is non-zero. +/// `None` when the factor is not finite (a degenerate set, or `f = 0` with `reciprocalRadial`). +pub fn wr2_factor(k: &[f64; WR2_LEN], reciprocal: bool, r: f64) -> Option { + let rc = r.clamp(k[17].min(k[18]), k[17].max(k[18])); + let f = k[..15].iter().rev().fold(0.0, |acc, &c| acc * rc + c); + let f = if reciprocal { 1.0 / f } else { f }; + f.is_finite().then_some(f) +} + /// The three opcode lists of a DNG raw IFD. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct OpcodeLists { @@ -184,6 +231,27 @@ fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option { } Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] } } + 14 => { + let n = r.u32()? as usize; + // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else + if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 { + return None; + } + let mut planes = Vec::with_capacity(n); + for _ in 0..n { + let mut k = [0.0; WR2_LEN]; + for v in k.iter_mut() { + *v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?; + } + // the spec requires 0 <= min_valid_radius < max_valid_radius <= 1 + if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) { + return None; + } + planes.push(k); + } + let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?]; + Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 } + } 3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] }, 4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? }, 5 => { @@ -268,6 +336,13 @@ fn params(op: &Opcode) -> (u32, u32, Vec) { f64s(&mut o, center); 2 } + Opcode::WarpRectilinear2 { planes, center, reciprocal, optional } => { + u32s(&mut o, &[planes.len() as u32]); + planes.iter().for_each(|p| f64s(&mut o, p)); + f64s(&mut o, center); + u32s(&mut o, &[*reciprocal as u32]); + return (14, *optional as u32, o); + } Opcode::FixVignetteRadial { k, center } => { f64s(&mut o, k); f64s(&mut o, center); @@ -332,7 +407,7 @@ pub fn write_list(list: &[Opcode]) -> Vec { let mut out = (list.len() as u32).to_be_bytes().to_vec(); for op in list { let (id, flags, p) = params(op); - for v in [id, 0x0103_0000, flags, p.len() as u32] { + for v in [id, if id == 14 { 0x0106_0000 } else { 0x0103_0000 }, flags, p.len() as u32] { out.extend_from_slice(&v.to_be_bytes()); } out.extend_from_slice(&p); @@ -383,6 +458,11 @@ fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9) } +/// Most pixels one bad-pixel opcode repairs (the list is 64 MB, `fix_pixels`' set about twice that). Uncapped, a +/// mosaic equal to `FixBadPixelsConstant`'s constant, or one `FixBadPixelsList` rectangle over the image, lists +/// every pixel: tens of GB at [`crate::MAX_SAMPLES`]. Pixels past the cap, in list order, are left as they are. +const MAX_BAD_PIXELS: usize = 1 << 22; + /// Apply a list to an interleaved `w × h × cpp` buffer. `cfa` (when the data is a mosaic) is used by the /// bad-pixel opcodes. `scale` is the value of 1.0 in the buffer: 65535 for list 1 (raw 16-bit values), 1 for /// lists 2 and 3 (normalised values). @@ -449,21 +529,22 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi Opcode::FixBadPixelsConstant { constant, .. } => { let c = (*constant as f64 / 65535.0 * scale as f64) as f32; let bad: Vec<(usize, usize)> = - (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).filter(|&(x, y)| cpp == 1 && buf[y * w + x] == c).collect(); + (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).filter(|&(x, y)| cpp == 1 && buf[y * w + x] == c).take(MAX_BAD_PIXELS).collect(); fix_pixels(buf, w, h, cpp, cfa, &bad); } Opcode::FixBadPixelsList { points, rects, .. } => { - let mut bad: Vec<(usize, usize)> = points.iter().map(|&(r, c)| (c as usize, r as usize)).filter(|&(x, y)| x < w && y < h).collect(); - for r in rects { + let mut bad: Vec<(usize, usize)> = + points.iter().map(|&(r, c)| (c as usize, r as usize)).filter(|&(x, y)| x < w && y < h).take(MAX_BAD_PIXELS).collect(); + 'rects: for r in rects { let [t, l, b, rr] = r.map(|v| v as usize); for y in t..b.min(h) { for x in l..rr.min(w) { + if bad.len() >= MAX_BAD_PIXELS { + break 'rects; + } bad.push((x, y)); } } - if bad.len() > 1 << 22 { - break; - } } fix_pixels(buf, w, h, cpp, cfa, &bad); } @@ -511,9 +592,10 @@ pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) { return; } let (w, h) = (img.width, img.height); - for op in list { + for op in applied_ops(list) { match op { Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center), + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal), Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {} other => { let mut flat: Vec = img.data.iter().flat_map(|p| *p).collect(); @@ -552,6 +634,35 @@ pub fn warp_rectilinear(img: &Rgb32f, planes: &[[f64; 6]], center: [f64; 2]) -> out } +/// DNG 1.6 `WarpRectilinear2`: the `WarpRectilinear` geometry (centre and normalisation as there), with the radial +/// factor of [`wr2_factor`] in place of the even polynomial in `r²`. A pixel whose factor is not finite stays put. +pub fn warp_rectilinear2(img: &Rgb32f, planes: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: bool) -> Rgb32f { + let (w, h) = (img.width, img.height); + let Some(last) = planes.len().checked_sub(1) else { return img.clone() }; + let mut out = Rgb32f::new(w, h); + let (cx, cy) = (center[0] * (w as f64 - 1.0), center[1] * (h as f64 - 1.0)); + let m = max_radius(cx, cy, w as f64 - 1.0, h as f64 - 1.0); + lightcraft_raster::par_rows(&mut out.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate() { + let Some(k) = planes.get(c.min(last)) else { continue }; + let dx = (x as f64 - cx) / m; + let dy = (y as f64 - cy) / m; + let r2 = dx * dx + dy * dy; + let (sx, sy) = match wr2_factor(k, reciprocal, r2.sqrt()) { + Some(f) => { + (dx * f + k[15] * 2.0 * dx * dy + k[16] * (r2 + 2.0 * dx * dx), dy * f + k[16] * 2.0 * dx * dy + k[15] * (r2 + 2.0 * dy * dy)) + } + None => (dx, dy), + }; + let (fx, fy) = (cx + m * sx, cy + m * sy); + *v = img.sample_bilinear(fx as f32 + 0.5, fy as f32 + 0.5)[c]; + } + } + }); + out +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -650,6 +761,33 @@ pub(crate) mod tests { assert_eq!(img[2 * 6 + 2], 102.0); } + /// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as + /// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good + /// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22 + /// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left + /// as it is (uncapped, it was rewritten from the last row). + fn assert_bad_pixels_capped(op: Opcode) { + let (w, h) = (2048, 2054); + let mut buf = vec![0.0f32; w * h]; + buf[..w].fill(500.0); + buf[(h - 1) * w..].fill(900.0); + apply_list(&[op], &mut buf, w, h, 1, None, 65535.0); + assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]); + let past = &buf[(h - 2) * w..(h - 1) * w]; + assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]); + } + + #[test] + fn bad_pixels_constant_is_capped() { + assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 }); + } + + #[test] + fn bad_pixels_list_is_capped_inside_a_rectangle() { + // top, left, bottom, right: every row but the first and the last + assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] }); + } + #[test] fn vignette_and_warp_identity() { let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]); @@ -701,6 +839,12 @@ pub(crate) mod tests { Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, Opcode::ScalePerRow { area: a, scales: vec![1.1] }, Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, + Opcode::WarpRectilinear2 { + planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], + center: [0.5, 0.49], + reciprocal: true, + optional: true, + }, Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, ]; assert_eq!(parse_list(&write_list(&list)), list); @@ -717,4 +861,175 @@ pub(crate) mod tests { apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); } } + + /// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, + /// then the centre and `reciprocalRadial`). + fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { + let mut p = (sets.len() as u32).to_be_bytes().to_vec(); + for s in sets { + p.extend(be(s)); + } + p.extend(be(¢er)); + p.extend_from_slice(&reciprocal.to_be_bytes()); + p + } + + fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { + let mut k = [0.0; WR2_LEN]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + k + } + + #[test] + fn warp_rectilinear2_parses_the_spec_layout() { + let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); + k[15] = 0.001; + k[16] = -0.002; + k[17] = 0.1; + k[18] = 0.9; + let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); + let list = parse_list(&blob); + assert_eq!( + list, + vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] + ); + assert!(list[0].is_applied() && list[0].is_lens_correction()); + // written back as opcode 14, version 1.6.0.0, optional flag kept + let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; + let bytes = write_list(&again); + assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); + assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); + assert_eq!(parse_list(&bytes), again); + } + + #[test] + fn warp_rectilinear2_identity_leaves_the_image() { + let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); + for reciprocal in [false, true] { + let mut i = img.clone(); + let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); + apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); + for (a, b) in i.data.iter().zip(&img.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); + } + } + } + } + + #[test] + fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { + // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 + let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); + let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); + let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); + k[15] = 0.01; + k[16] = -0.02; + let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..3 { + assert!((p[c] - q[c]).abs() < 1e-5); + } + } + // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines + let k = wr2_set(&[(0, 1.0), (1, 0.5)]); + assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); + assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); + let mut k = k; + k[18] = 0.5; // max_valid_radius + assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); + k[17] = 0.25; // min_valid_radius + assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); + assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); + } + + #[test] + fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { + let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); + assert!(is_wr2(&good)); + // every truncation and any trailing byte + for n in 0..good.len() { + assert!(!is_wr2(&good[..n]), "{n}"); + } + assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); + // N = 0, N too big for the data, N beyond the plane limit + for n in [0u32, 2, 5, u32::MAX] { + let mut p = good.clone(); + p[..4].copy_from_slice(&n.to_be_bytes()); + assert!(!is_wr2(&p), "N = {n}"); + } + // non-finite or absurd values, in a coefficient, the valid range and the centre + let patch = |at: usize, v: f64| { + let mut p = good.clone(); + p[at..at + 8].copy_from_slice(&v.to_be_bytes()); + p + }; + for (at, v) in [ + (4, f64::NAN), + (4 + 8, f64::INFINITY), + (4 + 8 * 3, 1e300), + (4 + 8 * 17, -0.5), + (4 + 8 * 18, 1.5), + (4 + 8 * 17, 1.0), + (4 + 19 * 8, f64::NAN), + (4 + 19 * 8 + 8, 1e30), + ] { + assert!(!is_wr2(&patch(at, v)), "{at} {v}"); + } + // refused ones stay Unknown and are not applied + let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); + assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); + let mut img = Rgb32f::filled(5, 5, [0.5; 3]); + apply_list3(&list, &mut img); + assert_eq!(img.get(2, 2), [0.5; 3]); + // a hostile but accepted set (huge radial terms) still gives a finite image + let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); + let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); + for reciprocal in [false, true] { + let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); + assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } + } + + #[test] + fn warp_list_order_and_the_optional_skip_rule() { + let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; + let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; + let d = std::mem::discriminant::; + let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); + // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped + assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); + assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); + // not optional: both run, in list order + assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); + // only the opcode immediately after is skipped + assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); + // two in a row: both run, and the WarpRectilinear after the optional second one is skipped + assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); + // a WarpRectilinear before the WarpRectilinear2 runs, in list order + assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); + // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 + let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let mut one = 1u32.to_be_bytes().to_vec(); + one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let mut blob = 2u32.to_be_bytes().to_vec(); + for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { + for v in [id, 0x0106_0000, flags, p.len() as u32] { + blob.extend_from_slice(&v.to_be_bytes()); + } + blob.extend_from_slice(p); + } + let list = parse_list(&blob); + assert_eq!(list.len(), 2); + assert_eq!(applied_ops(&list).count(), 1); + // a refused (Unknown) opcode 14 triggers no skip + let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; + assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); + } } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index d34fb4c6f..8cf518fd5 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -14,16 +14,35 @@ pub enum PreviewColorSpace { AdobeRgb, } -/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation. -/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence. +/// The colour space the raw says its embedded JPEG is in, for a JPEG that declares none itself (no ICC profile, +/// no EXIF), first available first: the Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB, per Nikon +/// tag documentation); the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the +/// Interoperability IFD's InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB +/// writes, issue #778; per the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour +/// declaration takes precedence. pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { let tiff = Tiff::parse(bytes).ok()?; - let make = tiff.ifds.first()?.string(t::MAKE)?; - if !make.to_ascii_uppercase().starts_with("NIKON") { + let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default(); + let exif = tiff.exif()?; + if make.to_ascii_uppercase().starts_with("NIKON") + && let Some(space) = nikon_color_space(bytes, &tiff, exif, &make) + { + return Some(space); + } + // a DNG's EXIF describes the original capture; its previews are rendered by the converter + if tiff.ifds.first()?.contains(t::DNG_VERSION) { return None; } - let e = tiff.exif()?.get(t::MAKER_NOTE)?; - let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + match exif.u16(0xA001)? { + 1 => Some(PreviewColorSpace::Srgb), + 0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + +fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> Option { + let e = exif.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?; match mn.ifd.u16(0x001e)? { 1 => Some(PreviewColorSpace::Srgb), 2 => Some(PreviewColorSpace::AdobeRgb), diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 582bf9446..566c2b1c3 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -76,7 +76,7 @@ impl HsvTable { /// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates). pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] { - let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); + let hf = (wrap(h, 360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); let h0 = (hf as usize).min(self.hue_divisions - 1); let h1 = (h0 + 1) % self.hue_divisions; let th = (hf - h0 as f32).clamp(0.0, 1.0); @@ -113,7 +113,9 @@ impl HsvTable { // already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered. let s2 = limited(sat, ss); let v2 = if self.srgb_value && max <= 1.0 { - srgb_to_linear((linear_to_srgb(max) * vs).min(1.0)) + // (a value scale of 1 — every hue/saturation-only table — leaves the value as it is: + // no round trip through the sRGB curve, two `powf` per pixel) + if vs == 1.0 { max } else { srgb_to_linear((v_index * vs).min(1.0)) } } else if self.srgb_value { max * srgb_to_linear(vs.min(1.0)).min(1.0) } else { @@ -128,12 +130,27 @@ fn limited(x: f32, k: f32) -> f32 { if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) } } +/// `x.rem_euclid(p)` (`p > 0`), bit for bit, without the `fmodf` call for the values it nearly always +/// gets here (within one period of `[0, p)`; both subtractions are exact there). +#[inline] +fn wrap(x: f32, p: f32) -> f32 { + if (0.0..p).contains(&x) { + x + } else if x > -p && x < 0.0 { + x + p + } else if (p..2.0 * p).contains(&x) { + x - p + } else { + x.rem_euclid(p) + } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; } let h = if max == c[0] { - ((c[1] - c[2]) / d).rem_euclid(6.0) + wrap((c[1] - c[2]) / d, 6.0) } else if max == c[1] { (c[2] - c[0]) / d + 2.0 } else { @@ -145,7 +162,7 @@ fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { /// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of /// [`hue_of`] for out-of-gamut inputs). fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { - let hp = h.rem_euclid(360.0) / 60.0; + let hp = wrap(h, 360.0) / 60.0; let sector = (hp as u32).min(5); let f = hp - sector as f32; let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); @@ -282,6 +299,37 @@ mod tests { HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false } } + #[test] + fn wrap_is_rem_euclid_bit_for_bit() { + for p in [6.0f32, 360.0] { + let mut x = -3.0 * p; + while x < 3.0 * p { + for v in [x, x.next_up(), x.next_down(), -0.0, 0.0, p, -p, 2.0 * p, p.next_down(), (-p).next_up()] { + assert_eq!(wrap(v, p).to_bits(), v.rem_euclid(p).to_bits(), "{v} mod {p}"); + } + x += p / 97.0; + } + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e30, -1e30] { + assert_eq!(wrap(v, p).is_nan(), v.rem_euclid(p).is_nan()); + } + } + } + + #[test] + fn unit_value_scale_with_srgb_values_keeps_the_value() { + let mut t = identity(6, 2, 3); + t.srgb_value = true; + for (i, e) in t.data.iter_mut().enumerate() { + *e = [(i % 7) as f32 - 3.0, 1.1, 1.0]; + } + for c in [[0.2f32, 0.1, 0.05], [0.9, 0.5, 0.7], [0.01, 0.02, 0.015], [1.5, 0.4, 0.2]] { + let max = c[0].max(c[1]).max(c[2]); + let out = t.apply(c); + let got = out[0].max(out[1]).max(out[2]); + assert!((got - max).abs() <= 1e-6 * max.max(1.0), "{c:?} -> {out:?}"); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) } diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs new file mode 100644 index 000000000..14fa3c1f8 --- /dev/null +++ b/crates/raw/src/sparse.rs @@ -0,0 +1,553 @@ +//! Describing a raw file without reading its pixel data. +//! +//! [`probe_info`](crate::probe_info) and `lightcraft_meta::extract` read only a raw file's +//! headers, but those are a few hundred kilobytes of a file of tens of megabytes: an import from a +//! slow drive spends nearly all its time reading samples nothing looks at. [`header_plan`] says +//! which byte ranges of a file those readers need. It is asked about a buffer of the file's full +//! length in which only the ranges in `loaded` hold the file's bytes (the rest is zeros); the caller +//! loads the ranges it names and asks again. Once it says [`HeaderPlan::Ready`], probing the buffer +//! gives what probing the whole file gives. +//! +//! Only bulk sample data is left out: the strips and tiles of each large TIFF image (keeping the +//! start of the first one, where the coding headers a header probe validates sit), a CR3's image +//! tracks (keeping the raw track's coding headers and quantization maps, and the timed-metadata +//! track where Canon puts the as-shot white balance), a RAF's CFA strip (keeping its compression +//! header) and a Panasonic RW2's raw data. Formats whose header probe reads samples (NEF: masked +//! columns; ORF: bit depth and CFA phase; PEF: dark borders) and anything else get +//! [`HeaderPlan::Whole`], as does a structure that is incomplete, inconsistent or keeps metadata +//! inside its sample data: the plan never guesses. + +use std::collections::HashSet; + +use lightcraft_meta::cr3::{Cr3TrackKind, XMP_UUID, is_cr3, parse_cr3}; +use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +use crate::RawFormat; + +/// Bytes kept at the start of a skipped block: the coding headers a header probe validates there +/// (lossless-JPEG frame headers, a RAF's compression header and stripe table) fit with room to spare. +const HEAD: u64 = 64 * 1024; +/// An image whose chunks hold fewer bytes than this is read (thumbnails, small previews). +const BULK: u64 = 256 * 1024; +/// The same bounds as `lightcraft_tiff`'s reader. +const MAX_IFDS: usize = 1024; +const MAX_ENTRIES: u64 = 8192; +const MAX_BOXES: usize = 4096; +/// Child IFDs listed by one `SubIFDs` entry. +const MAX_CHILDREN: u64 = 4096; +/// The RAF raw container's pointer to its raw IFD. +const RAF_RAW_IFD: u16 = 0xf000; + +/// Byte ranges `[start, end)` of a file: sorted, disjoint and not touching. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ranges(Vec<(u64, u64)>); + +impl Ranges { + pub fn new() -> Self { + Self::default() + } + + /// Add `[start, end)` (nothing when it is empty). + pub fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut start, mut end) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + let mut placed = false; + for &(a, b) in &self.0 { + if b < start { + out.push((a, b)); + } else if a > end { + if !placed { + out.push((start, end)); + placed = true; + } + out.push((a, b)); + } else { + start = start.min(a); + end = end.max(b); + } + } + if !placed { + out.push((start, end)); + } + 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) + } + + /// Whether some of `[start, end)` is covered. + pub fn overlaps(&self, start: u64, end: u64) -> bool { + start < end && self.0.iter().any(|&(a, b)| a < end && start < b) + } + + pub fn spans(&self) -> &[(u64, u64)] { + &self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Bytes covered. + pub fn total(&self) -> u64 { + self.0.iter().fold(0u64, |n, &(a, b)| n.saturating_add(b - a)) + } + + /// The parts of `[start, end)` not covered. + pub fn gaps(&self, start: u64, end: u64) -> Ranges { + let mut out = Ranges::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at { + continue; + } + if a >= end { + break; + } + if a > at { + out.insert(at, a); + } + at = at.max(b); + } + if at < end { + out.insert(at, end); + } + out + } + + /// These ranges without the ones in `other`. + pub fn minus(&self, other: &Ranges) -> Ranges { + let mut out = Ranges::new(); + for &(a, b) in &self.0 { + for &(x, y) in other.gaps(a, b).spans() { + out.insert(x, y); + } + } + out + } +} + +/// What [`header_plan`] says about a partly loaded file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HeaderPlan { + /// Load these ranges (none of them loaded yet) and ask again. + Need(Ranges), + /// Everything the header readers look at is loaded: probing the buffer gives the whole file's + /// result. + Ready, + /// The file can't be described from part of it (why): read it whole. + Whole(&'static str), +} + +/// What of the raw file in `bytes` a header probe reads. `bytes` has the file's full length; +/// only the ranges in `loaded` hold its bytes (see the module docs). +pub fn header_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan { + plan(bytes, loaded, false) +} + +/// [`header_plan`] for a raw shown from its embedded preview (a coding not decoded yet): also what +/// [`crate::embedded_preview`] reads — a CR3's JPEG track and preview boxes, the strips of a TIFF +/// raw's preview images. +pub fn preview_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan { + plan(bytes, loaded, true) +} + +fn plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { + let mut w = Want::new(bytes, loaded); + if !w.want(0, 16) { + return w.into_plan().unwrap_or(HeaderPlan::Whole("file too short")); + } + if is_cr3(bytes) { + cr3_plan(bytes, loaded, preview) + } else if bytes.starts_with(b"FUJIFILMCCD-RAW") { + // (its preview, the embedded JPEG, is read anyway: its metadata are there) + raf_plan(bytes, loaded) + } else if bytes.get(..4).is_some_and(|m| matches!(m, b"IIRO" | b"IIRS" | b"MMOR")) { + HeaderPlan::Whole("an ORF header probe reads samples") + } else if Tiff::sniff(bytes).is_some() { + tiff_plan(bytes, loaded, preview) + } else { + HeaderPlan::Whole("not a raw layout read in part") + } +} + +/// Collects the ranges a plan still needs. +struct Want<'a> { + len: u64, + loaded: &'a Ranges, + need: Ranges, +} + +impl<'a> Want<'a> { + fn new(bytes: &[u8], loaded: &'a Ranges) -> Self { + Self { len: bytes.len() as u64, loaded, need: Ranges::new() } + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for what isn't. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len); + if self.loaded.contains(start, end) { + return true; + } + for &(a, b) in self.loaded.gaps(start, end).spans() { + self.need.insert(a, b); + } + false + } + + /// `Need` when something is missing. + fn into_plan(self) -> Option { + (!self.need.is_empty()).then_some(HeaderPlan::Need(self.need)) + } +} + +/// Everything but `holes` is needed. `meta` (IFD tables and out-of-line values) must not lie in a +/// hole; once all is loaded, `ready` decides. +fn finish(len: u64, loaded: &Ranges, holes: &Ranges, meta: &Ranges, ready: impl FnOnce() -> HeaderPlan) -> HeaderPlan { + if holes.spans().iter().any(|&(a, b)| meta.overlaps(a, b)) { + return HeaderPlan::Whole("metadata inside sample data"); + } + let missing = holes.gaps(0, len).minus(loaded); + if missing.is_empty() { ready() } else { HeaderPlan::Need(missing) } +} + +fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { + let len = bytes.len() as u64; + let mut w = Want::new(bytes, loaded); + let meta = match walk_tiff(&mut w, 0, bytes, &[]) { + Ok(meta) => meta, + Err(why) => return HeaderPlan::Whole(why), + }; + if let Some(plan) = w.into_plan() { + return plan; + } + // (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read) + if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef)) { + return HeaderPlan::Whole("its header probe reads samples"); + } + let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; + let mut holes = Ranges::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 + Ok(info) if preview && !matches!(info.photometric, t::photometric::CFA | t::photometric::LINEAR_RAW) => continue, + Ok(info) => info.chunks(len).iter().map(|c| (c.offset, c.len)).collect(), + // a raw IFD without the tags a TIFF image needs (an older CR2's) still lists its strips + Err(_) => match (ifd.u64s(t::STRIP_OFFSETS), ifd.u64s(t::STRIP_BYTE_COUNTS)) { + (Some(offsets), Some(counts)) if offsets.len() == counts.len() => offsets.into_iter().zip(counts).collect(), + _ => continue, + }, + }; + let total = chunks.iter().fold(0u64, |n, c| n.saturating_add(c.1)); + if total < BULK { + continue; + } + for (i, &(offset, size)) in chunks.iter().enumerate() { + let start = if i == 0 { offset.saturating_add(HEAD) } else { offset }; + holes.insert(start, offset.saturating_add(size).min(len)); + } + } + // 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") + && let Some(off) = tiff.ifds.first().and_then(|i| i.u64(crate::vendor::rw2::RAW_OFFSET)) + { + holes.insert(off.saturating_add(HEAD), len); + } + finish(len, loaded, &holes, &meta, || match crate::probe(bytes) { + Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef) => HeaderPlan::Whole("its header probe reads samples"), + Some(f) if f.is_supported() => HeaderPlan::Ready, + _ => HeaderPlan::Whole("not a supported raw"), + }) +} + +/// Walk the IFDs of the TIFF in `data` (which starts at `origin` in the file) as `lightcraft_tiff`'s +/// reader does — the chain, `SubIFDs`, Exif, GPS and Interop, plus the `extra` pointer tags — +/// asking for every IFD table and for the values that locate IFDs and image chunks. Returns the IFD +/// tables and out-of-line values found (absolute ranges); incomplete while `w` needs more. +fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result { + let len = data.len() as u64; + let at = |rel: u64| origin.saturating_add(rel); + let mut meta = Ranges::new(); + if !w.want(origin, at(len.min(16))) { + return Ok(meta); + } + let (order, big) = Tiff::sniff(data).ok_or("not a TIFF")?; + let first = if big { order.read_u64(data, 8) } else { order.read_u32(data, 4).map(u64::from) }.ok_or("truncated TIFF header")?; + meta.insert(origin, at(len.min(if big { 16 } else { 8 }))); + let (head, entry, inline) = if big { (8u64, 20u64, 8u64) } else { (2, 12, 4) }; + let offset_at = |pos: u64| -> Option { + let pos = usize::try_from(pos).ok()?; + if big { order.read_u64(data, pos) } else { order.read_u32(data, pos).map(u64::from) } + }; + let mut stack = vec![first]; + let mut seen = HashSet::new(); + while let Some(off) = stack.pop() { + if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) { + continue; + } + if !w.want(at(off), at(off.saturating_add(head))) { + continue; + } + let Ok(pos) = usize::try_from(off) else { continue }; + let n = if big { order.read_u64(data, pos) } else { order.read_u16(data, pos).map(u64::from) }.unwrap_or(0); + let table = off.saturating_add(head); + let n_read = n.min(len.saturating_sub(table) / entry).min(MAX_ENTRIES); + if n_read == 0 { + continue; + } + let table_end = table.saturating_add(n_read * entry); + // the reader reads the next-IFD pointer only when every entry fits + let complete = n_read == n; + let end = if complete { table_end.saturating_add(inline).min(len) } else { table_end }; + if !w.want(at(off), at(end)) { + continue; + } + meta.insert(at(off), at(end)); + for i in 0..n_read { + let p = table + i * entry; + let Ok(pu) = usize::try_from(p) else { continue }; + let (Some(tag), Some(typ)) = (order.read_u16(data, pu), order.read_u16(data, pu + 2).and_then(FieldType::from_u16)) else { continue }; + let count = if big { order.read_u64(data, pu + 4) } else { order.read_u32(data, pu + 4).map(u64::from) }; + let Some(count) = count else { continue }; + let field = p + if big { 12 } else { 8 }; + let Some(total) = count.checked_mul(typ.size() as u64) else { continue }; + let start = if total <= inline { + field + } else { + match offset_at(field) { + Some(v) => v, + None => continue, + } + }; + let Some(value_end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + if total > inline { + meta.insert(at(start), at(value_end)); + } + let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag); + let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS); + if !locates || !w.want(at(start), at(value_end)) || !child { + continue; + } + // the reader follows every `SubIFDs` value and the first value of the other pointers + let take = if tag == t::SUB_IFDS { count.min(MAX_CHILDREN) } else { count.min(1) }; + for k in 0..take { + if let Some(v) = unsigned(data, order, typ, start + k * typ.size() as u64) { + stack.push(v); + } + } + } + if complete && let Some(next) = offset_at(table_end) { + stack.push(next); + } + } + Ok(meta) +} + +/// One unsigned value of type `typ` at `pos`. +fn unsigned(data: &[u8], order: ByteOrder, typ: FieldType, pos: u64) -> Option { + let pos = usize::try_from(pos).ok()?; + match typ.size() { + 1 => data.get(pos).map(|&b| u64::from(b)), + 2 => order.read_u16(data, pos).map(u64::from), + 4 => order.read_u32(data, pos).map(u64::from), + _ => order.read_u64(data, pos), + } +} + +fn be32(bytes: &[u8], at: u64) -> Option { + let at = usize::try_from(at).ok()?; + bytes.get(at..at.checked_add(4)?).map(|s| u64::from(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))) +} + +fn be64(bytes: &[u8], at: u64) -> Option { + let at = usize::try_from(at).ok()?; + bytes.get(at..at.checked_add(8)?).map(|s| u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) +} + +/// A CR3 is read where `lightcraft_meta::cr3::parse_cr3` and the CR3 decoder look: the file type, +/// `moov` and the XMP `uuid` box, the first sample of each timed-metadata (`CTMD`) track, and the +/// main raw track's coding headers and quantization maps. Previews and image tracks are skipped, +/// unless `preview`: then every top-level box but `mdat` and the JPEG tracks are read too. +fn cr3_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { + let len = bytes.len() as u64; + let mut w = Want::new(bytes, loaded); + // top-level boxes, walked as `parse_cr3` walks them (it stops at the first malformed header) + let mut at = 0u64; + let mut boxes = 0; + while at.saturating_add(8) <= len && boxes < MAX_BOXES { + boxes += 1; + // size, type, a 64-bit size and a uuid + if !w.want(at, at.saturating_add(32)) { + break; + } + let Some(size) = be32(bytes, at) else { break }; + let (box_len, hdr) = match size { + 0 => (len - at, 8), + 1 => match be64(bytes, at + 8) { + Some(l) => (l, 16), + None => break, + }, + n => (n, 8), + }; + let Some(end) = at.checked_add(box_len).filter(|&e| e <= len && box_len >= hdr) else { break }; + let kind = bytes.get(at as usize + 4..at as usize + 8); + let needed = match kind { + Some(b"mdat") => false, + Some(b"moov") => true, + Some(b"uuid") => preview || bytes.get((at + hdr) as usize..(at + hdr + 16) as usize) == Some(XMP_UUID.as_slice()), + _ => preview, + }; + if needed { + w.want(at, end); + } + at = end; + } + if let Some(plan) = w.into_plan() { + return plan; + } + let mut w = Want::new(bytes, loaded); + let Some(cr3) = parse_cr3(bytes) else { return HeaderPlan::Whole("CR3 structure") }; + for track in &cr3.tracks { + if (track.kind == Cr3TrackKind::Other(*b"CTMD") || preview && track.kind == Cr3TrackKind::Jpeg) + && let Some((offset, size)) = track.data + { + w.want(offset as u64, offset.saturating_add(size) as u64); + } + } + if let Some(track) = crate::vendor::cr3::main_track(&cr3.tracks) + && let Some((offset, size)) = track.data + && let Some(coding) = track.compression(bytes) + { + let o = offset as u64; + if w.want(o, o.saturating_add(u64::from(coding.header_size))) + && let Some(sample) = bytes.get(offset..offset.saturating_add(size)) + && let Ok(ranges) = crate::vendor::crx::header_ranges(sample, &coding) + { + for r in ranges { + w.want(o.saturating_add(r.start as u64), o.saturating_add(r.end as u64)); + } + } + } + w.into_plan().unwrap_or(HeaderPlan::Ready) +} + +/// A RAF is read whole but for its CFA strip: the header, the embedded JPEG (its metadata), the +/// record directory and the raw container's IFDs and compression header. +fn raf_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan { + let len = bytes.len() as u64; + let mut w = Want::new(bytes, loaded); + if !w.want(0, 160) { + return w.into_plan().unwrap_or(HeaderPlan::Whole("RAF header")); + } + let (Some(start), Some(size)) = (be32(bytes, 100), be32(bytes, 104)) else { return HeaderPlan::Whole("RAF header") }; + let Some(end) = start.checked_add(size).filter(|&e| start > 0 && size > 0 && e <= len) else { return HeaderPlan::Whole("RAF raw container") }; + let Some(container) = bytes.get(start as usize..end as usize) else { return HeaderPlan::Whole("RAF raw container") }; + let meta = match walk_tiff(&mut w, start, container, &[RAF_RAW_IFD]) { + Ok(meta) => meta, + Err(why) => return HeaderPlan::Whole(why), + }; + if let Some(plan) = w.into_plan() { + return plan; + } + let Ok(ifd) = crate::vendor::raf::raw_ifd(container) else { return HeaderPlan::Whole("RAF raw IFD") }; + let (Some(off), Some(strip)) = (ifd.u64(0xf007), ifd.u64(0xf008)) else { return HeaderPlan::Whole("RAF raw strip") }; + let mut holes = Ranges::new(); + if strip >= BULK { + let s = start.saturating_add(off); + holes.insert(s.saturating_add(HEAD), s.saturating_add(strip).min(end)); + } + finish(len, loaded, &holes, &meta, || HeaderPlan::Ready) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run a plan to its end, loading from `file` what it asks for; the buffer's unloaded bytes are `fill`. + pub(crate) fn run(file: &[u8], prefix: usize, fill: u8) -> (HeaderPlan, Vec, Ranges) { + let mut buf = vec![fill; file.len()]; + let mut loaded = Ranges::new(); + let load = |buf: &mut Vec, loaded: &mut Ranges, a: u64, b: u64| { + buf[a as usize..b as usize].copy_from_slice(&file[a as usize..b as usize]); + loaded.insert(a, b); + }; + load(&mut buf, &mut loaded, 0, prefix.min(file.len()) as u64); + for _ in 0..32 { + match header_plan(&buf, &loaded) { + HeaderPlan::Need(r) => { + for &(a, b) in r.spans() { + assert!(!loaded.overlaps(a, b), "asked again for {a}..{b}"); + load(&mut buf, &mut loaded, a, b); + } + } + done => return (done, buf, loaded), + } + } + panic!("no answer after 32 rounds"); + } + + #[test] + fn ranges_merge_and_subtract() { + let mut r = Ranges::new(); + r.insert(10, 20); + r.insert(30, 40); + r.insert(20, 25); // touching: merged + r.insert(5, 5); // empty + assert_eq!(r.spans(), &[(10, 25), (30, 40)]); + r.insert(0, 100); + assert_eq!(r.spans(), &[(0, 100)]); + let mut r = Ranges::new(); + r.insert(10, 20); + r.insert(30, 40); + assert!(r.contains(12, 18) && r.contains(5, 5) && !r.contains(15, 35)); + assert!(r.overlaps(15, 35) && !r.overlaps(20, 30)); + assert_eq!(r.gaps(0, 50).spans(), &[(0, 10), (20, 30), (40, 50)]); + assert_eq!(r.gaps(12, 18).spans(), &[] as &[(u64, u64)]); + let mut cut = Ranges::new(); + cut.insert(15, 32); + assert_eq!(r.minus(&cut).spans(), &[(10, 15), (32, 40)]); + assert_eq!(r.total(), 20); + } + + #[test] + fn hostile_input_never_panics_and_ends() { + let mut loaded = Ranges::new(); + for len in [0usize, 1, 8, 15, 16, 200] { + let bytes = vec![0u8; len]; + loaded.insert(0, len as u64); + assert!(matches!(header_plan(&bytes, &loaded), HeaderPlan::Whole(_))); + } + // an IFD that points at itself, an IFD count past the end, a huge SubIFDs count + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&2u16.to_le_bytes()); + tiff.extend_from_slice(&[0x4a, 0x01, 4, 0, 0xff, 0xff, 0xff, 0x0f, 8, 0, 0, 0]); // SubIFDs, 0x0fffffff values at 8 + tiff.extend_from_slice(&[0x69, 0x87, 4, 0, 1, 0, 0, 0, 8, 0, 0, 0]); // Exif → itself + tiff.extend_from_slice(&8u32.to_le_bytes()); // next → itself + let (plan, _, _) = run(&tiff, 4, 0); + assert!(matches!(plan, HeaderPlan::Whole(_)), "{plan:?}"); + // a CR3 whose `moov` claims more than the file holds: walked as the parser walks it (it + // stops there), so the plan ends with the little there is, and the probe then fails as + // it does on the whole file + let mut cr3 = 24u32.to_be_bytes().to_vec(); + cr3.extend_from_slice(b"ftypcrx \0\0\0\x01crx isom"); + cr3.extend_from_slice(&u32::MAX.to_be_bytes()); + cr3.extend_from_slice(b"moov"); + cr3.resize(4096, 0); + let (plan, buf, loaded) = run(&cr3, 16, 0); + assert_eq!(plan, HeaderPlan::Ready); + assert_eq!(loaded.total(), 24 + 32, "only the two box headers are read"); + assert_eq!(format!("{:?}", crate::probe_info(&buf)), format!("{:?}", crate::probe_info(&cr3))); + // a RAF whose container lies outside the file + let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec(); + raf.resize(160, 0); + raf[100..104].copy_from_slice(&1000u32.to_be_bytes()); + raf[104..108].copy_from_slice(&1000u32.to_be_bytes()); + assert_eq!(run(&raf, 160, 0).0, HeaderPlan::Whole("RAF raw container")); + } +} diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index 00f41b554..cfa99ff5d 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -8,7 +8,7 @@ //! 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; -use lightcraft_color::Mat3; +use lightcraft_color::{Mat3, Xy}; #[path = "spectral_table.rs"] mod table; @@ -76,6 +76,34 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { } } +/// [`find`] for a camera written as one string, Exif make and model joined by a space (the catalog keeps +/// `"SONY ILCE-7M3"`, `"NIKON CORPORATION NIKON D850"`): every split into make and model is tried, then the +/// whole string as a model alone. Returns the same row as `find(Some(make), model)` for the parts it was +/// made of, so the catalog and the decoder agree on which cameras have matrices. +pub fn find_camera(camera: &str) -> Option<&'static Camera> { + let words: Vec<&str> = camera.split_whitespace().collect(); + if words.is_empty() { + return None; + } + (1..words.len()).find_map(|i| find(Some(&words[..i].join(" ")), &words[i..].join(" "))).or_else(|| find(None, camera)) +} + +/// The as-shot white of a file through `camera`'s matrices instead of the file's own colour tags: the DNG +/// reading of its `AsShotNeutral` or vendor white-balance multipliers (`None` when it has neither, so the +/// white is unknown). What the catalog shows as the As Shot temperature of a raw whose colour model is a +/// JPEG fit: the matrices only convert between multipliers and Kelvin here. +pub fn as_shot_white_through(camera: &Camera, color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option { + if color.as_shot_white_xy.is_none() + && color.as_shot_neutral.is_none() + && wb_multipliers.is_none_or(|m| m.iter().any(|v| !(*v > 0.0 && v.is_finite()))) + { + return None; + } + let mut c = ColorData { profile: Default::default(), ..color.clone() }; + camera.fill(&mut c); + Some(crate::color::as_shot_white_of(&c, wb_multipliers)) +} + /// Every camera in the table. pub fn cameras() -> &'static [Camera] { table::CAMERAS @@ -135,6 +163,60 @@ mod tests { assert_eq!(find(Some(""), ""), None); } + #[test] + fn a_make_model_string_finds_what_its_parts_find() { + // every row and alias, joined the way the catalog keeps a camera ("Make Model") + for c in cameras() { + let joined = format!("{} {}", c.make, c.model); + 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 { + let joined = format!("{make} {alias}"); + assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}"); + assert!(find_camera(&joined).is_some(), "{joined}"); + } + // Exif makes with more words than the brand, and a model that repeats the brand + for (make, model, row) in [ + ("NIKON CORPORATION", "NIKON D850", "D850"), + ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), + ("SONY", "ILCE-7M3", "ILCE-7M3"), + ("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"), + ("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"), + ("FUJIFILM", "GFX100S", "GFX 100"), + ("Panasonic", "DC-GX9", "DC-GX9"), + ] { + let joined = format!("{make} {model}"); + assert_eq!(find_camera(&joined).map(|c| c.model), Some(row), "{joined}"); + assert_eq!(find_camera(&joined), find(Some(make), model), "{joined}"); + } + // a model alone, as a Lightroom catalog names it + assert_eq!(find_camera("ILCE-7M3").map(|c| c.model), Some("ILCE-7M3")); + assert_eq!(find_camera(" ILCE-7M3 ").map(|c| c.model), Some("ILCE-7M3")); + for none in ["", " ", "SONY", "SONY ILCE-7M2", "Canon EOS 7D", "D850 SONY", "OLYMPUS IMAGING CORP. E-M5"] { + assert_eq!(find_camera(none), None, "{none:?}"); + } + } + + #[test] + fn as_shot_white_through_the_matrices_reads_the_multipliers() { + let c = find(Some("SONY"), "ILCE-7M3").unwrap(); + let color = ColorData::default(); + // daylight-ish Sony multipliers: a white near the locus around 5000 K, not the generic model's wild tint + let xy = as_shot_white_through(c, &color, Some([2.2, 1.0, 1.6])).unwrap(); + let (t, tint) = lightcraft_color::cct::xy_to_temp_tint(xy); + assert!((3500.0..7000.0).contains(&t) && tint.abs() < 30.0, "{t} {tint}"); + // the file's own tags are left alone + assert_eq!(color, ColorData::default()); + // unknown white: no multipliers, or unusable ones + assert_eq!(as_shot_white_through(c, &color, None), None); + assert_eq!(as_shot_white_through(c, &color, Some([0.0, 1.0, 1.0])), None); + assert_eq!(as_shot_white_through(c, &color, Some([f32::NAN, 1.0, 1.0])), None); + // a neutral in the tags counts as known + let tagged = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + assert!(as_shot_white_through(c, &tagged, None).is_some()); + } + #[test] fn every_row_is_a_usable_dng_profile() { assert_eq!(cameras().len(), 52); diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d80c67f84..e8c766004 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -29,17 +29,21 @@ //! ([`word16_order`]). Measured on the CC0 DSC-R1 (raw.pixls.us 3221): StripByteCounts 20780544 = 3984×2608×2; read //! big-endian the maximum is 16368 and the image is smooth (neighbour roughness 0.012 against 0.249), read //! little-endian the maximum is 65340 (noise). Black stays the default 512 (the masked columns read 511.4) and white -//! comes from the data. The crop and white balance are NOT addressed here: the file has no maker-note `FullImageSize` -//! and no plain WB tag (they live in its `SR2SubIFD`), so it opens uncropped with unit multipliers. +//! comes from the data. The file has no maker-note `FullImageSize` and no plain WB or crop tag; they live in its +//! `SR2SubIFD` (key `63 be 01 3e`, see [`super::sr2`]): black `0x7300`, as-shot WB `0x7303` (G, R, B, G) and the crop +//! `0x74c3` (x0, y0, x1, y1), each read only when no other source exists. //! //! Packed 12-bit ARW (DSLR-A900; Compression 32767 but BitsPerSample 12 and a strip of exactly width × height × 1.5 //! bytes): plain 12-bit samples, two per three bytes, least-significant-bit first ([`unpack_row12`]); linear, black 128 //! (512 on the 14-bit scale), CFA from the file's own pattern tag, crop centred on the frame (see [`default_crop`]). //! +//! ARW6 (Compressed RAW 2, compression 32766): `arw6.rs`, output in 2 × 14-bit units. +//! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go //! through the generic TIFF path. +use super::arw6; use crate::tiffraw::{Packing, check_image, read_image_in}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; @@ -53,6 +57,11 @@ const BLACK_LEVEL: u16 = 0x7310; const WB_RGGB: u16 = 0x7313; const CROP_TOP_LEFT: u16 = 0x74c7; const CROP_SIZE: u16 = 0x74c8; +/// `SR2SubIFD` tags of the oldest layout (DSC-R1): per-channel black, as-shot WB in G, R, B, G order, and the crop +/// `(x0, y0, x1, y1)`. Used only when nothing else gives the value. +const SR2_OLD_BLACK: u16 = 0x7300; +const SR2_OLD_WB: u16 = 0x7303; +const SR2_OLD_CROP: u16 = 0x74c3; const YCBCR_COEFFICIENTS: u16 = 529; const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). @@ -454,12 +463,27 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re /// on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 (62112 bytes: 975), whose black is 512 by /// ExifTool and by the data floor (issue #535). fn sr2_black(sr2: &super::sr2::SubIfd) -> Option { - match sr2.short_bytes(BLACK_LEVEL)? { + match sr2.short_bytes(BLACK_LEVEL).or_else(|| sr2.short_bytes(SR2_OLD_BLACK))? { (3, plain) if plain.len() == 8 => sr2_black_levels(plain, sr2.order()), _ => None, } } +/// The DSC-R1's as-shot gains `[R/G, 1, B/G]` from `0x7303` (G, R, B, G), when plausible. +fn sr2_old_wb(sr2: &super::sr2::SubIfd) -> Option<[f32; 3]> { + let v = sr2.shorts(SR2_OLD_WB).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0.0))?; + let g = (v[0] + v[3]) / 2.0; + let (r, b) = ((v[1] / g) as f32, (v[2] / g) as f32); + ((0.25..=16.0).contains(&r) && (0.25..=16.0).contains(&b)).then_some([r, 1.0, b]) +} + +/// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame. +fn sr2_old_crop(sr2: &super::sr2::SubIfd, w: usize, h: usize) -> Option { + let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?; + let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize]; + (x0 % 2 == 0 && y0 % 2 == 0 && x1 > x0 && y1 > y0 && x1 <= w && y1 <= h).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0)) +} + /// One black level from the four deciphered per-channel levels (their mean), when they are near-equal and plausible. fn sr2_black_levels(plain: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { let levels: Vec = (0..4).filter_map(|i| order.read_u16(plain, 2 * i)).collect(); @@ -609,6 +633,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { (RawData::U16(Vec::new()), bits) } }, + 32766 if chunks.len() == 1 => { + if bits != 14 { + return Err(RawError::Unsupported("ARW6 12-bit mode (no sample to verify)".into())); + } + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(arw6::decode(src, w, h, mode)?), 16) + } + 32766 => return Err(RawError::Unsupported("ARW6 without exactly one strip".into())), 1 => { let word16 = strip_len >= (w * h * 2) as u64; let packing = if word16 { Packing::Word16 } else { Packing::Msb }; @@ -646,11 +678,20 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), _ => BlackLevel::uniform((scale_bits >= 14).then(|| sr2().and_then(sr2_black)).flatten().unwrap_or(default_black)), }; + // ARW6 samples are in 2 x 14-bit units, so the file's 14-bit levels double; without the tag the white is the + // 14-bit white in those units, 2 x 16383 + let unit = if info.compression == 32766 { 2.0 } else { 1.0 }; let white = if linear_rgb { YCBCR_WHITE + YCBCR_OFFSET } else { - raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)) + let tag = raw.f64(t::WHITE_LEVEL).map(|v| v as f32 * unit).filter(|v| *v > 0.0); + match (tag, info.compression) { + (Some(v), _) => v, + (None, 32766) => 32766.0, + (None, _) => super::white_from_data(samples, scale_bits), + } }; + let black = BlackLevel { values: black.values.iter().map(|v| v * unit).collect(), ..black }; let model = ifd0.string(t::MODEL).unwrap_or_default(); let mn = tiff .exif() @@ -660,12 +701,15 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .f64s(WB_RGGB) .and_then(|v| rggb_gains(&v)) .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) - .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))) + .or_else(|| sr2_old_wb(sr2()?)); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), _ => { let exif_size = tiff.exif().and_then(|e| Some((e.u64(t::PIXEL_X_DIMENSION)?, e.u64(t::PIXEL_Y_DIMENSION)?))); - default_crop(raw, mn.as_ref(), exif_size, w, h, packed12) + let crop = default_crop(raw, mn.as_ref(), exif_size, w, h, packed12); + // no crop from any other source: the old SR2SubIFD's + if crop == Rect::new(0, 0, w, h) { sr2().and_then(|s| sr2_old_crop(s, w, h)).unwrap_or(crop) } else { crop } } }; let mut metadata = lightcraft_meta::from_tiff(&tiff); @@ -694,7 +738,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so // it stays on the optical centre whichever crop frames the image. opcodes: OpcodeLists { - list3: if linear_rgb { + // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. + // Both the image framing and the table geometry must stay within the validated 3:2 scope. + list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { Vec::new() } else { let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); @@ -1131,6 +1177,29 @@ mod tests { file } + #[test] + fn dsc_r1_sr2_subifd_gives_black_wb_and_crop() { + use lightcraft_tiff::ByteOrder::Little; + let key = [0x63, 0xbe, 0x01, 0x3e]; + let enc = |crop: [u16; 4], wb: [u16; 4]| { + let b = super::super::sr2::tests::block_keyed( + 230812, + &[(SR2_OLD_BLACK, 3, &[514, 511, 512, 513]), (SR2_OLD_WB, 3, &wb), (SR2_OLD_CROP, 3, &crop)], + Little, + key, + ); + super::super::sr2::SubIfd::decrypt_keyed(&b, 230812, Little, key) + }; + let sub = enc([16, 8, 3904, 2600], [256, 500, 385, 256]); + assert_eq!(sr2_black(&sub), Some(512.5)); + assert_eq!(sr2_old_wb(&sub), Some([500.0 / 256.0, 1.0, 385.0 / 256.0])); + assert_eq!(sr2_old_crop(&sub, 3984, 2608), Some(Rect::new(16, 8, 3888, 2592))); + // implausible values are dropped, not guessed + assert_eq!(sr2_old_crop(&enc([15, 8, 3904, 2600], [0, 1, 1, 0]), 3984, 2608), None); + assert_eq!(sr2_old_crop(&sub, 3900, 2608), None); + assert_eq!(sr2_old_wb(&enc([16, 8, 3904, 2600], [0, 1, 1, 0])), None); + } + #[test] fn white_balance_prefers_the_applied_levels() { const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs new file mode 100644 index 000000000..f6f535bd6 --- /dev/null +++ b/crates/raw/src/vendor/arw6.rs @@ -0,0 +1,994 @@ +//! Sony ARW6 ("Compressed RAW 2" / HQ, TIFF Compression 32766): container parsing, geometry, colour and decode. +//! +//! Clean-room. The layout comes from SMPTE RDD 34 (Picture_info in word 0) and from Phase 0 black-box +//! inspection of real ILCE-7RM6 files (tile table, group size words, stream headers and index entries), each rule +//! checked by re-serialising parsed tiles byte-for-byte. The format is described in `docs/arw6-compression.md`; section +//! names in italics below refer to it. No other raw decoder's source was consulted. + +use crate::llvc::{Bands3, Plane, band_rows, dequant, reconstruct3, vld_decode_line}; +use crate::vendor::arw6_curve::curve; +use crate::vendor::pef::Bits; +use crate::{RawError, Result}; +use rayon::prelude::*; +use std::ops::Range; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("ARW6: {why}")) +} + +fn unsupported(why: &str) -> RawError { + RawError::Unsupported(format!("ARW6 {why}")) +} + +fn u16_be(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(usize::from(u16::from_be_bytes([*s.first()?, *s.get(1)?]))) +} + +fn u24_be(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(3)?)?; + Some(s.iter().fold(0usize, |a, &v| (a << 8) | usize::from(v))) +} + +fn u32_le(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?) as usize) +} + +/// One tile of the strip: its bytes run from `offset` to the next tile's offset (the last: the strip end). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TileEntry { + pub offset: usize, + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, +} + +/// Most tiles a strip may hold (real files have 1 or 4). +const MAX_TILES: usize = 16; + +/// Parse the tile table at the start of the strip (file structure, *Container*): `u32` count, `u32` 0, then +/// 24-byte entries `u64` offset (relative to the strip), `u32` x, y, width, height, all little-endian. +pub(crate) fn parse_tile_table(strip: &[u8], width: usize, height: usize) -> Result> { + let count = u32_le(strip, 0).ok_or_else(|| corrupt("truncated tile table"))?; + if count == 0 || count > MAX_TILES { + return Err(unsupported("tile count outside 1..=16")); + } + let mut tiles: Vec = Vec::with_capacity(count); + let mut area = 0usize; + for i in 0..count { + let at = 8 + 24 * i; + let e = strip.get(at..at + 24).ok_or_else(|| corrupt("truncated tile table"))?; + let offset = e.get(..8).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes).and_then(|o| usize::try_from(o).ok()); + let offset = offset.filter(|&o| o < strip.len()).ok_or_else(|| corrupt("tile offset outside the strip"))?; + if tiles.last().is_some_and(|p| offset <= p.offset) { + return Err(corrupt("tile offsets do not increase")); + } + let (x, y, w, h) = (u32_le(e, 8), u32_le(e, 12), u32_le(e, 16), u32_le(e, 20)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(corrupt("truncated tile table")) }; + // Even geometry; the level-3 band width w / 16 must be whole (w/2 a multiple of 8). + if w == 0 || h == 0 || x % 2 != 0 || y % 2 != 0 || w % 2 != 0 || h % 2 != 0 || (w / 2) % 8 != 0 { + return Err(unsupported("tile geometry")); + } + let fits = x.checked_add(w).is_some_and(|r| r <= width) && y.checked_add(h).is_some_and(|b| b <= height); + if !fits { + return Err(corrupt("tile outside the image")); + } + let a = w.checked_mul(h).filter(|&a| a <= crate::MAX_SAMPLES).ok_or(RawError::Limit("ARW6 tile too large"))?; + area = area.checked_add(a).ok_or(RawError::Limit("ARW6 tiles too large"))?; + tiles.push(TileEntry { offset, x, y, width: w, height: h }); + } + for (i, a) in tiles.iter().enumerate() { + for b in tiles.iter().skip(i + 1) { + if a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height { + return Err(corrupt("tiles overlap")); + } + } + } + // In-image, disjoint tiles cover the image exactly when their areas sum to it. + if width.checked_mul(height) != Some(area) { + return Err(corrupt("tiles do not cover the image")); + } + Ok(tiles) +} + +/// Tile `i`'s bytes: from its offset to the next tile's offset (the last: the strip end). +pub(crate) fn tile_bytes<'a>(strip: &'a [u8], tiles: &[TileEntry], i: usize) -> Option<&'a [u8]> { + let start = tiles.get(i)?.offset; + let end = match tiles.get(i.checked_add(1)?) { + Some(next) => next.offset, + None => strip.len(), + }; + strip.get(start..end) +} + +/// One of a tile's 13 streams (positions in 16-byte words from the tile start). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StreamRef { + pub group: u8, + pub comp: u8, + pub word: usize, + pub size_words: usize, + pub index_words: usize, + pub data_words: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TileHeader { + pub hs: usize, + pub vs: usize, + /// File order: g0 c0..2, g1 c0..2, g2 c0..2, g3 c0..2, g4 c0. + pub streams: Vec, +} + +/// `n` bits at bit position `pos` (MSB-first) of a tile's word 0. +fn field(w: &[u8], pos: usize, n: usize) -> Option { + let mut v = 0u32; + for i in pos..pos.checked_add(n)? { + let byte = *w.get(i / 8)?; + v = (v << 1) | u32::from((byte >> (7 - i % 8)) & 1); + } + Some(v) +} + +/// Streams per group: g0..g3 one per colour component, g4 the green residual. +const GROUP_STREAMS: [usize; 5] = [3, 3, 3, 3, 1]; + +/// Parse word 0 (RDD 34 `Picture_info`; every real file has HS = tile width, VS = tile height / 2, BBD 16, NC 3, NW 3, +/// NP 1000000000b), the group size words 3..=7 and each stream's header word (*Container*, Phase 0). +pub(crate) fn parse_tile_header(tile: &[u8], tw: usize, th: usize) -> Result { + let w0 = tile.get(..16).ok_or_else(|| corrupt("tile shorter than its picture header"))?; + let f = |pos, n| field(w0, pos, n).map(|v| v as usize); + let (hs, vs, bbd, nc, nw, np) = (f(64, 16), f(80, 16), f(102, 6), f(112, 3), f(115, 3), f(118, 10)); + let (Some(hs), Some(vs), Some(bbd), Some(nc), Some(nw), Some(np)) = (hs, vs, bbd, nc, nw, np) else { + return Err(corrupt("picture header")); + }; + if hs != tw || vs * 2 != th || bbd != 16 || nc != 3 || nw != 3 || np != 0b10_0000_0000 { + return Err(unsupported(&format!("picture header variant (HS {hs}, VS {vs}, BBD {bbd}, NC {nc}, NW {nw}, NP {np:#b})"))); + } + let mut streams = Vec::with_capacity(13); + let mut word = 8usize; + for (group, &n) in GROUP_STREAMS.iter().enumerate() { + let table = tile.get((3 + group) * 16..(4 + group) * 16).ok_or_else(|| corrupt("truncated group table"))?; + if usize::from(table.first().copied().unwrap_or(0)) != n { + return Err(unsupported("group stream count")); + } + for comp in 0..n { + let size_words = u24_be(table, 1 + 3 * comp).ok_or_else(|| corrupt("group table"))?; + let end = word.checked_add(size_words).filter(|e| e.checked_mul(16).is_some_and(|b| b <= tile.len())); + let end = end.ok_or_else(|| corrupt("stream outside the tile"))?; + let hdr = tile.get(word * 16..word * 16 + 16).ok_or_else(|| corrupt("stream outside the tile"))?; + let (index_words, data_words) = + (u16_be(hdr, 0).ok_or_else(|| corrupt("stream header"))?, u24_be(hdr, 2).ok_or_else(|| corrupt("stream header"))?); + if index_words.checked_add(data_words).and_then(|s| s.checked_add(1)).is_none_or(|s| s > size_words) { + return Err(corrupt("stream header sizes exceed the stream")); + } + streams.push(StreamRef { group: group as u8, comp: comp as u8, word, size_words, index_words, data_words }); + word = end; + } + } + Ok(TileHeader { hs, vs, streams }) +} + +/// One half-TU of a stream: `len` bytes at byte `start` of the tile, quantiser index per band +/// (g0/g4: the single nibble repeated). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Half { + pub start: usize, + pub len: usize, + pub qi: [u32; 3], +} + +fn nibbles(b: &[u8], from: usize, n: usize) -> Option { + (from..from + n).try_fold(0u32, |a, i| Some((a << 4) | u32::from((*b.get(i / 2)? >> (4 * (1 - i % 2))) & 15))) +} + +/// The `2 * ntu` halves of a stream. Index entry per TU (file structure): `A:16 X B:16 X` with A / B the byte +/// lengths of half 0 / 1 and X the QI nibble (g0, g4: 1 nibble) or three nibbles HL, LH, HH (g1..g3); the data area +/// holds each TU's A bytes then B bytes, consecutive TUs contiguous. +pub(crate) fn stream_halves(tile: &[u8], s: &StreamRef, ntu: usize) -> Result> { + let nx = if s.group == 0 || s.group == 4 { 1 } else { 3 }; + let esz = 4 + nx; // bytes: (16 + 4nx + 16 + 4nx) / 8 + let idx_start = s.word.checked_add(1).and_then(|w| w.checked_mul(16)).ok_or_else(|| corrupt("stream position"))?; + let idx_len = s.index_words.checked_mul(16).ok_or_else(|| corrupt("stream position"))?; + if ntu.checked_mul(esz).is_none_or(|n| n > idx_len) { + return Err(corrupt("index table too small for the tile's TUs")); + } + let data_start = idx_start.checked_add(idx_len).ok_or_else(|| corrupt("stream position"))?; + let index = tile.get(idx_start..data_start).ok_or_else(|| corrupt("index table outside the tile"))?; + let data_end = s.data_words.checked_mul(16).and_then(|d| d.checked_add(data_start)).ok_or_else(|| corrupt("stream position"))?; + if data_end > tile.len() { + return Err(corrupt("stream data outside the tile")); + } + let mut halves = Vec::with_capacity(2 * ntu); + let mut pos = data_start; + for k in 0..ntu { + let e = index.get(k * esz..(k + 1) * esz).ok_or_else(|| corrupt("index table"))?; + let bad = || corrupt("index entry"); + let (a, x1) = (nibbles(e, 0, 4).ok_or_else(bad)? as usize, nibbles(e, 4, nx).ok_or_else(bad)?); + let (b, x2) = (nibbles(e, 4 + nx, 4).ok_or_else(bad)? as usize, nibbles(e, 8 + nx, nx).ok_or_else(bad)?); + for (len, x) in [(a, x1), (b, x2)] { + let end = pos.checked_add(len).filter(|&e| e <= data_end).ok_or_else(|| corrupt("half outside its stream's data"))?; + let qi = if nx == 1 { [x; 3] } else { [(x >> 8) & 15, (x >> 4) & 15, x & 15] }; + halves.push(Half { start: pos, len, qi }); + pos = end; + } + } + Ok(halves) +} + +// ---- sensor-anchored frame geometry and tile reconstruction (*Frame geometry*) ---- + +/// Halves of `vs` plane rows are anchored to the sensor: half `n` covers plane rows `8n - 6 + s .. 8n + 1 + s`, so +/// `ceil((vs - 2 - s) / 8) + 1` halves are needed, two per TU (*Container*). +pub(crate) fn tu_count(vs: usize, s: u8) -> usize { + let halves = vs.saturating_sub(2usize.saturating_add(usize::from(s))).div_ceil(8).saturating_add(1); + halves.div_ceil(2) +} + +/// Lifting phases of levels 1..=3 for anchor offset `s` (the Phase 0 reference model, local and not in the repo; see `docs/arw6-compression.md`): +/// FF (s = 0) gives [0, 1, 1], the APS-C crop (s = 3) [1, 0, 0]. +pub(crate) fn phases(s: u8) -> [u8; 3] { + let s = i32::from(s); + let p1 = s % 2; + let p2 = ((2 + s - p1) / 2) % 2; + let k3 = ((6 + s) % 8 - p1).div_euclid(2); + let p3 = (k3 - p2).div_euclid(2).rem_euclid(2); + [p1 as u8, p2 as u8, p3 as u8] +} + +/// Plane rows of half `n`: `8n - 6 + s .. 8n + 2 + s`, clipped to `0..rows` (empty when disjoint). +pub(crate) fn half_rows(n: usize, s: u8, rows: usize) -> Range { + let base = n.saturating_mul(8).saturating_add(usize::from(s)); + base.saturating_sub(6).min(rows)..base.saturating_add(2).min(rows) +} + +/// First half's frame rows `lines - 2`, read before the TU count is known from g4's first index entry alone +/// (index table at word `g4.word + 1`, entry 0's `A` = big-endian u16; its bytes start after the index table). +/// Residual lines are decoded while at least 8 bits remain and fewer than 8 lines were read (*Frame geometry*). +pub(crate) fn first_half_shift(tile: &[u8], g4: &StreamRef, width2: usize) -> Result { + let idx = g4.word.checked_add(1).and_then(|w| w.checked_mul(16)).ok_or_else(|| corrupt("stream position"))?; + let data = g4.index_words.checked_mul(16).and_then(|l| l.checked_add(idx)).ok_or_else(|| corrupt("stream position"))?; + let a = u16_be(tile, idx).ok_or_else(|| corrupt("index table outside the tile"))?; + let bytes = tile.get(data..data.checked_add(a).ok_or_else(|| corrupt("stream position"))?).ok_or_else(|| corrupt("half outside the tile"))?; + let total = bytes.len() * 8; + let mut bits = Bits::new(bytes); + let mut lines = 0usize; + while lines < 8 && total.saturating_sub(bits.consumed_bits()) >= 8 { + vld_decode_line(&mut bits, total, width2)?; + lines += 1; + } + // The stop rule is sound because a residual line costs at least 8 bits: an all-zero line of width w costs + // 1 + ceil(log2(ceil(w/4) + 1)) bits, >= 8 for w >= 253, and real plane widths are >= 2496. + match lines.checked_sub(2) { + Some(s @ 0..=5) => Ok(s as u8), + _ => Err(unsupported("first-half row count outside 2..=7")), + } +} + +/// Band rows of one half: plane rows `t` and the rows they land on at each level (`split`). +struct HalfRows { + t: Range, + l1: Vec, + h1: Vec, + l2: Vec, + h2: Vec, + l3: Vec, + h3: Vec, +} + +/// Rows with `t % 2 == p` go to the low band at `(t - p) / 2`, the others to the high band at `(t - (1 - p)) / 2`. +fn split(rows: &[usize], p: u8) -> (Vec, Vec) { + let p = usize::from(p); + let (mut lo, mut hi) = (Vec::new(), Vec::new()); + for &t in rows { + if t % 2 == p { + lo.push(t.saturating_sub(p) / 2); + } else { + hi.push(t.saturating_sub(1 - p) / 2); + } + } + (lo, hi) +} + +fn half_layout(n: usize, s: u8, vs: usize, ph: [u8; 3]) -> HalfRows { + let t = half_rows(n, s, vs); + let rows: Vec = t.clone().collect(); + let (l1, h1) = split(&rows, ph[0]); + let (l2, h2) = split(&l1, ph[1]); + let (l3, h3) = split(&l2, ph[2]); + HalfRows { t, l1, h1, l2, h2, l3, h3 } +} + +/// Lines a half holds in group `g`: g0 LL3 rows; g1 HL3 + LH3 + HH3; g2 HL2 + LH2 + HH2; g3 HL1 + LH1 + HH1; g4 residual. +fn line_count(g: u8, r: &HalfRows) -> usize { + match g { + 0 => r.l3.len(), + 1 => r.l3.len() + 2 * r.h3.len(), + 2 => r.l2.len() + 2 * r.h2.len(), + 3 => r.l1.len() + 2 * r.h1.len(), + _ => r.t.len(), + } +} + +/// Line width of group `g`: level-`l` bands are `(tw / 2) >> l` wide (g0, g1: level 3; g2: 2; g3: 1; g4: the plane). +fn line_width(g: u8, w2: usize) -> usize { + match g { + 0 | 1 => w2 >> 3, + 2 => w2 >> 2, + 3 => w2 >> 1, + _ => w2, + } +} + +/// A half with its decoded (still quantised) lines. +type HalfLines = (Half, Vec>); + +/// Index the stream's halves and VLD-decode exactly the lines each holds; a line running past its half is `Corrupt`. +fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result> { + let halves = stream_halves(tile, st, ntu)?; + let width = line_width(st.group, w2); + halves + .into_iter() + .zip(layouts) + .map(|(half, rows)| { + let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; + let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; + let total = bytes.len() * 8; + let mut bits = Bits::new(bytes); + let mut lines = Vec::with_capacity(line_count(st.group, rows)); + let n = line_count(st.group, rows); + for _ in 0..n { + lines.push(vld_decode_line(&mut bits, total, width)?); + if bits.consumed_bits() > total { + return Err(corrupt("line runs past its half")); + } + } + // Phase 0 measurement: in all 3562 halves of the 8 real files the lines end within the half's last + // byte (`len*8 - 8 < consumed <= len*8`); garbage that decodes to fewer bits is corruption. + if n > 0 && bits.consumed_bits() <= total.saturating_sub(8) { + return Err(corrupt("half ends early")); + } + Ok((half, lines)) + }) + .collect() +} + +/// Dequantise `line` into band row `row`. +fn put(plane: &mut Plane, row: usize, line: &[i32], mut f: impl FnMut(i32) -> i32) -> Result<()> { + let start = row.checked_mul(plane.width).ok_or_else(|| corrupt("band row"))?; + let dst = plane.data.get_mut(start..start.saturating_add(plane.width)).ok_or_else(|| corrupt("band row outside its band"))?; + for (d, &v) in dst.iter_mut().zip(line) { + *d = f(v); + } + Ok(()) +} + +/// Lay a half's lines into (band, rows, QI) in order: the natural clipped assignment of *Frame geometry*. +fn fill(set: [(&mut Plane, &[usize], u32); 3], lines: &[Vec]) -> Result<()> { + let mut it = lines.iter(); + for (plane, rows, qi) in set { + for &r in rows { + put(plane, r, it.next().ok_or_else(|| corrupt("half holds fewer lines than rows"))?, |q| dequant(q, qi))?; + } + } + Ok(()) +} + +/// One colour component: its ten bands from streams g0..g3 (component `c`), then the 3-level inverse. +fn component(c: usize, streams: &[Vec], layouts: &[HalfRows], vs: usize, w2: usize, ph: [u8; 3]) -> Result { + let (n1l, n1h) = band_rows(vs, ph[0]); + let (n2l, n2h) = band_rows(n1l, ph[1]); + let (n3l, n3h) = band_rows(n2l, ph[2]); + let (w1, w2b, w3) = (w2 >> 1, w2 >> 2, w2 >> 3); + let z = Plane::zeros; + let mut b = Bands3 { + ll3: z(w3, n3l), + hl3: z(w3, n3l), + lh3: z(w3, n3h), + hh3: z(w3, n3h), + hl2: z(w2b, n2l), + lh2: z(w2b, n2h), + hh2: z(w2b, n2h), + hl1: z(w1, n1l), + lh1: z(w1, n1h), + hh1: z(w1, n1h), + }; + let grp = |g: usize, n: usize| streams.get(3 * g + c).and_then(|s| s.get(n)).ok_or_else(|| corrupt("missing half")); + for (n, r) in layouts.iter().enumerate() { + let (h0, l0) = grp(0, n)?; + // LL3: the nibble must be 0 (Phase 0: never seen otherwise); lines are DPCM from 2048 for the green mean (c = 0) and + // from 0 for the base-free chroma planes (*Entropy coding and dequantisation*). + if h0.qi[0] != 0 { + return Err(unsupported("LL3 quantiser index")); + } + for (&row, line) in r.l3.iter().zip(l0) { + let mut acc = if c == 0 { 2048i32 } else { 0 }; + put(&mut b.ll3, row, line, |d| { + acc = acc.saturating_add(d); + acc + })?; + } + let (h1, l1) = grp(1, n)?; + fill([(&mut b.hl3, &r.l3, h1.qi[0]), (&mut b.lh3, &r.h3, h1.qi[1]), (&mut b.hh3, &r.h3, h1.qi[2])], l1)?; + let (h2, l2) = grp(2, n)?; + fill([(&mut b.hl2, &r.l2, h2.qi[0]), (&mut b.lh2, &r.h2, h2.qi[1]), (&mut b.hh2, &r.h2, h2.qi[2])], l2)?; + let (h3, l3) = grp(3, n)?; + fill([(&mut b.hl1, &r.l1, h3.qi[0]), (&mut b.lh1, &r.h1, h3.qi[1]), (&mut b.hh1, &r.h1, h3.qi[2])], l3)?; + } + reconstruct3(&b, ph) +} + +/// A decoded tile: the three wavelet planes (green mean, two chroma) and the green residual, each `tw/2 x th/2`. +pub(crate) struct TilePlanes { + pub m: Plane, + pub c1: Plane, + pub c2: Plane, + pub res: Plane, +} + +/// Decode a tile to its planes: header, `s` from g4's first half, TU count, all 13 streams (in parallel), band +/// assembly and the 3-level inverse per component (in parallel). +pub(crate) fn decode_tile_planes(tile: &[u8], tw: usize, th: usize) -> Result { + let h = parse_tile_header(tile, tw, th)?; + let (w2, vs) = (tw / 2, h.vs); + if vs == 0 || !tw.is_multiple_of(2) || !w2.is_multiple_of(8) || w2.checked_mul(vs).is_none_or(|a| a > crate::MAX_SAMPLES) { + return Err(unsupported("tile geometry")); + } + let g4 = h.streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?; + let s = first_half_shift(tile, g4, w2)?; + let (ntu, ph) = (tu_count(vs, s), phases(s)); + let layouts: Vec = (0..2 * ntu).map(|n| half_layout(n, s, vs, ph)).collect(); + let streams = + h.streams.par_iter().map(|st| decode_stream(tile, st, ntu, w2, &layouts)).collect::>().into_iter().collect::>>()?; + let comps = + (0..3).into_par_iter().map(|c| component(c, &streams, &layouts, vs, w2, ph)).collect::>().into_iter().collect::>>()?; + // Residual: g4's lines are the half's plane rows, dequantised with the half's nibble. + let mut res = Plane::zeros(w2, vs); + for ((half, lines), r) in streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?.iter().zip(&layouts) { + for (row, line) in r.t.clone().zip(lines) { + put(&mut res, row, line, |q| dequant(q, half.qi[0]))?; + } + } + let mut it = comps.into_iter(); + let (Some(m), Some(c1), Some(c2)) = (it.next(), it.next(), it.next()) else { return Err(corrupt("missing component")) }; + Ok(TilePlanes { m, c1, c2, res }) +} + +/// Colour reconstruction (*Colour reconstruction*, checked against Phase 0 oracle output): the planes give a +/// `(2 W2) x (2 H2)` mosaic with R at (0,0), G1 (0,1), G2 (1,0), B (1,1) of every 2x2 cell, each value passed through +/// [`curve`]. G1 comes from the green mean minus a residual average, G2 is predicted from the (unclipped) G1 plus the +/// residual, and R/B are chroma plus the mean of the greens clamped to 0..=4095. +pub(crate) fn colour(p: &TilePlanes) -> Result> { + let (w2, h2) = (p.m.width, p.m.height); + let len = w2.checked_mul(h2).ok_or_else(|| corrupt("tile size"))?; + if [&p.c1, &p.c2, &p.res].iter().any(|q| q.width != w2 || q.height != h2 || q.data.len() != len) || p.m.data.len() != len { + return Err(corrupt("plane size mismatch")); + } + let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; + let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; + let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 }; + let mut g1 = vec![0i64; len]; + for j in 0..h2 { + let jp = j.saturating_sub(1); + for x in 0..w2 { + let xn = (x + 1).min(w2 - 1); + let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn); + if let Some(g) = g1.get_mut(j * w2 + x) { + *g = at(&p.m, j, x) - ((r + 4) >> 3); + } + } + } + let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0); + let mut out = vec![0u16; total]; + for j in 0..h2 { + let jn = (j + 1).min(h2 - 1); + for x in 0..w2 { + let xp = x.saturating_sub(1); + let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x); + let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let r = 2 * at(&p.c1, j, x) + mean; + let b = 2 * at(&p.c2, j, x) + mean; + let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)]; + for (off, v) in cells { + if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) { + *o = curve(v.clamp(0, 4095) as i32); + } + } + } + } + Ok(out) +} + +/// One tile to its `tw x th` mosaic in output units. +pub(crate) fn decode_tile(tile: &[u8], tw: usize, th: usize) -> Result> { + colour(&decode_tile_planes(tile, tw, th)?) +} + +/// Decode a whole ARW6 strip: [`Mode::Header`](crate::Mode) validates the tile table, tile headers and stream +/// tables and returns no samples; `Full` decodes the tiles in parallel into a `width x height` mosaic. +pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mode) -> Result> { + let tiles = parse_tile_table(strip, width, height)?; + let bytes = |i: usize| tile_bytes(strip, &tiles, i).ok_or_else(|| corrupt("tile outside strip")); + if mode == crate::Mode::Header { + for (i, t) in tiles.iter().enumerate() { + let tile = bytes(i)?; + let h = parse_tile_header(tile, t.width, t.height)?; + let g4 = h.streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?; + let s = first_half_shift(tile, g4, t.width / 2)?; + let ntu = tu_count(h.vs, s); + for st in &h.streams { + stream_halves(tile, st, ntu)?; + } + } + return Ok(Vec::new()); + } + let decoded: Vec>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect(); + let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?]; + for (t, d) in tiles.iter().zip(decoded) { + let d = d?; + for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() { + let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?; + let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?; + dst.copy_from_slice(row); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::arw6_testenc::*; + use crate::vendor::pef::Bits; + use lightcraft_tiff::Tiff; + use lightcraft_tiff::image::chunk_bytes; + use lightcraft_tiff::tags as t; + + fn planes(w2: usize, h2: usize, seed: u64) -> (Plane, Plane, Plane, Plane) { + let mut x = seed.max(1); + let mut next = |span: i32| -> i32 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x % (2 * span as u64 + 1)) as i32 - span + }; + let mut mk = |centre: i32, span: i32| Plane { width: w2, height: h2, data: (0..w2 * h2).map(|_| centre + next(span)).collect() }; + (mk(2048, 200), mk(0, 60), mk(0, 60), mk(0, 24)) + } + + fn one_tile(w: usize, h: usize, s: u8, qi: Qis) -> (Vec, TileSpec) { + seeded_tile(w, h, s, qi, 7) + } + + fn seeded_tile(w: usize, h: usize, s: u8, qi: Qis, seed: u64) -> (Vec, TileSpec) { + let (m, c1, c2, res) = planes(w / 2, h / 2, seed); + let t = TileSpec { s, qi, m, c1, c2, res }; + (encode_tile(&t), t) + } + + /// The raw strip bytes of the compression-32766 IFD, as `arw.rs` locates them. + fn strip_of(file: &[u8]) -> &[u8] { + let tiff = Tiff::parse(file).unwrap(); + let raw = tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION) == Some(32766)).unwrap(); + let info = raw.image().unwrap(); + let chunks = info.chunks(file.len() as u64); + chunk_bytes(file, &chunks[0]).unwrap() + } + + #[test] + fn tile_table_round_trips() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file(&[(0, 0, 64, 48, tile.clone())], 64, 48); + let strip = strip_of(&file); + let t = parse_tile_table(strip, 64, 48).unwrap(); + assert_eq!(t.len(), 1); + assert_eq!((t[0].x, t[0].y, t[0].width, t[0].height, t[0].offset), (0, 0, 64, 48, 512)); + } + + #[test] + fn tile_table_rejects_bad_layouts() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile.clone())], 64, 48)).to_vec(); + let mut zero = strip.clone(); + zero[0] = 0; + assert!(parse_tile_table(&zero, 64, 48).is_err()); + let mut far = strip.clone(); + far[8..16].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(parse_tile_table(&far, 64, 48).is_err()); + let mut odd = strip.clone(); + odd[20..24].copy_from_slice(&63u32.to_le_bytes()); // the first tile's y: odd, and no longer covering + assert!(parse_tile_table(&odd, 64, 48).is_err()); + assert!(parse_tile_table(&strip, 64, 64).is_err()); // does not cover the image + let two = arw6_file(&[(0, 0, 64, 48, tile.clone()), (0, 0, 64, 48, tile.clone())], 128, 48); + assert!(parse_tile_table(strip_of(&two), 128, 48).is_err()); // overlapping + assert!(parse_tile_table(&strip[..7], 64, 48).is_err()); // truncated table + } + + #[test] + fn tile_header_and_streams() { + let (tile, _) = one_tile(64, 48, 3, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + assert_eq!((h.hs, h.vs, h.streams.len()), (64, 24, 13)); + let ntu = tu_count(24, 3); + assert_eq!(ntu, 2); + assert_eq!( + h.streams.iter().map(|s| (s.group, s.comp)).collect::>(), + [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2), (3, 0), (3, 1), (3, 2), (4, 0)] + ); + let g3 = stream_halves(&tile, &h.streams[9], ntu).unwrap(); + assert_eq!(g3.len(), 4); + assert_eq!(g3[0].qi, [1, 1, 2]); + let g4 = stream_halves(&tile, &h.streams[12], ntu).unwrap(); + assert_eq!(g4[1].qi, [2, 2, 2]); + assert!(g4[3].start + g4[3].len <= tile.len()); + let mut bad = tile.clone(); + bad[13] = 0xff; // corrupts word 0's BBD bits: any picture-header deviation is Unsupported + assert!(matches!(parse_tile_header(&bad, 64, 48), Err(RawError::Unsupported(_)))); + assert!(parse_tile_header(&tile, 64, 50).is_err()); // VS != th / 2 + } + + fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + } + + #[test] + fn tile_table_validation_cases() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let many = |n: usize| { + let tiles: Vec<_> = (0..n).map(|i| (64 * i, 0, 64, 48, tile.clone())).collect(); + strip_of(&arw6_file(&tiles, 64 * n, 48)).to_vec() + }; + let sixteen = many(16); + assert_eq!(parse_tile_table(&sixteen, 1024, 48).unwrap().len(), 16); + let mut seventeen = sixteen.clone(); + seventeen[0] = 17; + assert!(parse_tile_table(&seventeen, 1088, 48).is_err()); + + let two = strip_of(&arw6_file(&[(0, 0, 64, 48, tile.clone()), (64, 0, 64, 48, tile.clone())], 128, 48)).to_vec(); + assert_eq!(parse_tile_table(&two, 128, 48).unwrap().len(), 2); + let mut swapped = two.clone(); + let (a, b) = (two[8..16].to_vec(), two[32..40].to_vec()); + swapped[8..16].copy_from_slice(&b); + swapped[32..40].copy_from_slice(&a); + assert!(parse_tile_table(&swapped, 128, 48).is_err()); + let mut at_end = two.clone(); + at_end[32..40].copy_from_slice(&(two.len() as u64).to_le_bytes()); + assert!(parse_tile_table(&at_end, 128, 48).is_err()); + + let one = many(1); + let mut wide = one.clone(); + put32(&mut wide, 24, 72); // w / 2 = 36 is not a multiple of 8 + assert!(parse_tile_table(&wide, 72, 48).is_err()); + let mut huge = one.clone(); + put32(&mut huge, 24, 1 << 20); + put32(&mut huge, 28, 1 << 20); + assert!(matches!(parse_tile_table(&huge, 1 << 20, 1 << 20), Err(RawError::Limit(_)))); + } + + #[test] + fn tile_bytes_span_to_the_next_offset() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file(&[(0, 0, 64, 48, tile.clone()), (64, 0, 64, 48, tile.clone())], 128, 48); + let strip = strip_of(&file); + let tiles = parse_tile_table(strip, 128, 48).unwrap(); + let (t0, t1) = (tile_bytes(strip, &tiles, 0).unwrap(), tile_bytes(strip, &tiles, 1).unwrap()); + assert_eq!(t0.len(), tiles[1].offset - tiles[0].offset); + assert_eq!(t0, &tile[..]); + assert_eq!(t1.as_ptr() as usize + t1.len(), strip.as_ptr() as usize + strip.len()); + assert!(tile_bytes(strip, &tiles, 2).is_none()); + } + + #[test] + fn tile_header_and_stream_validation_cases() { + let (tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let mut two_streams = tile.clone(); + two_streams[3 * 16] = 2; + assert!(parse_tile_header(&two_streams, 64, 48).is_err()); + let mut big = tile.clone(); + big[3 * 16 + 1..3 * 16 + 4].copy_from_slice(&[0xff; 3]); // stream 0 larger than the tile + assert!(parse_tile_header(&big, 64, 48).is_err()); + let mut idx = tile.clone(); + idx[8 * 16..8 * 16 + 2].copy_from_slice(&[0xff, 0xff]); // index words beyond the stream + assert!(parse_tile_header(&idx, 64, 48).is_err()); + + let s = &h.streams[9]; + let mut bad_half = tile.clone(); + let idx_start = (s.word + 1) * 16; + bad_half[idx_start..idx_start + 2].copy_from_slice(&[0xff, 0xff]); // A past the data area + assert!(stream_halves(&bad_half, s, tu_count(24, 0)).is_err()); + let outside = StreamRef { index_words: 0xffff, ..s.clone() }; + assert!(stream_halves(&tile, &outside, tu_count(24, 0)).is_err()); + } + + #[test] + fn every_stream_of_an_encoded_tile_parses() { + for s in [0u8, 3] { + let (tile, spec) = one_tile(64, 48, s, Qis::REAL); + assert_eq!(tile.len() % 4096, 0); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let ntu = tu_count(24, s); + for st in &h.streams { + assert_eq!(stream_halves(&tile, st, ntu).unwrap().len(), 2 * ntu); + } + // the residual stream's halves decode to the clipped, quantised rows 8n-6+s ..= 8n+1+s + let g4 = stream_halves(&tile, &h.streams[12], ntu).unwrap(); + for (n, half) in g4.iter().enumerate() { + let bytes = &tile[half.start..half.start + half.len]; + let mut bits = Bits::new(bytes); + for r in (8 * n as i32 - 6 + s as i32..8 * n as i32 + 2 + s as i32).filter(|&r| (0..24).contains(&r)) { + let line = crate::llvc::vld_decode_line(&mut bits, bytes.len() * 8, 32).unwrap(); + let want: Vec = spec.res.row(r as usize).unwrap().iter().map(|&v| quantize(v, 2)).collect(); + assert_eq!(line, want, "s {s} half {n} row {r}"); + } + } + // a stream that claims more halves than its index holds is rejected + assert!(stream_halves(&tile, &h.streams[0], 1000).is_err()); + } + } + + #[test] + fn encoder_decodes_its_own_lines() { + let vals: Vec = (0..100).map(|i| (i * 37) % 23 - 11).collect(); + let mut w = BitWriter::default(); + vld_encode_line(&vals, 100, &mut w); + let bytes = w.finish(); + let mut b = Bits::new(&bytes); + assert_eq!(crate::llvc::vld_decode_line(&mut b, bytes.len() * 8, 100).unwrap(), vals); + } + + #[test] + fn phases_and_half_rows_match_the_spec() { + assert_eq!(phases(0), [0, 1, 1]); + assert_eq!(phases(3), [1, 0, 0]); + assert_eq!(half_rows(0, 0, 1668), 0..2); + assert_eq!(half_rows(209, 0, 1668), 1666..1668); + assert_eq!(half_rows(1, 0, 1668), 2..10); + assert_eq!(half_rows(0, 3, 2186), 0..5); + assert_eq!(half_rows(273, 3, 2186), 2181..2186); + assert_eq!(half_rows(300, 3, 2186), 2186..2186); + } + + #[test] + fn tu_count_covers_every_row() { + assert_eq!((tu_count(1668, 0), tu_count(2186, 3), tu_count(24, 3), tu_count(48, 0)), (105, 137, 2, 4)); + for s in 0u8..=5 { + for vs in 1usize..200 { + let last = half_rows(2 * tu_count(vs, s) - 1, s, vs); + assert!(last.end == vs || (2 * tu_count(vs, s) - 1) * 8 + usize::from(s) + 2 >= vs, "s {s} vs {vs}"); + assert_eq!((0..2 * tu_count(vs, s)).map(|n| half_rows(n, s, vs).len()).sum::(), vs, "s {s} vs {vs}"); + } + } + } + + #[test] + fn first_half_shift_is_read_from_the_stream() { + for s in [0u8, 3, 5] { + let (tile, _) = one_tile(64, 48, s, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + assert_eq!(first_half_shift(&tile, &h.streams[12], 32).unwrap(), s); + } + } + + #[test] + fn round_trip_every_shift() { + for s in 0u8..=5 { + for (w, h) in [(64usize, 48usize), (80, 96), (64, 36)] { + let (tile, spec) = one_tile(w, h, s, Qis::ZERO); // QI 0: lossless, planes must come back exactly + let p = decode_tile_planes(&tile, w, h).unwrap_or_else(|e| panic!("s={s} {w}x{h}: {e}")); + assert_eq!(p.m, spec.m, "s={s} {w}x{h} m"); + assert_eq!(p.c1, spec.c1); + assert_eq!(p.c2, spec.c2); + assert_eq!(p.res, spec.res); + } + } + } + + #[test] + fn round_trip_with_real_quantisers() { + // bands drawn as quantised integers q, planes = reconstruct3(dequant(q)); the encoder must reproduce them bit for bit + for s in [0u8, 3] { + let (tile, spec) = one_tile_quantised(96, 64, s, Qis::REAL); + let p = decode_tile_planes(&tile, 96, 64).unwrap(); + assert_eq!(p.m, spec.m); + assert_eq!(p.c1, spec.c1); + assert_eq!(p.c2, spec.c2); + assert_eq!(p.res, spec.res); + } + } + + #[test] + fn half_too_short_is_corrupt() { + let (mut tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let w = h.streams[9].word * 16 + 16; // first index entry of g3 c0: shrink A by 1 byte + let a = u16::from_be_bytes([tile[w], tile[w + 1]]) - 1; + tile[w..w + 2].copy_from_slice(&a.to_be_bytes()); + assert!(matches!(decode_tile_planes(&tile, 64, 48), Err(RawError::Corrupt(_)))); + } + + proptest::proptest! { + #[test] + fn mutated_tiles_never_panic(flips in proptest::collection::vec((proptest::num::usize::ANY, proptest::num::u8::ANY), 1..12), cut in proptest::num::usize::ANY) { + let (mut tile, _) = one_tile(64, 48, 3, Qis::REAL); + for (i, b) in flips { let i = i % tile.len(); tile[i] ^= b; } + let n = cut % (tile.len() + 1); + let _ = decode_tile(&tile[..n], 64, 48); + let _ = decode_tile(&tile, 64, 48); + } + } + + fn plane(w: usize, v: &[i32]) -> Plane { + Plane { width: w, height: v.len() / w, data: v.to_vec() } + } + + fn example() -> TilePlanes { + TilePlanes { + m: plane(3, &[2048, 2052, 2060, 2050, 2049, 2070]), + res: plane(3, &[4, -8, 2, 0, 2, -6]), + c1: plane(3, &[10, -10, 0, 0, 3, -2]), + c2: plane(3, &[-5, 7, 1, 2, 0, 4]), + } + } + + #[test] + fn colour_matches_the_phase0_example() { + let out = colour(&example()).unwrap(); // 6 wide x 4 rows, curve units + let code = |r: usize, c: usize| out[r * 6 + c] as i32; + assert_eq!( + [code(0, 1), code(0, 3), code(0, 5), code(2, 1), code(2, 3), code(2, 5)], + [2049, 2053, 2059, 2050, 2050, 2071].map(|c| curve(c) as i32) + ); // G1 + assert_eq!( + [code(1, 0), code(1, 2), code(1, 4), code(3, 0), code(3, 2), code(3, 4)], + [2053, 2042, 2060, 2050, 2052, 2054].map(|c| curve(c) as i32) + ); // G2 + assert_eq!([code(0, 0), code(0, 2), code(0, 4)], [2071, 2027, 2059].map(|c| curve(c) as i32)); // R + assert_eq!([code(1, 1), code(1, 3), code(1, 5)], [2041, 2061, 2061].map(|c| curve(c) as i32)); // B + } + + #[test] + fn colour_clips_like_the_oracle() { + let mut p = example(); + p.m.data[1] = 4100; + let out = colour(&p).unwrap(); + assert_eq!(out[3], curve(4095)); // G1 = 4101 -> clipped output + assert_eq!(out[6 + 2], curve(2554)); // G2 predicted from the unclipped G1 + assert_eq!(out[2], curve(3304)); // R = 2*(-10) + ((4095 + 2554) >> 1): chroma uses the clipped greens + } + + #[test] + fn colour_clamps_negative_greens() { + let p = TilePlanes { + m: plane(2, &[10, 2048, 2048, 2048]), + res: plane(2, &[100, 100, 0, 0]), + c1: plane(2, &[7, 0, 0, 0]), + c2: plane(2, &[-3, 0, 0, 0]), + }; + let g1_00 = 10 - ((400 + 4) >> 3); // -40 + let g1_10 = 2048 - ((200 + 4) >> 3); + let g2_00 = ((2 * g1_00 + 2 * g1_10) >> 2) + 100; + assert!(g1_00 < 0); + let mean = g2_00.clamp(0, 4095) >> 1; // G1 contributes 0 + let out = colour(&p).unwrap(); + assert_eq!(out[0], curve(14 + mean)); + assert_eq!(out[1], curve(0)); // G1 itself clamps + assert_eq!(out[2 * 2 + 1], curve(-6 + mean)); // B at (0,0): row 1, col 1 of a 4-wide mosaic + } + + #[test] + fn decode_header_and_full_and_truncations() { + let (tile, _) = one_tile(64, 48, 3, Qis::REAL); + let file = arw6_file(&[(0, 0, 64, 48, tile)], 64, 48); + let strip = strip_of(&file); + assert!(decode(strip, 64, 48, crate::Mode::Header).unwrap().is_empty()); + assert_eq!(decode(strip, 64, 48, crate::Mode::Full).unwrap().len(), 64 * 48); + for n in 0..strip.len() { + let _ = decode(&strip[..n], 64, 48, crate::Mode::Full); + let _ = decode(&strip[..n], 64, 48, crate::Mode::Header); + } + } + + #[test] + fn garbage_streams_import_then_fail() { + let (mut tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let st = &h.streams[9]; // g3 c0 + let start = (st.word + 1 + st.index_words) * 16; + for (i, b) in tile[start..start + st.data_words * 16].iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(97) ^ 0xa5; + } + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile)], 64, 48)).to_vec(); + assert!(decode(&strip, 64, 48, crate::Mode::Header).is_ok()); + assert!(matches!(decode(&strip, 64, 48, crate::Mode::Full), Err(RawError::Corrupt(_)))); + } + + #[test] + fn chroma_planes_are_base_free() { + let (w2, h2) = (32, 24); + let flat = |v: i32| Plane { width: w2, height: h2, data: vec![v; w2 * h2] }; + let (m, _, _, res) = planes(w2, h2, 7); + for (c1, c2) in [(0, 0), (5, -3)] { + let t = TileSpec { s: 0, qi: Qis::ZERO, m: m.clone(), c1: flat(c1), c2: flat(c2), res: res.clone() }; + let tile = encode_tile(&t); + let p = decode_tile_planes(&tile, 64, 48).unwrap(); + assert!(p.c1.data.iter().all(|&v| v == c1) && p.c2.data.iter().all(|&v| v == c2)); + if c1 == 0 { + // zero chroma: R = B = (G1 + G2) >> 1 at every cell (no 2048 base, no saturation) + let out = decode_tile(&tile, 64, 48).unwrap(); + for j in 0..h2 { + for x in 0..w2 { + let at = |dy: usize, dx: usize| out[(2 * j + dy) * 64 + 2 * x + dx]; + assert_eq!(at(0, 0), at(1, 1), "R == B at ({x},{j})"); + assert!(at(0, 0) < curve(4095)); + } + } + } + } + } + + #[test] + fn arw6_white_without_tag_is_32766() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file_no_white(&[(0, 0, 64, 48, tile)], 64, 48); + let img = crate::decode(&file).unwrap(); + assert_eq!(img.white, [32766.0]); + } + + #[test] + fn twelve_bit_mode_is_unsupported() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file_bits(&[(0, 0, 64, 48, tile)], 64, 48, 12); + assert!(matches!(crate::decode(&file), Err(RawError::Unsupported(_)))); + } + + #[test] + fn shift_out_of_range_is_unsupported() { + let (w2, h2) = (32, 24); + let (m, c1, c2, res) = planes(w2, h2, 7); + let tile = encode_tile(&TileSpec { s: 6, qi: Qis::ZERO, m, c1, c2, res }); + assert!(matches!(decode_tile_planes(&tile, 64, 48), Err(RawError::Unsupported(_)))); + } + + #[test] + fn corrupt_streams_import_then_fail() { + // Garbage VLD data is self-delimiting and decodes to *something*; what is detectable is a half whose index + // length is too short for the lines it must hold. + let (mut tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let at = (h.streams[9].word + 1) * 16; // g3 c0 index table, first entry: half 0's byte length (u16 BE) + tile[at] = 0; + tile[at + 1] = 1; + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile)], 64, 48)).to_vec(); + assert!(decode(&strip, 64, 48, crate::Mode::Header).is_ok()); + assert!(matches!(decode(&strip, 64, 48, crate::Mode::Full), Err(RawError::Corrupt(_)))); + } + + #[test] + fn four_tiles_place_correctly_and_arw_reports_doubled_levels() { + let tiles: Vec<_> = [(0, 0), (64, 0), (0, 48), (64, 48)] + .iter() + .enumerate() + .map(|(i, &(x, y))| { + let (t, _) = seeded_tile(64, 48, 0, Qis::ZERO, 11 + i as u64 * 17); // a different image per tile + (x, y, 64, 48, t) + }) + .collect(); + let file = arw6_file(&tiles, 128, 96); + let img = crate::decode(&file).unwrap(); + assert_eq!((img.width, img.height, img.bits), (128, 96, 16)); + assert_eq!(img.black.values, vec![1024.0; 4]); + assert_eq!(img.white, vec![30720.0]); + assert_eq!(img.cfa.as_ref().map(|c| c.pattern.clone()), Some(vec![0, 1, 1, 2])); + let crate::RawData::U16(d) = img.data else { panic!() }; + for (x, y, w, h, bytes) in &tiles { + let want = decode_tile(bytes, *w, *h).unwrap(); + for r in 0..*h { + assert_eq!(&d[(y + r) * 128 + x..(y + r) * 128 + x + w], &want[r * w..(r + 1) * w], "tile at ({x},{y}) row {r}"); + } + } + assert_eq!(crate::probe_info(&file).unwrap().width, 128); // header mode: tile tables and headers only + } +} diff --git a/crates/raw/src/vendor/arw6_curve.rs b/crates/raw/src/vendor/arw6_curve.rs new file mode 100644 index 000000000..f7c748a15 --- /dev/null +++ b/crates/raw/src/vendor/arw6_curve.rs @@ -0,0 +1,55 @@ +//! The ARW6 companding curve: 12-bit tile code to output value in 2 × 14-bit DN (black 1024, white 32766). +//! +//! Piecewise linear through knots (c_k, 128 k): c_k = 128 k for k <= 11, else 356.89 + 655.646 ln(k - 6); each code's +//! value is the interpolation rounded half up. Our own measurement from the CC0 lossless / ARW6 pairs of the ILCE-7RM6, +//! not stored in the file; see docs/arw6-compression.md (*Output range*). + +use std::sync::OnceLock; + +/// Code of knot `k`, whose output value is 128 k. +fn knot(k: u32) -> f64 { + if k <= 11 { f64::from(128 * k) } else { 356.89 + 655.646 * f64::from(k - 6).ln() } +} + +fn table() -> &'static [u16; 4096] { + static TABLE: OnceLock<[u16; 4096]> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut t = [0u16; 4096]; + let mut k = 0u32; + for (code, v) in t.iter_mut().enumerate() { + let c = code as f64; + while knot(k + 1) < c { + k += 1; + } + let (c0, c1, u0) = (knot(k), knot(k + 1), f64::from(128 * k)); + *v = (u0 + 128.0 * (c - c0) / (c1 - c0) + 0.5).floor() as u16; + } + t + }) +} + +/// Companding curve: 12-bit code (clamped to 0..=4095) to output value. +pub(crate) fn curve(code12: i32) -> u16 { + table().get(code12.clamp(0, 4095) as usize).copied().unwrap_or(u16::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pins the whole generated table (sum and position-weighted sum of all 4096 values, from the derivation's own + /// table), so a platform whose `ln` rounds differently fails here. + #[test] + fn table_is_pinned() { + let t = table(); + let sum: u64 = t.iter().map(|&v| u64::from(v)).sum(); + let weighted: u64 = t.iter().enumerate().map(|(i, &v)| (i as u64 + 1) * u64::from(v)).sum(); + assert_eq!((sum, weighted), (27776336, 92792980288)); + for (c, v) in [(0, 0), (1408, 1408), (1536, 1542), (2048, 2457), (3000, 7979), (3500, 16227), (3977, 32767), (4095, 39077)] { + assert_eq!(curve(c), v, "code {c}"); + } + assert!((0..=1408).all(|c| curve(c) == c as u16), "identity through 1408"); + assert!(t.windows(2).all(|w| w[0] < w[1]), "strictly increasing"); + assert_eq!((curve(-5), curve(5000)), (0, 39077)); + } +} diff --git a/crates/raw/src/vendor/arw6_testenc.rs b/crates/raw/src/vendor/arw6_testenc.rs new file mode 100644 index 000000000..549331123 --- /dev/null +++ b/crates/raw/src/vendor/arw6_testenc.rs @@ -0,0 +1,483 @@ +//! Test-only ARW6 synthetic encoder pieces: forward 5/3 transforms (exact inverse of `llvc::inverse_*`) and `quantize`. +//! Section names in italics refer to `docs/arw6-compression.md`. + +use crate::llvc::{Bands3, Plane, band_rows}; +use lightcraft_tiff::tags as t; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + +/// Exact inverse of `dequant` on representable values: `sign(v) * (|v| >> qi)`. +pub(crate) fn quantize(v: i32, qi: u32) -> i32 { + v.signum() * (v.abs() >> qi) +} + +fn at(v: &[i32], i: isize) -> i32 { + if v.is_empty() { 0 } else { v[i.clamp(0, v.len() as isize - 1) as usize] } +} + +/// Forward 1-D 5/3 with whole-sample symmetric extension (port of the Phase 0 reference model, local and not in the repo). +fn forward_1d(x: &[i32], phase: u8) -> (Vec, Vec) { + let n = x.len() as isize; + let p = isize::from(phase); + let xs = |i: isize| -> i32 { + let r = if i < 0 { + -i + } else if i >= n { + 2 * (n - 1) - i + } else { + i + }; + if (0..n).contains(&r) { x[r as usize] } else { 0 } + }; + let (nl, nh) = band_rows(x.len(), phase); + let h: Vec = (0..nh as isize) + .map(|k| { + let pos = 2 * k + 1 - p; + xs(pos) - ((xs(pos - 1) + xs(pos + 1)) >> 1) + }) + .collect(); + let l = (0..nl as isize).map(|j| xs(2 * j + p) + ((at(&h, j - 1 + p) + at(&h, j + p) + 2) >> 2)).collect(); + (l, h) +} + +fn split(x: &Plane, phase: u8, vertical: bool) -> (Plane, Plane) { + let (across, n) = if vertical { (x.width, x.height) } else { (x.height, x.width) }; + let (nl, nh) = band_rows(n, phase); + let dims = |m| if vertical { (across, m) } else { (m, across) }; + let (mut lo, mut hi) = (Plane::zeros(dims(nl).0, dims(nl).1), Plane::zeros(dims(nh).0, dims(nh).1)); + for i in 0..across { + let line: Vec = (0..n).map(|k| if vertical { x.data[k * x.width + i] } else { x.data[i * x.width + k] }).collect(); + let (l, h) = forward_1d(&line, phase); + for (p, v) in [(&mut lo, l), (&mut hi, h)] { + for (k, v) in v.into_iter().enumerate() { + let idx = if vertical { k * p.width + i } else { i * p.width + k }; + p.data[idx] = v; + } + } + } + (lo, hi) +} + +pub(crate) fn forward_53_1d_rows(x: &Plane, phase: u8) -> (Plane, Plane) { + split(x, phase, true) +} + +pub(crate) fn forward_53_1d_cols(x: &Plane, phase: u8) -> (Plane, Plane) { + split(x, phase, false) +} + +/// Horizontal first (phase 0), then vertical; returns `(ll, lh, hl, hh)`, the exact inverse of `inverse_53_2d`. +pub(crate) fn forward_53_2d(x: &Plane, vphase: u8) -> (Plane, Plane, Plane, Plane) { + let (lo, hi) = forward_53_1d_cols(x, 0); + let (ll, lh) = forward_53_1d_rows(&lo, vphase); + let (hl, hh) = forward_53_1d_rows(&hi, vphase); + (ll, lh, hl, hh) +} + +/// 3-level forward, the exact inverse of `reconstruct3` with the same `phases`. +pub(crate) fn forward3(x: &Plane, phases: [u8; 3]) -> Bands3 { + let (ll1, lh1, hl1, hh1) = forward_53_2d(x, phases[0]); + let (ll2, lh2, hl2, hh2) = forward_53_2d(&ll1, phases[1]); + let (ll3, lh3, hl3, hh3) = forward_53_2d(&ll2, phases[2]); + Bands3 { ll3, hl3, lh3, hh3, hl2, lh2, hh2, hl1, lh1, hh1 } +} + +// ---- RDD 34 line encoder (mirror of `llvc::vld_decode_line`; port of the Phase 0 reference model's encoder) ---- + +/// MSB-first bit sink; one `0`/`1` per element until `finish`. +#[derive(Default)] +pub(crate) struct BitWriter { + bits: Vec, +} + +impl BitWriter { + pub(crate) fn put(&mut self, bit: bool) { + self.bits.push(u8::from(bit)); + } + pub(crate) fn put_n(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + self.put((v >> i) & 1 == 1); + } + } + fn zeros(&mut self, n: u32) { + for _ in 0..n { + self.put(false); + } + } + /// Zero-padded to a whole byte. + pub(crate) fn finish(self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (b << (7 - i)))).collect() + } +} + +/// Encode one line (`width` coefficients, padded with zeros to whole sets of 4), with Sony's two deviations. +pub(crate) fn vld_encode_line(values: &[i32], width: usize, out: &mut BitWriter) { + let sets_n = width.div_ceil(4); + let mut vals = values[..width.min(values.len())].to_vec(); + vals.resize(4 * sets_n, 0); + let sets: Vec<&[i32]> = vals.chunks(4).collect(); + let dpts: Vec = sets.iter().map(|s| 32 - s.iter().map(|v| v.unsigned_abs()).max().unwrap_or(0).leading_zeros()).collect(); + let (mut dpt, mut dpts_state, mut cnt, mut zr) = (0u32, 0u8, sets_n, 0usize); + let mut prev: Option<&[i32]> = None; + let mut k = 0usize; + loop { + let dpt_v = cnt != 0; + cnt = cnt.saturating_sub(1); + zr = zr.saturating_sub(1); + if dpt_v { + let d = dpts[k]; + match dpts_state { + 0 if d == dpt => { + out.put(false); + if dpt == 0 { + dpts_state = 1; + } + } + 0 if d > dpt => { + out.put(true); + out.put(false); + out.zeros(d - dpt - 1); + out.put(true); + dpt = d; + } + 0 => { + out.put(true); + out.put(true); + if d >= 1 { + out.zeros(dpt - d - 1); + out.put(true); + dpt = d; + } else { + out.zeros(dpt - 1); + dpt = 0; + dpts_state = 1; + } + } + 1 if d > 0 => { + out.put(true); + out.zeros(d - 1); + out.put(true); + dpt = d; + dpts_state = 0; + } + 1 => { + let r = dpts[k..].iter().take_while(|&&x| x == 0).count(); + let lim = (cnt as u64 + 2).next_power_of_two().trailing_zeros(); + if k + r >= sets_n { + out.zeros(lim); // the rest of the line is zero + zr = cnt + 2; + } else { + let v = (r + 1) as u32; + let m = 31 - v.leading_zeros(); + assert!(m < lim); + out.zeros(m); + out.put(true); + out.put_n(v - (1 << m), m); + zr = r + 1; + } + dpts_state = 2; + } + _ if zr == 1 => { + out.zeros(d - 1); + out.put(true); + dpt = d; + dpts_state = 0; + } + _ => assert_eq!(d, 0), + } + } + if let Some(p) = prev { + for &v in p { + if v != 0 { + out.put(v < 0); + } + } + } + if k >= sets_n { + return; + } + if dpt_v { + for &v in sets[k] { + out.put_n(v.unsigned_abs(), dpt); + } + prev = Some(sets[k]); + } + k += 1; + } +} + +// ---- tile / file serialiser (`tilewrite.py::TileModel.serialize`, `arw6dump.py`) ---- + +/// Per-band quantiser indices: `l1`/`l2`/`l3` per component, bands in HL, LH, HH order. +#[derive(Clone)] +pub(crate) struct Qis { + pub l1: [[u32; 3]; 3], + pub l2: [[u32; 3]; 3], + pub l3: [[u32; 3]; 3], + pub res: u32, +} + +impl Qis { + pub(crate) const ZERO: Qis = Qis { l1: [[0; 3]; 3], l2: [[0; 3]; 3], l3: [[0; 3]; 3], res: 0 }; + /// The values seen in every real file (*Container*). + pub(crate) const REAL: Qis = Qis { l1: [[1, 1, 2], [2, 2, 3], [2, 2, 3]], l2: [[0, 0, 1]; 3], l3: [[0; 3]; 3], res: 2 }; +} + +/// One tile to encode: `s` is the anchor offset (*Frame geometry*), planes are W/2 x H/2. +pub(crate) struct TileSpec { + pub s: u8, + pub qi: Qis, + pub m: Plane, + pub c1: Plane, + pub c2: Plane, + pub res: Plane, +} + +/// Band row indices of the plane rows `rows` (low: `t % 2 == p`, row `(t-p)/2`; high: row `(t-(1-p))/2`). +fn split_rows(rows: &[usize], p: usize) -> (Vec, Vec) { + let (mut lo, mut hi) = (Vec::new(), Vec::new()); + for &t in rows { + if t % 2 == p { + lo.push((t - p) / 2); + } else { + hi.push((t - (1 - p)) / 2); + } + } + (lo, hi) +} + +fn quantised_rows(plane: &Plane, rows: &[usize], qi: u32) -> Vec> { + rows.iter().map(|&r| plane.row(r).unwrap().iter().map(|&v| quantize(v, qi)).collect()).collect() +} + +fn encode_lines(lines: &[Vec]) -> Vec { + let mut w = BitWriter::default(); + for l in lines { + vld_encode_line(l, l.len(), &mut w); + } + w.finish() +} + +fn pad16(v: &mut Vec) { + v.resize(v.len().next_multiple_of(16), 0); +} + +fn u24(v: usize) -> [u8; 3] { + [(v >> 16) as u8, (v >> 8) as u8, v as u8] +} + +/// One stream: header word, index table, data area. `halves[i]` = (bytes, qi nibbles of the TU). +fn build_stream(halves: &[(Vec, Vec)]) -> Vec { + let (mut idx, mut data) = (Vec::new(), Vec::new()); + for pair in halves.chunks(2) { + let mut nib: Vec = Vec::new(); + for (bytes, qi) in pair { + nib.extend((0..4).rev().map(|i| (bytes.len() as u32 >> (4 * i)) & 15)); + nib.extend(qi); + data.extend_from_slice(bytes); + } + idx.extend(nib.chunks(2).map(|c| (c[0] << 4 | c[1]) as u8)); + } + pad16(&mut idx); + pad16(&mut data); + let mut out = (idx.len() as u16 / 16).to_be_bytes().to_vec(); + out.extend(u24(data.len() / 16)); + out.extend([0x40, 0x40, 0x01, 0x12, 0x10, 0, 0, 0, 0, 0, 0]); + out.extend(idx); + out.extend(data); + out +} + +/// Encode one tile: forward 3-level 5/3 per component with the phases of `s`, quantise, pack every half in natural +/// clipped order (*Frame geometry*), then add index tables, stream headers and the tile header. +pub(crate) fn encode_tile(t: &TileSpec) -> Vec { + let (vs, s) = (t.m.height, i32::from(t.s)); + let ntu = crate::vendor::arw6::tu_count(vs, t.s); + let p1 = s % 2; + let p2 = ((2 + s - p1) / 2) % 2; + let k3 = ((6 + s) % 8 - p1) / 2; + let p3 = ((k3 - p2) / 2).rem_euclid(2); + let (up1, up2, up3) = (p1 as usize, p2 as usize, p3 as usize); + let comps = [&t.m, &t.c1, &t.c2]; + let bands: Vec<_> = comps.iter().map(|p| forward3(p, [p1 as u8, p2 as u8, p3 as u8])).collect(); + let qi = &t.qi; + // streams[group][comp] -> halves + let mut streams: Vec, Vec)>>> = + vec![vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new()]]; + for n in 0..2 * ntu as i32 { + let rows: Vec = (8 * n - 6 + s..8 * n + 2 + s).filter(|&r| r >= 0 && (r as usize) < vs).map(|r| r as usize).collect(); + let (low1, high1) = split_rows(&rows, up1); + let (low2, high2) = split_rows(&low1, up2); + let (low3, high3) = split_rows(&low2, up3); + for (c, b) in bands.iter().enumerate() { + let cat = |parts: [Vec>; 3]| parts.into_iter().flatten().collect::>(); + let dpcm = { + let mut ll = quantised_rows(&b.ll3, &low3, 0); + for l in &mut ll { + let mut prev = if c == 0 { 2048 } else { 0 }; + for v in l.iter_mut() { + (*v, prev) = (*v - prev, *v); + } + } + ll + }; + let sets: [(usize, Vec>, Vec); 4] = [ + (0, dpcm, vec![0]), + ( + 1, + cat([ + quantised_rows(&b.hl3, &low3, qi.l3[c][0]), + quantised_rows(&b.lh3, &high3, qi.l3[c][1]), + quantised_rows(&b.hh3, &high3, qi.l3[c][2]), + ]), + qi.l3[c].to_vec(), + ), + ( + 2, + cat([ + quantised_rows(&b.hl2, &low2, qi.l2[c][0]), + quantised_rows(&b.lh2, &high2, qi.l2[c][1]), + quantised_rows(&b.hh2, &high2, qi.l2[c][2]), + ]), + qi.l2[c].to_vec(), + ), + ( + 3, + cat([ + quantised_rows(&b.hl1, &low1, qi.l1[c][0]), + quantised_rows(&b.lh1, &high1, qi.l1[c][1]), + quantised_rows(&b.hh1, &high1, qi.l1[c][2]), + ]), + qi.l1[c].to_vec(), + ), + ]; + for (g, lines, q) in sets { + streams[g][c].push((encode_lines(&lines), q)); + } + } + streams[4][0].push((encode_lines(&quantised_rows(&t.res, &rows, qi.res)), vec![qi.res])); + } + let blobs: Vec>> = streams.iter().map(|g| g.iter().map(|s| build_stream(s)).collect()).collect(); + let mut head = vec![0u8; 8 * 16]; + head[..4].copy_from_slice(b"0000"); // bytes 4..8: tile index, 0 + let tw = 2 * t.m.width; + let w0: u64 = (tw as u64) << 48 | (vs as u64) << 32 | 16 << 20 | 3 << 13 | 3 << 10 | 0b10_0000_0000; + head[8..16].copy_from_slice(&w0.to_be_bytes()); + for (g, group) in blobs.iter().enumerate() { + let words: Vec = group.iter().map(|b| b.len() / 16).collect(); + head[(3 + g) * 16] = words.len() as u8; + for (i, w) in words.iter().enumerate() { + head[(3 + g) * 16 + 1 + 3 * i..][..3].copy_from_slice(&u24(*w)); + } + head[16 + 3 * g..][..3].copy_from_slice(&u24(words.iter().sum())); + } + let mut out = head; + out.extend(blobs.into_iter().flatten().flatten()); + out.resize(out.len().next_multiple_of(4096), 0); + out +} + +/// A little-endian ARW6 TIFF: IFD0 (Sony, ILCE-7RM6) + a SubIFD with the strip = tile table, zero padding to 512, +/// then the tiles `(x, y, w, h, bytes)` back to back. +pub(crate) fn arw6_file(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize) -> Vec { + arw6_file_with(tiles, width, height, true, 14) +} + +/// [`arw6_file`] with another BitsPerSample. +pub(crate) fn arw6_file_bits(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize, bits: u16) -> Vec { + arw6_file_with(tiles, width, height, true, bits) +} + +/// [`arw6_file`] without the WhiteLevel tag. +pub(crate) fn arw6_file_no_white(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize) -> Vec { + arw6_file_with(tiles, width, height, false, 14) +} + +fn arw6_file_with(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize, white: bool, bits: u16) -> Vec { + let mut strip = (tiles.len() as u32).to_le_bytes().to_vec(); + strip.extend([0; 4]); + let mut offset = 512u64; + for (x, y, w, h, bytes) in tiles { + strip.extend(offset.to_le_bytes()); + for v in [x, y, w, h] { + strip.extend((*v as u32).to_le_bytes()); + } + offset += bytes.len() as u64; + } + strip.resize(512, 0); + for (.., bytes) in tiles { + strip.extend_from_slice(bytes); + } + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![width as u32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![height as u32])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits])); + raw.set(t::COMPRESSION, Value::Short(vec![32766])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7310, Value::Short(vec![512; 4])); + if white { + raw.set(t::WHITE_LEVEL, Value::Long(vec![15360])); + } + raw.set_image(ImageData::Strips { rows_per_strip: height as u32, strips: vec![strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii("ILCE-7RM6".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![1])); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +/// A tile whose bands are random quantised integers `q` (level 1 wider), `v = dequant(q, qi)`, planes by +/// `reconstruct3` (so `quantize` in the encoder is exact on them); LL3 is random around 2048 (green mean) or 0 (chroma), the residual +/// `dequant(q, qi.res)`. +pub(crate) fn one_tile_quantised(w: usize, h: usize, s: u8, qi: Qis) -> (Vec, TileSpec) { + use crate::llvc::{dequant, reconstruct3}; + let (w2, vs) = (w / 2, h / 2); + let ph = crate::vendor::arw6::phases(s); + let mut x = 0x9e37_79b9_7f4a_7c15u64; + let mut rnd = |span: i32, centre: i32, q: u32| -> i32 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let v = (x % (2 * span as u64 + 1)) as i32 - span; + if centre == i32::MIN { + v + } else if centre == 0 { + dequant(v, q) + } else { + centre + v + } + }; + let mut plane = |wd: usize, ht: usize, span: i32, centre: i32, q: u32| Plane { + width: wd, + height: ht, + data: (0..wd * ht).map(|_| rnd(span, centre, q)).collect(), + }; + let mut comps = Vec::new(); + for c in 0..3 { + let (n1l, n1h) = band_rows(vs, ph[0]); + let (n2l, n2h) = band_rows(n1l, ph[1]); + let (n3l, n3h) = band_rows(n2l, ph[2]); + let (w1, w2b, w3) = (w2 / 2, w2 / 4, w2 / 8); + let (a, b, d) = (qi.l1[c], qi.l2[c], qi.l3[c]); + let bands = Bands3 { + ll3: plane(w3, n3l, 60, if c == 0 { 2048 } else { i32::MIN }, 0), + hl3: plane(w3, n3l, 7, 0, d[0]), + lh3: plane(w3, n3h, 7, 0, d[1]), + hh3: plane(w3, n3h, 7, 0, d[2]), + hl2: plane(w2b, n2l, 7, 0, b[0]), + lh2: plane(w2b, n2h, 7, 0, b[1]), + hh2: plane(w2b, n2h, 7, 0, b[2]), + hl1: plane(w1, n1l, 15, 0, a[0]), + lh1: plane(w1, n1h, 15, 0, a[1]), + hh1: plane(w1, n1h, 15, 0, a[2]), + }; + comps.push(reconstruct3(&bands, ph).unwrap()); + } + let res = plane(w2, vs, 7, 0, qi.res); + let (m, c1, c2) = (comps.remove(0), comps.remove(0), comps.remove(0)); + let t = TileSpec { s, qi, m, c1, c2, res }; + (encode_tile(&t), t) +} diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index 2fa0fdb72..f4e02d69d 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -194,36 +194,39 @@ mod tests { fn header_and_full_decode_preserve_correction_through_dng() { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { - for order in [ByteOrder::Little, ByteOrder::Big] { - let mut raw = IfdBuilder::new(); - raw.set(t::MAKE, Value::Ascii("SONY".into())); - raw.set(t::MODEL, Value::Ascii(model.into())); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); - 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::WHITE_LEVEL, Value::Long(vec![16383])); - raw.set(0x74c7, Value::Long(vec![0, 2])); - raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); - raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); - raw.set(0x74c8, Value::Long(vec![30, 20])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); - // Zero pixels have the same byte representation in both orders. - raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); - let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); - let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); - let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); - assert_eq!(header.info(), full.info()); - assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?}"); - // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) - assert_eq!(full.crop, Rect::new(0, 2, 30, 20)); - if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { - assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + for (height, expected) in [(20, expected), (16, 0)] { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + 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::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. + raw.set(0x74c8, Value::Long(vec![30, height])); + raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); + let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?} height={height}"); + // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) + assert_eq!(full.crop, Rect::new(0, 2, 30, height as usize)); + if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + } + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); } - let dng = crate::write_dng(&full, &Default::default()).unwrap(); - assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); } } } diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 1d23d7272..5c9f8ee9a 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -31,7 +31,7 @@ const ASPECT_INFO: u16 = 0x009a; /// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which /// must never replace the main sensor image. An invalid full-size descriptor is reported, not /// silently replaced with the reduced raw preview. -fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> { +pub(crate) fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> { let mut best = None; let mut best_area = 0u64; for track in tracks { diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 64ca37071..938c6a251 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -10,9 +10,12 @@ //! //! Current support: version 0x100 lossless Bayer RAW and horizontal-tile C-RAW, //! plus version 0x200 single-tile 14-bit C-RAW with adaptive QP quantization -//! (including odd-height QP maps, as on the EOS R7). +//! (including odd-height QP maps, as on the EOS R7, and odd-height Bayer planes, as on the EOS R5 Mark II). //! Both ff01/ff02/ff03 and ff11/ff12/ff13 marker families are supported. //! Six full M50/R100/R8 sensor fixtures match an external decoder sample for sample. +//! The QP map's first-symbol literal and its escape (the T.87 limited-length layout) were found +//! on EOS R5 Mark II maps, whose larger QP steps need them; every map of 33,665 R5 Mark II files +//! decodes to the exact end of its entropy stream with them. //! Unverified coding variants return explicit unsupported errors. use super::crx_wavelet::{self, Band as WaveletBand, TileEdges}; @@ -25,6 +28,8 @@ const MAX_TILES: usize = 1024; const MAX_HEADER_BYTES: usize = 1 << 20; const MAX_CRX_SAMPLES: usize = 200_000_000; const MAX_CRX_EDGE: u32 = 65_536; +/// Unary QP-map prefix length that announces an 8-bit escaped symbol. +const QP_ESCAPE_ZEROS: u32 = 23; const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; fn corrupt(reason: &str) -> RawError { @@ -68,6 +73,15 @@ struct Tile { qp: Option>, } +/// The byte ranges of a CRX sample (relative to its start) that [`validate`] reads: the tile, plane +/// and band headers, and each tile's quantization map. `sample` must hold its first +/// `config.header_size` bytes; the rest is only measured. +pub(crate) fn header_ranges(sample: &[u8], config: &Cr3Compression) -> Result>> { + let tiles = layout(sample, config)?; + let header = 0..config.header_size as usize; + Ok(std::iter::once(header).chain(tiles.into_iter().filter_map(|t| t.qp)).collect()) +} + /// Decode to full sensor-order CFA samples, including masked borders. pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result> { supports(config)?; @@ -530,13 +544,17 @@ impl Bits<'_> { } fn qp_rice(&mut self, k: u32) -> Result { + // Limited-length code (the ITU-T T.87 layout for a 32-bit limit and 8-bit symbols): + // a quotient of 23 or more is sent as 23 zeros, the terminating 1 and the symbol in 8 bits. let mut zeros = 0; - while zeros < 16 && self.take(1)? == 0 { + while self.take(1)? == 0 { zeros += 1; + if zeros > QP_ESCAPE_ZEROS { + return Err(corrupt("QP Rice escape prefix exceeds 23 bits")); + } } - if zeros == 16 { - // QP escape has no unary terminating 1 before the 16-bit literal. - self.take(16) + if zeros == QP_ESCAPE_ZEROS { + self.take(8) } else if k <= 20 { Ok((zeros << k) | self.take(k)?) } else { @@ -754,7 +772,8 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result { let above = *previous.get(x).ok_or_else(|| corrupt("above QP outside row"))?; let corner = if x == 0 { above } else { *previous.get(x - 1).ok_or_else(|| corrupt("corner QP outside row"))? }; let right = previous.get(x + 1).copied().unwrap_or(above); - let symbol = bits.qp_rice(k)?; + // The map opens with its first symbol as a 32-bit literal; every later one is Rice coded. + let symbol = if x == 0 && y == 0 { (bits.take(16)? << 16) | bits.take(16)? } else { bits.qp_rice(k)? }; let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; if !(131..=168).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}"))); @@ -986,9 +1005,16 @@ mod tests { #[test] fn qp_escape_and_average_precede_the_quantization_table() { - let mut bits = Bits { src: &[0, 0, 1, 0x2c], at: 0 }; - assert_eq!(bits.qp_rice(0).unwrap(), 300); + // 23 zeros, the terminating 1, then the symbol in 8 bits. + let mut bits = Bits { src: &[0, 0, 1, 0x18], at: 0 }; + assert_eq!(bits.qp_rice(0).unwrap(), 24); assert_eq!(bits.at, 32); + // 22 zeros are an ordinary Rice prefix. + let mut bits = Bits { src: &[0, 0, 2, 0], at: 0 }; + assert_eq!(bits.qp_rice(0).unwrap(), 22); + assert_eq!(bits.at, 23); + assert!(Bits { src: &[0, 0, 0, 0x80, 0], at: 0 }.qp_rice(0).is_err()); + // The map's first symbol is a 32-bit literal (300 = QP 150 from prediction 0). let map = qp_map(&[0, 0, 1, 0x2c, 0x94], 1, 4).unwrap(); assert_eq!(map.data, [150; 4]); assert!(qp_map(&[0, 0, 1, 0x2c, 0x95], 1, 4).is_err()); @@ -1011,6 +1037,17 @@ mod tests { assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_)))); } + #[test] + fn long_qp_prefixes_and_escapes_as_on_the_eos_r5_mark_ii() { + // EOS R5 Mark II maps (QP 150–162) jump by up to 12 where k is 0: an 18-zero prefix that an + // escape after 16 zeros misread, then a real escape (23 zeros, 1, symbol 23 = −12). + let src = [0x00, 0x00, 0x01, 0x2c, 0x90, 0x00, 0x01, 0x90, 0x00, 0x00, 0x08, 0xb8]; + assert_eq!(qp_map(&src, 7, 1).unwrap().data, [150, 150, 150, 159, 159, 159, 147]); + for end in 0..src.len() { + assert!(qp_map(&src[..end], 7, 1).is_err()); + } + } + #[test] fn odd_height_qp_map_uses_its_last_row_alone() { // EOS R7 C-RAW: 4732 sensor rows give a 1183-row QP map, so the last diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs new file mode 100644 index 000000000..f7f82cff8 --- /dev/null +++ b/crates/raw/src/vendor/kodak.rs @@ -0,0 +1,283 @@ +//! Kodak DCS520C raw TIFF: the CFA sub-IFD holds one lossless JPEG (SOF3) stream of two components. +//! +//! Sources: TIFF 6.0 (container; `GrayResponseCurve` `0x123`; the CFA tags `CFARepeatPatternDim` and `CFAPattern`), +//! ITU T.81 (lossless JPEG, process SOF3), LightCraft's own decoder in [`crate::ljpeg`], the file's `KodakIFD` +//! (`0x8290`, a plain IFD with absolute offsets) and our own black-box analysis of the one CC0 sample from +//! raw.pixls.us (2573, DCS520C; every number below was measured on it): +//! +//! - The raw IFD is `Compression` 7, photometric 32803 (CFA), 12 bits, one strip, `ImageWidth` x `ImageLength` = +//! 1736 x 1160. Its stream is a 12-bit lossless JPEG of 868 x 1160 with two components; the decoder's interleaved +//! output (component 0, component 1, per pixel) is the mosaic row by row, so component 0 is the even columns and +//! component 1 the odd ones. The stream ends exactly at the strip's end. Recognised structurally: a Kodak make +//! and a stream whose width times its component count is the IFD's `ImageWidth`. +//! - `CFAPattern` bytes `01 00 02 01` (anchored at the first sample) are GRBG; the decoded colours follow the IFD0 +//! RGB thumbnail (156 x 104) with that layout. +//! - The samples span only 191..=622 because they are stored companded: IFD `GrayResponseCurve` (`0x123`, 4096 +//! entries) is a non-decreasing table that is the identity below 216 and expands the rest to 12-bit linear +//! values (623 distinct values, stored 622 gives 4095 and so does every higher code). Applying it makes the +//! frame linear with 12-bit white 4095. +//! - No masked border: all four edges are picture. Black differs per colour: the darkest 0.01% of each CFA +//! position's samples sit at 220 (G), 223 (R) and 197 (B) after the table (scene shadows end at the sensor's +//! black), so black is measured per position from the samples. +//! - White balance: five rational triples in the `KodakIFD` look like neutral sensor values (`0x848`, `0x849`, +//! `0x84a`, `0x84c`, `0x84d`). Against the thumbnail's neutral pixels (370 pixels with R, G and B within 6 of +//! each other) the raw G/R is 0.94 and G/B 1.96; `0x848` (1913, 1834, 921) gives 0.96 and 1.99, the others +//! 0.77..1.25 and 2.08..2.96, so its green over red and over blue are the gains. +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// The vendor IFD pointer (`KodakIFD`). +const KODAK_IFD: u16 = 0x8290; +/// Rational triple (red, green, blue) of neutral sensor values, see the module notes. +const NEUTRAL: u16 = 0x848; +/// TIFF `GrayResponseCurve`. +const RESPONSE_CURVE: u16 = 0x123; +/// Entries of the response curve: one per 12-bit code. +const CURVE_LEN: usize = 4096; + +/// The raw sub-IFD of a Kodak DCS TIFF with this layout: the CFA image whose single lossless JPEG stream has two +/// components, `ImageWidth / 2` columns and `ImageLength` rows, 12 bits. +pub(crate) fn raw_ifd<'a>(tiff: &'a Tiff, bytes: &[u8]) -> Option<&'a Ifd> { + let make = tiff.find(t::MAKE).and_then(|e| e.value.as_str())?; + if !make.to_ascii_uppercase().starts_with("KODAK") { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + if i.u16(t::PHOTOMETRIC) != Some(t::photometric::CFA) || i.u16(t::COMPRESSION) != Some(t::compression::JPEG) { + return false; + } + let Ok(info) = i.image() else { return false }; + let chunks = info.chunks(bytes.len() as u64); + let [chunk] = chunks.as_slice() else { return false }; + let Some(src) = chunk_bytes(bytes, chunk) else { return false }; + matches!( + ljpeg::frame_info(src), + Ok((w, h, 2, 12)) if w.checked_mul(2) == Some(info.width as usize) && h == info.height as usize + ) && i.u64s(RESPONSE_CURVE).is_some_and(|c| c.len() == CURVE_LEN) + }) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("Kodak TIFF without IFDs".into()))?; + let raw = raw_ifd(&tiff, bytes).ok_or_else(|| RawError::Unsupported("Kodak raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Kodak raw without a 2x2 CFAPattern".into())), + }; + let curve: Vec = raw + .u64s(RESPONSE_CURVE) + .filter(|c| c.len() == CURVE_LEN && c.windows(2).all(|p| p[0] <= p[1]) && c.iter().all(|&v| v < CURVE_LEN as u64)) + .ok_or_else(|| RawError::Unsupported("Kodak raw without a usable response curve".into()))? + .into_iter() + .map(|v| v as u16) + .collect(); + let (black, data) = if mode == Mode::Full { + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Kodak strip outside file".into()))?; + let f = ljpeg::decode(src, n)?; + if f.components != 2 || f.width * 2 != w || f.height != h || f.data.len() != n { + return Err(RawError::Corrupt("Kodak lossless JPEG does not match the image size".into())); + } + let mut d = f.data; + for v in &mut d { + *v = curve.get(usize::from(*v)).copied().unwrap_or(CURVE_LEN as u16 - 1); + } + (measured_black(&d, w), RawData::U16(d)) + } else { + (BlackLevel::uniform(0.0), RawData::U16(Vec::new())) + }; + let wb = kodak_ifd(bytes, &tiff, ifd0) + .and_then(|k| k.f64s(NEUTRAL)) + .filter(|v| v.len() == 3 && v.iter().all(|&x| x > 0.0)) + .map(|v| [(v[1] / v[0]) as f32, 1.0, (v[1] / v[2]) as f32]); + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(w as u32); + metadata.height = Some(h as u32); + let img = RawImage { + format: RawFormat::KodakDcs, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![(CURVE_LEN - 1) as f32], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +/// The vendor IFD (`KodakIFD`): a plain IFD with absolute value offsets. +fn kodak_ifd(bytes: &[u8], tiff: &Tiff, ifd0: &Ifd) -> Option { + let off = u64::from(ifd0.u32(KODAK_IFD)?); + let opts = lightcraft_tiff::ParseOptions { max_ifds: 4, max_depth: 1, follow_children: false, ..Default::default() }; + lightcraft_tiff::parse_ifd_at(bytes, off, tiff.order, 0, false, &opts).ok().map(|(i, _)| i) +} + +/// Black level per CFA position (the mosaic's first sample is position 0): the value below which only 0.01% of that +/// position's samples fall, see the module notes. Uniform 0 for an empty frame. +fn measured_black(d: &[u16], w: usize) -> BlackLevel { + let mut hist = vec![[0u32; CURVE_LEN]; 4]; + let mut count = [0u64; 4]; + for (y, row) in d.chunks(w.max(1)).enumerate() { + for (x, &v) in row.iter().enumerate() { + let p = (y & 1) * 2 + (x & 1); + hist[p][usize::from(v).min(CURVE_LEN - 1)] += 1; + count[p] += 1; + } + } + if count.contains(&0) { + return BlackLevel::uniform(0.0); + } + let values = (0..4) + .map(|p| { + let limit = count[p].div_ceil(10_000); + let mut seen = 0u64; + let level = hist[p] + .iter() + .position(|&c| { + seen += u64::from(c); + seen >= limit + }) + .unwrap_or(0); + level as f32 + }) + .collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode, probe, probe_info}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + + const W: usize = 16; + const H: usize = 8; + + /// A response curve: the identity below 100, then steeper, saturating at 4095. + fn curve() -> Vec { + (0..CURVE_LEN as u32).map(|i| if i < 100 { i as u16 } else { (100 + (i - 100) * 7).min(4095) as u16 }).collect() + } + + /// Stored mosaic: values 60..=300, in the range the curve expands. + fn stored() -> Vec { + (0..W * H).map(|i| 60 + ((i * 37) % 241) as u16).collect() + } + + /// A big-endian IFD of `(tag, SRATIONAL triple)` entries at `at` in the file. + fn kodak_ifd_bytes(at: usize, tag: u16, v: [i32; 3]) -> Vec { + let mut b = vec![0, 1]; + b.extend(tag.to_be_bytes()); + b.extend(10u16.to_be_bytes()); + b.extend(3u32.to_be_bytes()); + b.extend(((at + 2 + 12 + 4) as u32).to_be_bytes()); + b.extend([0; 4]); + for x in v { + b.extend(x.to_be_bytes()); + b.extend(1i32.to_be_bytes()); + } + b + } + + fn file(make: &str, stream: Vec, width: u32, with_curve: bool, wb: Option<[i32; 3]>) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Short(vec![width as u16])); + raw.set(t::IMAGE_LENGTH, Value::Short(vec![H as u16])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + if with_curve { + raw.set(RESPONSE_CURVE, Value::Short(curve())); + } + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![1, 0, 2, 1])); + raw.set_image(ImageData::Strips { rows_per_strip: H as u32, strips: vec![stream] }); + let build = |ptr: u32| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("DCS520C".into())); + if wb.is_some() { + ifd0.set(KODAK_IFD, Value::Long(vec![ptr])); + } + ifd0.add_sub_ifd(raw.clone()); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + }; + let mut bytes = build(0); + if let Some(v) = wb { + let at = bytes.len().next_multiple_of(2); + bytes = build(at as u32); + bytes.resize(at, 0); + bytes.extend(kodak_ifd_bytes(at, NEUTRAL, v)); + } + bytes + } + + fn stream() -> Vec { + ljpeg::encode(&stored(), W / 2, H, 2, 12, 1, 0) + } + + /// Known answer: two interleaved components are the even and odd columns, the response curve linearises them, + /// the layout is GRBG, white balance comes from the vendor IFD, black is measured per CFA position. + #[test] + fn two_component_stream_decodes_through_the_response_curve() { + let bytes = file("Kodak", stream(), W as u32, true, Some([1900, 1800, 900])); + assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::KodakDcs, W, H, 12)); + let want: Vec = stored().iter().map(|&v| curve()[usize::from(v)]).collect(); + assert_eq!(r.data, RawData::U16(want.clone())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "GRBG"); + assert_eq!(r.white, vec![4095.0]); + assert_eq!(r.wb_multipliers, Some([1800.0 / 1900.0, 1.0, 2.0])); + // 0.01% of 32 samples is the smallest one of each position + let w = &want; + let min = |y0: usize, x0: usize| (y0..H).step_by(2).flat_map(|y| (x0..W).step_by(2).map(move |x| w[y * W + x])).min().unwrap() as f32; + assert_eq!(r.black.values, vec![min(0, 0), min(0, 1), min(1, 0), min(1, 1)]); + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.format, i.width, i.height, i.wb_multipliers), (RawFormat::KodakDcs, W, H, r.wb_multipliers)); + } + + /// Without the vendor IFD there is no white balance; the layout is recognised from the structure alone, so + /// another make, one component, a stream of another width or a missing curve change nothing for other files. + #[test] + fn only_this_layout_is_claimed() { + assert_eq!(decode(&file("Kodak", stream(), W as u32, true, None)).unwrap().wb_multipliers, None); + assert_ne!(probe(&file("Canon", stream(), W as u32, true, None)), Some(RawFormat::KodakDcs)); + assert_ne!(probe(&file("Kodak", stream(), W as u32 + 2, true, None)), Some(RawFormat::KodakDcs)); + assert_ne!(probe(&file("Kodak", stream(), W as u32, false, None)), Some(RawFormat::KodakDcs)); + let one = ljpeg::encode(&stored()[..W * H / 2], W, H / 2, 1, 12, 1, 0); + assert_ne!(probe(&file("Kodak", one, W as u32, true, None)), Some(RawFormat::KodakDcs)); + } + + /// Hostile input: truncated streams and tables report errors instead of panicking. + #[test] + fn damaged_files_are_errors() { + let s = stream(); + for cut in [s.len() / 2, 40, 0] { + let bytes = file("Kodak", s[..cut].to_vec(), W as u32, true, Some([1, 1, 1])); + if probe(&bytes) == Some(RawFormat::KodakDcs) { + assert!(decode(&bytes).is_err() || cut == s.len()); + } + } + let bytes = file("Kodak", s, W as u32, true, Some([0, 5, 0])); + assert_eq!(decode(&bytes).unwrap().wb_multipliers, None); + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index b807c6c1e..b7bef1471 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -1,16 +1,22 @@ //! Vendor raw formats (TIFF-based) and helpers shared by them. pub mod arw; +pub mod arw6; +mod arw6_curve; +#[cfg(test)] +pub(crate) mod arw6_testenc; mod arw_lens; pub mod cr2; pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; +pub mod kodak; pub mod nef; pub mod nefc; pub mod orf; pub mod pef; pub mod raf; +mod raf_lens; mod rafc; pub mod rw2; mod sr2; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index f97a48f3b..cacb516af 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -9,8 +9,8 @@ //! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193). use super::{nefc, white_from_data}; -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::tiffraw::{Packing, check_image, read_image}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::ImageInfo; use lightcraft_tiff::tags::{self as t, photometric}; @@ -88,16 +88,27 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } -/// Width without the optically masked columns some bodies append on the right: trailing columns (at most 64) -/// whose mean is below 1% of the white level while the image interior is brighter. Kept even for CFA phase. -pub(crate) fn trailing_masked_columns(d: &[u16], w: usize, h: usize, white: f32) -> usize { - if w < 128 || h == 0 { +/// Rows the masked-column test reads, from the top of the frame. The header probe ([`Mode::Header`]) decodes +/// only these (a compressed strip is one sequential Huffman stream, so it can stop here; an uncompressed one reads +/// just its first strips), and the full decode judges the active width from the same rows, so +/// [`crate::probe_info`] equals [`RawImage::info`] of the decoded image (issue #708). On every corpus NEF the +/// answer is the same for 8 to 512 rows as for the whole frame: the masked columns decode to 0 (compressed) or +/// ~19 (uncompressed) while live columns sit at the black level (~150 at 12 bit, ~600 at 14 bit) or above. +pub(crate) const MASK_ROWS: usize = 128; + +/// Width without the optically masked columns some bodies append on the right (the D5100 and D7000 write 8, with +/// no maker-note tag that says so, in compressed and uncompressed files alike): trailing columns (at most 64) whose +/// mean over the first [`MASK_ROWS`] rows of `d` is below 1% of the nominal white level and a tenth of the image +/// interior. Kept even for CFA phase. +pub(crate) fn trailing_masked_columns(d: &[u16], w: usize, bits: u32) -> usize { + let rows = d.len().checked_div(w).unwrap_or(0).min(MASK_ROWS); + if w < 128 || rows == 0 { return w; } - let step = (h / 256).max(1); - let col_mean = |x: usize| (0..h).step_by(step).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(step) as f64; + let col_mean = |x: usize| (0..rows).map(|y| d.get(y * w + x).copied().unwrap_or(0) as f64).sum::() / rows as f64; let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; - let dark = (white as f64 * 0.01).min(interior * 0.1); + let white = ((1u32 << bits.clamp(1, 16)) - 1) as f64; + let dark = (white * 0.01).min(interior * 0.1); let mut aw = w; while aw > w - 64 && col_mean(aw - 1) < dark { aw -= 1; @@ -105,7 +116,7 @@ pub(crate) fn trailing_masked_columns(d: &[u16], w: usize, h: usize, white: f32) aw & !1 } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("NEF without a CFA image IFD".into()))?; @@ -115,10 +126,15 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let make = ifd0.string(t::MAKE).unwrap_or_default(); let mn = tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + // the header probe reads only the rows the masked-column test needs + let rows = match mode { + Mode::Full => h, + Mode::Header => h.min(MASK_ROWS), + }; let data = if info.compression == t::compression::NIKON { - compressed(bytes, &info, mn.as_ref())? + compressed(bytes, &info, mn.as_ref(), rows)? } else { - uncompressed(bytes, &info, tiff.order, mn.as_ref())? + uncompressed(bytes, &info, tiff.order, mn.as_ref(), rows)? }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) }; @@ -145,8 +161,14 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { _ => Cfa::bayer_static("RGGB"), }; let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits); - let white = body.map_or_else(|| white_from_data(samples, bits), |b| b.white); - let active_w = trailing_masked_columns(samples, w, h, white); + let active_w = trailing_masked_columns(samples, w, bits); + // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its + // result) takes the nominal one + let white = match (body, mode) { + (Some(b), _) => b.white, + (None, Mode::Full) => white_from_data(samples, bits), + (None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, + }; let crop = match body { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), @@ -159,7 +181,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { width: w, height: h, cpp: 1, - data, + data: match mode { + Mode::Full => data, + Mode::Header => RawData::U16(Vec::new()), + }, cfa: Some(cfa), bits, black, @@ -173,14 +198,14 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } /// Uncompressed strips: 16-bit words or 12-bit packed, told apart by the strip size. Packed 12-bit data is an MSB-first /// byte stream, except in files with an NRW data block (Coolpix), where it is MSB-first inside little-endian 32-bit -/// words (the rows are whole words). -fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&makernote::MakerNote>) -> Result { +/// words (the rows are whole words). Reads the first `rows` rows (the strips that hold them) of the `info.height`. +fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&makernote::MakerNote>, rows: usize) -> Result { let (w, h) = (info.width as usize, info.height as usize); let bits = info.bits() as u32; let row_samples = w * info.samples_per_pixel as usize; @@ -196,11 +221,18 @@ fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&ma } else { Packing::Msb }; - read_image(bytes, info, order, packing) + if rows >= h { + return read_image(bytes, info, order, packing); + } + // the whole image's limits apply either way; then only the strips holding the first `rows` rows are unpacked + check_image(bytes, info)?; + let top = ImageInfo { height: rows as u32, ..info.clone() }; + read_image(bytes, &top, order, packing) } -/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. -fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>) -> Result { +/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. Decodes the first +/// `rows` rows of the `info.height`. +fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, rows: usize) -> Result { let starts_with = |magic: &[u8]| { let start = info.chunks(bytes.len() as u64).first().and_then(|c| usize::try_from(c.offset).ok()); start.and_then(|o| bytes.get(o..o.checked_add(magic.len())?)) == Some(magic) @@ -223,7 +255,7 @@ fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>) let end = last.offset.saturating_add(last.len).min(bytes.len() as u64); let src = usize::try_from(first.offset).ok().zip(usize::try_from(end).ok()).and_then(|(a, b)| bytes.get(a..b)); let src = src.ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?; - Ok(RawData::U16(nefc::decode(src, info.width as usize, info.height as usize, bits, &table)?)) + Ok(RawData::U16(nefc::decode_rows(src, info.width as usize, info.height as usize, rows, bits, &table)?)) } #[cfg(test)] @@ -279,6 +311,45 @@ mod tests { assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None); } + /// Issue #778: any make's raw falls back to its EXIF ColorSpace, with Uncalibrated + InteropIndex "R03" read as + /// Adobe RGB; the Nikon note wins; a DNG is left alone. + #[test] + fn preview_color_space_falls_back_to_the_exif_color_space() { + let file = |make: &str, cs: u16, interop: Option<&str>, note: Option, dng: bool| { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![cs])); + if let Some(i) = interop { + let mut io = IfdBuilder::new(); + io.set(0x0001, Value::Ascii(i.into())); + exif.set_child(t::INTEROP_IFD, io); + } + if let Some(v) = note { + let mut mn = IfdBuilder::new(); + mn.set(0x001e, Value::Short(vec![v])); + let mut bytes = b"Nikon".to_vec(); + bytes.extend(TiffWriter::new(ByteOrder::Little, false).write(&[mn]).unwrap()); + exif.set(t::MAKER_NOTE, Value::Undefined(bytes)); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + if dng { + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + }; + use crate::PreviewColorSpace::{AdobeRgb, Srgb}; + let f = crate::embedded_preview_color_space; + assert_eq!(f(&file("SONY", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("SONY", 1, Some("R98"), None, false)), Some(Srgb)); + assert_eq!(f(&file("SONY", 0xFFFF, Some("R98"), None, false)), None); + assert_eq!(f(&file("SONY", 0xFFFF, None, None, false)), None); + assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("NIKON CORPORATION", 1, Some("R98"), Some(2), false)), Some(AdobeRgb)); + assert_eq!(f(&file("NIKON CORPORATION", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, true)), None); + } + #[test] fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() { let (w, h) = (32, 24); @@ -327,6 +398,34 @@ mod tests { } } + /// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe + /// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not. + #[test] + fn masked_columns_are_judged_from_the_top_rows_in_both_modes() { + let (w, h, rps) = (256usize, MASK_ROWS + 64, 8u32); + let file = |masked_rows: std::ops::Range| { + let words: Vec = (0..w * h) + .map(|i| if masked_rows.contains(&(i / w)) && i % w >= w - 8 { 0u16 } else { 1000 + (i % 7) as u16 }) + .flat_map(|v| v.to_be_bytes()) + .collect(); + nef(1, 14, words.chunks(w * 2 * rps as usize).map(|c| c.to_vec()).collect(), w as u32, h as u32, rps) + }; + for (masked_rows, expected_width) in [(0..h, w - 8), (0..MASK_ROWS, w - 8), (MASK_ROWS..h, w), (0..0, w)] { + let bytes = file(masked_rows.clone()); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(full.active_area.width, expected_width, "masked rows {masked_rows:?}"); + assert_eq!(full.crop.width, expected_width); + assert_eq!(crate::probe_info(&bytes).unwrap(), full.info(), "masked rows {masked_rows:?}"); + } + // the shared rule on its own: nominal white, trailing columns at most 64, even width + let d: Vec = (0..w * 4).map(|i| if i % w >= w - 9 { 0 } else { 600 }).collect(); + assert_eq!(trailing_masked_columns(&d, w, 14), w - 10, "nine dark columns trim to an even width"); + assert_eq!(trailing_masked_columns(&d, w, 12), w - 10); + assert_eq!(trailing_masked_columns(&d[..w - 1], w, 14), w, "less than a row of samples leaves the width alone"); + assert_eq!(trailing_masked_columns(&[0; 64], 64, 14), 64, "narrow images are left alone"); + assert_eq!(trailing_masked_columns(&[], 0, 14), 0); + } + #[test] fn black_level_tag_is_in_14_bit_units() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/src/vendor/nefc.rs b/crates/raw/src/vendor/nefc.rs index 8dab6b5dc..c63f0fdc0 100644 --- a/crates/raw/src/vendor/nefc.rs +++ b/crates/raw/src/vendor/nefc.rs @@ -225,17 +225,28 @@ fn fixed_word_table(bits: u32) -> Vec> { lut } -/// Decode a `w × h` Nikon Huffman-compressed strip `src` with `bits`-bit samples. +/// Decode a whole `w × h` Nikon Huffman-compressed strip `src` with `bits`-bit samples. +#[cfg(test)] pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTable) -> Result> { + decode_rows(src, w, h, h, bits, table) +} + +/// The first `rows` rows (`w × rows` samples) of a `w × h` strip. The stream is sequential, so a header-only probe +/// decodes just the rows its masked-column test looks at (issue #708) instead of the whole image. The size and +/// truncation checks still cover the whole image; the "lossy after split" end-of-strip rule (and corruption +/// further down) can only be seen when the whole strip is decoded. +pub(crate) fn decode_rows(src: &[u8], w: usize, h: usize, rows: usize, bits: u32, table: &DecodeTable) -> Result> { if bits != 12 && bits != 14 { return Err(RawError::Unsupported(format!("Nikon compressed NEF with {bits}-bit samples"))); } - let n = w.checked_mul(h).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("NEF image size"))?; + let whole = w.checked_mul(h).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("NEF image size"))?; // every code word is at least 2 bits long: a strip that can't hold the image is truncated (also bounds the // allocation by the file size) - if n / 4 > src.len() { + if whole / 4 > src.len() { return Err(RawError::Corrupt(format!("NEF: {} bytes of compressed data for {w}x{h} pixels", src.len()))); } + let rows = rows.min(h); + let n = w.saturating_mul(rows); let huff = Huffman::new( &match (&table.encoding, bits) { (Encoding::Lossless, 12) => LOSSLESS_12, @@ -286,7 +297,7 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa return Err(RawError::Corrupt(format!("NEF: compressed data ends at row {y} of {h}"))); } } - if table.split != 0 { + if table.split != 0 && rows == h { // the fixed-rate section must fill the rest of the strip: whole rows of w words and 0..7 bits of padding. // A strip may hold a few more rows than the image (one body stores 4022 rows for 4020); anything else means // the file does not follow the rule, so refuse it instead of decoding it wrongly. @@ -676,6 +687,47 @@ mod tests { assert!(e.is_err(), "{e:?}"); } + /// Issue #708: the header probe of a compressed NEF decodes only the first [`super::super::nef::MASK_ROWS`] + /// rows (the ones the masked-column test reads) instead of the whole Huffman stream, and still finds the masked + /// columns the full decode finds. A strip cut off after those rows proves it: the probe succeeds with the same + /// description while the full decode reports the truncation. + #[test] + fn header_probe_stops_after_the_masked_column_rows() { + use super::super::nef::MASK_ROWS; + let (w, h) = (160usize, MASK_ROWS * 3); + let mut img = image(w, h, 16383, 7); + // eight optically masked columns at 0, as the D5100 / D7000 write them + for row in img.chunks_mut(w) { + row[w - 8..].fill(0); + } + let t = lossless(14); + let table = { + let mut v = vec![0x46, 0x30]; + for s in t.seeds.iter().flatten() { + v.extend_from_slice(&(*s as u16).to_be_bytes()); + } + v.resize(46, 0); + v + }; + let src = encode(&img, w, &LOSSLESS_14, t.seeds); + let whole = nef_file(src.clone(), w as u32, h as u32, 14, table.clone(), ByteOrder::Big); + let full = crate::decode(&whole).unwrap(); + assert_eq!(full.active_area, crate::Rect::new(0, 0, w - 8, h), "the masked columns are found in a compressed file"); + assert_eq!(crate::probe_info(&whole).unwrap(), full.info()); + // the same stream cut off after two thirds of the rows (the first rows' codes are a prefix of the whole) + let cut = encode(&img[..w * (h * 2 / 3)], w, &LOSSLESS_14, t.seeds).len(); + assert!(cut > w * h / 4, "enough bytes to pass the size check"); + let truncated = nef_file(src[..cut].to_vec(), w as u32, h as u32, 14, table, ByteOrder::Big); + assert!(matches!(crate::decode(&truncated), Err(RawError::Corrupt(_))), "the full decode sees the truncation"); + assert_eq!(crate::probe_info(&truncated).unwrap(), full.info(), "the header probe never reads that far"); + // decode_rows itself: a prefix of the whole decode, and the whole image's size check still applies + let top = decode_rows(&src, w, h, MASK_ROWS, 14, &t).unwrap(); + assert_eq!(top.len(), w * MASK_ROWS); + assert_eq!(top[..], decode(&src, w, h, 14, &t).unwrap()[..w * MASK_ROWS]); + assert_eq!(decode_rows(&src, w, h, h + 5, 14, &t).unwrap().len(), w * h, "rows are clamped to the height"); + assert!(decode_rows(&src[..w * h / 4 - 1], w, h, 1, 14, &t).is_err(), "a strip too short for the image is corrupt in any mode"); + } + #[test] fn truncated_and_corrupted_streams_error_never_panic() { let (w, h) = (64, 16); diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 61205a3f7..7b148929d 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -85,13 +85,20 @@ fn pair(h: &Header, tag: u16) -> Option<(usize, usize)> { Some((u16::from_be_bytes([d[0], d[1]]) as usize, u16::from_be_bytes([d[2], d[3]]) as usize)) } -fn raw_ifd(raw: &[u8]) -> Result { +pub(crate) fn raw_ifd(raw: &[u8]) -> Result { let tiff = Tiff::parse(raw).map_err(|_| RawError::Unsupported("RAF without a raw IFD (older FinePix layout)".into()))?; let off = tiff.ifds.first().and_then(|i| i.u64(0xf000)).ok_or_else(|| RawError::Unsupported("RAF without a raw IFD".into()))?; let (ifd, _) = lightcraft_tiff::parse_ifd_at(raw, off, ByteOrder::Little, 0, false, &Default::default())?; Ok(ifd) } +/// The camera name in the file header (bytes 28..60, NUL padded): the Exif `Model` of every sample. +fn camera_name(b: &[u8]) -> String { + let name = b.get(28..60).unwrap_or_default(); + let end = name.iter().position(|&c| c == 0).unwrap_or(name.len()); + String::from_utf8_lossy(name.get(..end).unwrap_or_default()).trim().to_string() +} + /// The CFA: X-Trans from the (reversed) layout record, else RGGB. fn cfa(h: &Header) -> Cfa { match record(h, XTRANS_LAYOUT) { @@ -205,7 +212,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, }; img.validate_for(mode)?; @@ -223,29 +230,57 @@ pub(super) mod tests { /// [`raf`] with the raw IFD's `0xf006` flag set to `words32`. pub(crate) fn raf_flagged(w: u32, h: u32, bits: u32, strip: Vec, layout: Option<[u8; 36]>, jpeg: &[u8], words32: u32) -> Vec { + raf_full(w, h, bits, strip, layout, jpeg, words32, "TEST", &[]) + } + + /// [`raf_flagged`] with the camera name in the header and extra raw-IFD entries of signed rationals. + #[allow(clippy::too_many_arguments)] + pub(crate) fn raf_full( + w: u32, + h: u32, + bits: u32, + strip: Vec, + layout: Option<[u8; 36]>, + jpeg: &[u8], + words32: u32, + model: &str, + extra: &[(u16, Vec<(i32, i32)>)], + ) -> Vec { let mut block = b"II*\0\x08\0\0\0".to_vec(); let sub_off: u32 = 8 + 2 + 12 + 4; block.extend_from_slice(&1u16.to_le_bytes()); block.extend_from_slice(&[0x00, 0xf0, 13, 0, 1, 0, 0, 0]); block.extend_from_slice(&sub_off.to_le_bytes()); block.extend_from_slice(&0u32.to_le_bytes()); - let n = 8u32; + let n = 8u32 + extra.len() as u32; let wb_off = sub_off + 2 + 12 * n + 4; - let strip_off = wb_off + 12; - let entries: [(u16, u32, u32); 8] = [ - (0xf001, 1, w), - (0xf002, 1, h), - (0xf003, 1, bits), - (0xf006, 1, words32), - (0xf007, 1, strip_off), - (0xf008, 1, strip.len() as u32), - (0xf00a, 1, 64), - (0xf00e, 3, wb_off), + // out-of-line data after the white balance: each extra table in turn, then the strip + let mut next = wb_off + 12; + let mut table_at = Vec::new(); + for (_, v) in extra { + table_at.push(next); + next += 8 * v.len() as u32; + } + let strip_off = next; + // (tag, type, count, value or offset), in tag order + let mut entries: Vec<(u16, u16, u32, u32)> = vec![ + (0xf001, 4, 1, w), + (0xf002, 4, 1, h), + (0xf003, 4, 1, bits), + (0xf006, 4, 1, words32), + (0xf007, 4, 1, strip_off), + (0xf008, 4, 1, strip.len() as u32), + (0xf00a, 4, 1, 64), + (0xf00e, 4, 3, wb_off), ]; + for ((tag, v), at) in extra.iter().zip(&table_at) { + entries.push((*tag, 10, v.len() as u32, *at)); + } + entries.sort_by_key(|e| e.0); block.extend_from_slice(&(n as u16).to_le_bytes()); - for (tag, count, v) in entries { + for (tag, ty, count, v) in entries { block.extend_from_slice(&tag.to_le_bytes()); - block.extend_from_slice(&4u16.to_le_bytes()); + block.extend_from_slice(&ty.to_le_bytes()); block.extend_from_slice(&count.to_le_bytes()); block.extend_from_slice(&v.to_le_bytes()); } @@ -253,6 +288,12 @@ pub(super) mod tests { for v in [300u32, 600, 450] { block.extend_from_slice(&v.to_le_bytes()); } + for (_, v) in extra { + for (num, den) in v { + block.extend_from_slice(&num.to_le_bytes()); + block.extend_from_slice(&den.to_le_bytes()); + } + } block.extend_from_slice(&strip); let mut recs: Vec = Vec::new(); let mut nrec = 0u32; @@ -270,7 +311,8 @@ pub(super) mod tests { } let mut dir = nrec.to_be_bytes().to_vec(); dir.extend_from_slice(&recs); - let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + let mut out = b"FUJIFILMCCD-RAW 0201FF000000".to_vec(); + out.extend_from_slice(model.as_bytes()); out.resize(108, 0); if strip.starts_with(b"IS") { let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 }; @@ -374,6 +416,46 @@ pub(super) mod tests { assert_eq!(decode(raf_flagged(w, h, 12, pack(&px, 12), None, b"", 0)), RawData::U16(px)); } + /// The lens tables of the raw IFD reach `Mode::Header` and `Mode::Full` alike, only for validated cameras, + /// and survive a DNG round trip. The frame is 12 × 4 after the crop records (4, 2). + #[test] + fn lens_tables_become_opcodes_for_validated_cameras() { + let (w, h) = (16u32, 6u32); + let half = 12.0f64.hypot(4.0) / 2.0; + let rat = |v: &[f64]| -> Vec<(i32, i32)> { v.iter().map(|x| ((x * 1.0e6).round() as i32, 1_000_000)).collect() }; + let knots = [0.3535211268, 0.5, 0.6126760563, 0.7070422535, 0.7908450704, 0.8661971831, 0.9352112676, 1.0, 1.06056338]; + let table = |values: &[f64]| { + let mut v = vec![half / 9.0]; + v.extend_from_slice(&knots); + v.extend_from_slice(values); + rat(&v) + }; + let dist = [-1.2, -2.4, -3.5, -4.4, -5.3, -5.9, -6.3, -6.4, -6.4]; + let vig = [98.5, 96.9, 94.9, 91.0, 84.7, 78.4, 73.0, 67.5, 67.5]; + // the colour-plane table is present in real files and ignored + let ca = [-0.00003, -0.00009, -0.00015, -0.0002, -0.00027, -0.00034, -0.00046, -0.0006, -0.0006]; + let extra = vec![(0xf00b, table(&dist)), (0xf00f, table(&ca)), (0xf010, table(&vig))]; + for (model, expected) in [("X-T4", 2), ("GFX 100", 2), ("X-T6", 0), ("X-T4 II", 0), ("", 0)] { + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, model, &extra); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(header.info(), full.info(), "{model}"); + assert_eq!(full.opcodes.list3.len(), expected, "{model}"); + let Some(crate::Opcode::WarpRectilinear { planes, center }) = full.opcodes.list3.first() else { continue }; + assert_eq!((planes.len(), *center), (1, [0.5, 0.5]), "{model}"); + assert!(matches!(full.opcodes.list3[1], crate::Opcode::FixVignetteRadial { .. })); + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3, "{model}"); + } + // a table in another scale is left alone, and so is a file without tables + let wrong: Vec<(u16, Vec<(i32, i32)>)> = + extra.iter().map(|(t, v)| (*t, v.iter().map(|&(n, d)| if n == v[0].0 { (n * 2, d) } else { (n, d) }).collect())).collect(); + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, "X-T4", &wrong); + assert!(crate::decode(&bytes).unwrap().opcodes.list3.iter().all(|o| !matches!(o, crate::Opcode::WarpRectilinear { .. }))); + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, "X-T4", &[]); + assert!(crate::decode(&bytes).unwrap().opcodes.list3.is_empty()); + } + #[test] fn overflowing_row_stride_is_an_error() { let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]); diff --git a/crates/raw/src/vendor/raf_lens.rs b/crates/raw/src/vendor/raf_lens.rs new file mode 100644 index 000000000..93b668e2f --- /dev/null +++ b/crates/raw/src/vendor/raf_lens.rs @@ -0,0 +1,452 @@ +//! Fujifilm's per-shot lens correction tables from the RAF raw IFD, expressed as DNG opcodes for the shared optics +//! pipeline (the same path as Sony `arw_lens` and Panasonic `rw2`). +//! +//! Tag identity: the ExifTool FujiFilm tag-name documentation names `0xf00b` GeometricDistortionParams and +//! `0xf010` VignettingParams (signed rationals). Layout, units, radius normalisation and geometry were derived from +//! the CC0 samples and the cameras' own JPEGs; see docs/fuji-lens-corrections.md. No lens database and no external +//! decoder code. The lateral chromatic aberration table (`0xf00f`) is deliberately not applied: LightCraft's +//! demosaic already registers the colour planes, so the stored shift over-corrects (see the document). +//! +//! Layout (`n` knots): `[scale, knot × n, value × n]`. `scale × n` is the half diagonal of the recorded image in +//! pixels. Knots are positions along the radius, the last one being the farthest +//! corner; they are evenly spaced in `r` (`0, 0.1 … 1`) or in `r²` (`√(k/8)`, last `√(9/8)`) depending on the body. +//! +//! - distortion: percent, the bracket `1 + D/100` multiplies the radius; the camera's JPEG samples the raw at +//! `source = z · r · (1 + D(ρ(source))/100)`, `ρ = knot_last · radius / half diagonal`, with the zoom `z` that +//! keeps the output rectangle inside the source (`1 / max` of the bracket over the frame's border radii). +//! - vignetting: relative illumination in percent at the source radius; the correction is the gain `100 / V`. + +use crate::{Opcode, Rect}; +use lightcraft_tiff::Ifd; + +const DISTORTION: u16 = 0xf00b; +const VIGNETTING: u16 = 0xf010; +const SAMPLES: usize = 1024; +/// Largest allowed deviation of the warp polynomial from the table, in half diagonals (median over the 118 sample +/// tables 0.00012, largest 0.0007; about 1 px at 1440 px). +const MAX_WARP_ERROR: f64 = 0.0008; +/// Largest allowed relative deviation of the gain polynomial from the table (median 0.4 %, largest 1.9 %). +const MAX_GAIN_ERROR: f64 = 0.025; +/// Reweighting rounds of the minimax polynomial fit. +const FIT_ROUNDS: usize = 40; +/// `scale × n` must be the recorded image's half diagonal to this relative tolerance (other normalisations are not +/// understood). +const SCALE_TOLERANCE: f64 = 0.005; + +/// Camera models whose tables were checked against the camera's own JPEG (distortion within 0.01 of the best free +/// radial fit's correlation on at least one lens, see docs/fuji-lens-corrections.md). Other bodies stay uncorrected. +const VALIDATED: &[&str] = &[ + "FinePix F770EXR", + "FinePix HS30EXR", + "FinePix HS33EXR", + "FinePix HS50EXR", + "FinePix X100", + "GFX 100", + "GFX 50R", + "GFX 50S", + "GFX100 II", + "GFX100RF", + "GFX100S", + "GFX100S II", + "GFX50S II", + "X-A1", + "X-A10", + "X-A2", + "X-A3", + "X-A5", + "X-A7", + "X-E1", + "X-E2", + "X-E2S", + "X-E3", + "X-E4", + "X-E5", + "X-H1", + "X-H2", + "X-H2S", + "X-M1", + "X-M5", + "X-Pro1", + "X-Pro2", + "X-Pro3", + "X-S1", + "X-S10", + "X-S20", + "X-T1", + "X-T10", + "X-T100", + "X-T2", + "X-T20", + "X-T200", + "X-T3", + "X-T30", + "X-T30 II", + "X-T30 III", + "X-T4", + "X-T5", + "X-T50", + "X100F", + "X100S", + "X100T", + "X100V", + "X100VI", + "X20", + "X30", + "X70", + "XF10", + "XQ2", +]; + +/// One parsed table: knot positions and the value array of the same length. +struct Table { + knots: Vec, + values: Vec, +} + +/// Read and sanity-check a table. `half_diagonal` is the recorded image's half diagonal in pixels, which +/// `scale × n` must reproduce. +fn read(ifd: &Ifd, tag: u16, half_diagonal: f64) -> Option { + let v = ifd.f64s(tag)?; + if v.is_empty() || v.iter().any(|x| !x.is_finite()) { + return None; + } + let n = (v.len() - 1) / 2; + if !(3..=40).contains(&n) || v.len() != 1 + 2 * n { + return None; + } + let scale = v[0]; + if scale <= 0.0 || (scale * n as f64 / half_diagonal - 1.0).abs() > SCALE_TOLERANCE { + return None; + } + let knots = v[1..1 + n].to_vec(); + if knots[0] < 0.0 || knots.windows(2).any(|w| w[1] <= w[0]) || !(0.5..=2.0).contains(&knots[n - 1]) { + return None; + } + Some(Table { knots, values: v[1 + n..].to_vec() }) +} + +impl Table { + /// Value `which` at knot position `x`: linear between knots, flat outside, anchored at `x = 0` to `at_zero` + /// when the first knot lies above it (distortion is 0 and illumination 100 % on the axis). + fn at(&self, x: f64, at_zero: Option) -> f64 { + let (k, y) = (&self.knots, &self.values); + if let (Some(z), true) = (at_zero, k[0] > 0.0) + && x < k[0] + { + return z + (y[0] - z) * (x / k[0]).max(0.0); + } + if x <= k[0] { + return y[0]; + } + match k.iter().position(|&p| p >= x) { + None => y[y.len() - 1], + Some(i) => y[i - 1] + (y[i] - y[i - 1]) * (x - k[i - 1]) / (k[i] - k[i - 1]), + } + } + fn last(&self) -> f64 { + self.knots[self.knots.len() - 1] + } +} + +/// Source radius of the output radius `r` (both in half diagonals) with the distortion zoom. +struct Geometry<'a> { + table: Option<&'a Table>, + zoom: f64, +} + +impl Geometry<'_> { + fn bracket(&self, source: f64) -> f64 { + self.table.map_or(1.0, |t| 1.0 + t.at(source * t.last(), Some(0.0)) / 100.0) + } + fn source(&self, r: f64) -> f64 { + let mut s = self.zoom * r; + for _ in 0..24 { + s = self.zoom * r * self.bracket(s); + } + s + } +} + +/// The frame-filling zoom: the largest radial scale for which every border point of the `w × h` output samples +/// inside the source (the bracket is largest at the nearest border radius or beyond). +fn zoom(table: &Table, nearest: f64) -> Option { + let mut g = Geometry { table: Some(table), zoom: 1.0 }; + for _ in 0..8 { + let m = (0..=512).map(|i| nearest + (1.0 - nearest) * i as f64 / 512.0).map(|r| g.bracket(g.source(r))).fold(f64::MIN, f64::max); + if !(0.5..2.0).contains(&m) { + return None; + } + g.zoom = 1.0 / m; + } + Some(g.zoom) +} + +/// Fit `target(r) ≈ Σ c_i basis_i(r)` on `r ∈ [0, 1]` minimising the largest deviation (iteratively reweighted least +/// squares), returning the coefficients and that deviation. +fn minimax(basis: impl Fn(f64) -> [f64; N], target: impl Fn(f64) -> f64) -> Option<([f64; N], f64)> { + let rows: Vec<([f64; N], f64)> = (0..=SAMPLES).map(|j| j as f64 / SAMPLES as f64).map(|r| (basis(r), target(r))).collect(); + let mut weight = vec![1.0; rows.len()]; + let mut best: Option<([f64; N], f64)> = None; + for _ in 0..FIT_ROUNDS { + let mut matrix = [[0.0; N]; N]; + let mut rhs = [0.0; N]; + for ((terms, y), w) in rows.iter().zip(&weight) { + for (i, &a) in terms.iter().enumerate() { + rhs[i] += w * a * y; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += w * a * b; + } + } + } + let c = solve(matrix, rhs)?; + let errors: Vec = rows.iter().map(|(terms, y)| (terms.iter().zip(c).map(|(a, b)| a * b).sum::() - y).abs()).collect(); + let worst = errors.iter().copied().fold(0.0, f64::max); + if best.as_ref().is_none_or(|b| worst < b.1) { + best = Some((c, worst)); + } + let mean = weight.iter().sum::() / weight.len() as f64; + for (w, e) in weight.iter_mut().zip(&errors) { + *w *= 0.3 + e / worst.max(1e-15); + *w /= mean; + } + } + best +} + +/// `source(r) = r (k0 + k1 r² + k2 r⁴ + k3 r⁶)`, rejected when it strays from the table or folds. +fn polynomial(source: impl Fn(f64) -> f64) -> Option<[f64; 4]> { + let mut previous = -1.0; + for j in 0..=SAMPLES { + let s = source(j as f64 / SAMPLES as f64); + if !s.is_finite() || s <= previous { + return None; + } + previous = s; + } + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let (k, error) = minimax(basis, &source)?; + let folds = (0..=SAMPLES).any(|j| { + let r2 = (j as f64 / SAMPLES as f64).powi(2); + k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])) <= 0.0 + }); + (error <= MAX_WARP_ERROR && !folds).then_some(k) +} + +/// Gaussian elimination with partial pivoting for the small normal equations. +fn solve(mut a: [[f64; N]; N], mut b: [f64; N]) -> Option<[f64; N]> { + for col in 0..N { + let pivot = (col..N).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in col + 1..N { + let factor = a[row][col] / a[col][col]; + for j in col..N { + a[row][j] -= factor * a[col][j]; + } + b[row] -= factor * b[col]; + } + } + let mut x = [0.0; N]; + for i in (0..N).rev() { + x[i] = (b[i] - (i + 1..N).map(|j| a[i][j] * x[j]).sum::()) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// The warp (distortion with the frame-filling zoom) for a recorded image of `w × h` pixels. +fn warp(ifd: &Ifd, w: usize, h: usize) -> Option { + let half = (w as f64).hypot(h as f64) / 2.0; + let table = read(ifd, DISTORTION, half)?; + // Percent: anything beyond a third is not distortion; an all-zero table has nothing to correct. + if table.values.iter().any(|d| d.abs() > 30.0) || table.values.iter().all(|d| *d == 0.0) { + return None; + } + let (cw, ch) = (w as f64 - 1.0, h as f64 - 1.0); + let nearest = cw.min(ch) / cw.hypot(ch); + let g = Geometry { table: Some(&table), zoom: zoom(&table, nearest)? }; + let k = polynomial(|r| g.source(r))?; + // The recorded image is the active area and the tables are centred on it: DNG normalises by the farthest + // corner, which is the half diagonal used here. + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) +} + +/// Radial illumination gain `100 / V` as DNG `FixVignetteRadial` (`1 + k0 r² + … + k4 r¹⁰`). +fn vignette(ifd: &Ifd, w: usize, h: usize) -> Option { + let half = (w as f64).hypot(h as f64) / 2.0; + let t = read(ifd, VIGNETTING, half)?; + if t.values.iter().any(|v| !(10.0..=130.0).contains(v)) { + return None; + } + let gain = |r: f64| 100.0 / t.at(r * t.last(), Some(100.0)); + if (0..=SAMPLES).all(|j| (gain(j as f64 / SAMPLES as f64) - 1.0).abs() < 1e-4) { + return None; + } + let basis = |r: f64| { + let x = r * r; + [x, x * x, x.powi(3), x.powi(4), x.powi(5)] + }; + let (k, error) = minimax(basis, |r| gain(r) - 1.0)?; + // the relative error at the worst point (gain is at least 1 / 1.3 here) + if error > MAX_GAIN_ERROR || (0..=SAMPLES).any(|j| 1.0 + basis(j as f64 / SAMPLES as f64).iter().zip(k).map(|(a, b)| a * b).sum::() < 0.5) { + return None; + } + Some(Opcode::FixVignetteRadial { k, center: [0.5, 0.5] }) +} + +/// The lens corrections of a RAF whose camera `model` was validated (anything else, and any table that is not in +/// the understood layout and scale, is left uncorrected). `active` is the recorded image (the raw crop). +pub(super) fn corrections(model: &str, raw: &Ifd, active: Rect) -> Vec { + if !VALIDATED.contains(&model.trim()) || active.width < 2 || active.height < 2 { + return Vec::new(); + } + warp(raw, active.width, active.height).into_iter().chain(vignette(raw, active.width, active.height)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, Value}; + + pub(crate) const KNOTS9: [f64; 9] = [0.3535211268, 0.5, 0.6126760563, 0.7070422535, 0.7908450704, 0.8661971831, 0.9352112676, 1.0, 1.06056338]; + /// X-T4 with the XF16-55 at 16 mm (3913): the tables as ExifTool prints them. + pub(crate) const XT4_DIST: [f64; 9] = + [-1.172515869, -2.354660034, -3.458114624, -4.434677124, -5.274719238, -5.918792725, -6.328369141, -6.426345825, -6.426345825]; + pub(crate) const XT4_VIG: [f64; 9] = + [98.46142578, 96.87792969, 94.90234375, 90.98144531, 84.73291016, 78.43701172, 72.96044922, 67.45703125, 67.45703125]; + pub(crate) const XT4_AREA: Rect = Rect { x: 0, y: 0, width: 6240, height: 4160 }; + + pub(crate) fn rationals(v: &[f64]) -> Value { + Value::SRational(v.iter().map(|x| ((x * 1.0e6).round() as i32, 1_000_000)).collect()) + } + pub(crate) fn table(scale: f64, knots: &[f64], values: &[f64]) -> Value { + let mut v = vec![scale]; + v.extend_from_slice(knots); + v.extend_from_slice(values); + rationals(&v) + } + pub(crate) fn xt4() -> Ifd { + let scale = 3749.0 / 9.0; + Ifd { + entries: vec![ + Entry { tag: DISTORTION, value: table(scale, &KNOTS9, &XT4_DIST), offset: 0 }, + Entry { tag: VIGNETTING, value: table(scale, &KNOTS9, &XT4_VIG), offset: 0 }, + ], + ..Default::default() + } + } + fn only(tag: u16, value: Value) -> Ifd { + Ifd { entries: vec![Entry { tag, value, offset: 0 }], ..Default::default() } + } + fn evaluate(k: &[f64; 6], r: f64) -> f64 { + r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))) + } + + #[test] + fn x_t4_tables_become_a_warp_and_a_vignette_gain() { + let ops = corrections("X-T4", &xt4(), XT4_AREA); + let [Opcode::WarpRectilinear { planes, center }, Opcode::FixVignetteRadial { k, center: vc }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!((*center, *vc), ([0.5, 0.5], [0.5, 0.5])); + assert_eq!(planes.len(), 1, "no colour planes: lateral CA is not applied"); + // barrel distortion: zoomed to fill the frame (measured against the camera JPEG: 1.030..1.033), the + // corner samples inside the raw (-6.4 % bracket) + let green = &planes[0]; + assert!((1.02..1.04).contains(&green[0]), "{}", green[0]); + let corner = evaluate(green, 1.0); + assert!((corner - 0.9668).abs() < 0.002, "{corner}"); + // vignette: 67.457 % at the corner, 100 % on the axis + let gain = |r: f64| 1.0 + (0..5).map(|i| k[i] * r.powi(2 * i as i32 + 2)).sum::(); + assert!((gain(1.0) - 100.0 / 67.45703125).abs() < 0.01, "{}", gain(1.0)); + assert!((gain(0.0) - 1.0).abs() < 1e-12); + assert!((gain(0.5) - 100.0 / (96.87792969 + (94.90234375 - 96.87792969) * (0.5 * 1.06056338 - 0.5) / (0.6126760563 - 0.5))).abs() < 0.01); + } + + #[test] + fn matches_the_measured_x_t4_geometry() { + // Free radial fit (data/testing/raf-lens/tools/fit_model.py) of the camera JPEG against LightCraft's uncorrected + // render of pixls 3913 at 600 px (maximum correlation 0.996 of the high-passed luminance), no table or zoom + // model: source = s · r · (1 + k1 r² + k2 r⁴ + k3 r⁶), r in half diagonals. + let measured = [1.0299688, -0.1024252, 0.0195257, 0.0183398]; + let ops = corrections("X-T4", &xt4(), XT4_AREA); + let Opcode::WarpRectilinear { planes, .. } = &ops[0] else { panic!() }; + for i in 2..=20 { + let r = i as f64 / 20.0; + let r2 = r * r; + let reference = measured[0] * r * (1.0 + measured[1] * r2 + measured[2] * r2 * r2 + measured[3] * r2 * r2 * r2); + assert!((evaluate(&planes[0], r) - reference).abs() < 0.004, "{r}: {} vs {reference}", evaluate(&planes[0], r)); + } + } + + #[test] + fn only_validated_models_are_corrected() { + for model in ["", "X-T4 ", "X-T6", "X-Pro3", "FinePix S1", "FinePix F550EXR", "X10", "XF1", "XQ1", "GFX 100S", "x-t4"] { + let n = corrections(model, &xt4(), XT4_AREA).len(); + assert_eq!(n, usize::from(VALIDATED.contains(&model.trim())) * 2, "{model:?}"); + } + assert!(corrections("X-T4", &xt4(), Rect::new(0, 0, 1, 1)).is_empty()); + } + + #[test] + fn rejects_unknown_scale_layout_and_values() { + let scale = 3749.0 / 9.0; + let bad = |ifd: Ifd| corrections("X-T4", &ifd, XT4_AREA); + // another normalisation (X-S20 stores the 40 MP half diagonal), non-increasing knots, wrong lengths, garbage + assert!(bad(only(DISTORTION, table(4643.0 / 9.0, &KNOTS9, &XT4_DIST))).is_empty()); + let mut flat = KNOTS9; + flat[3] = flat[2]; + assert!(bad(only(DISTORTION, table(scale, &flat, &XT4_DIST))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9[..8], &XT4_DIST))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9, &[40.0; 9]))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9, &[f64::NAN; 9]))).is_empty()); + assert!(bad(only(DISTORTION, Value::Short(vec![19; 19]))).is_empty()); + assert!(bad(only(DISTORTION, table(0.0, &KNOTS9, &XT4_DIST))).is_empty()); + assert!(bad(only(VIGNETTING, table(scale, &KNOTS9, &[5.0; 9]))).is_empty()); + // flat tables (nothing to correct) produce nothing + let flat_tables = Ifd { + entries: vec![ + Entry { tag: DISTORTION, value: table(scale, &KNOTS9, &[0.0; 9]), offset: 0 }, + Entry { tag: VIGNETTING, value: table(scale, &KNOTS9, &[100.0; 9]), offset: 0 }, + ], + ..Default::default() + }; + assert!(bad(flat_tables).is_empty()); + assert!(bad(Ifd::default()).is_empty()); + // an unusable distortion table leaves the warp off but not the vignette + let mut ifd = xt4(); + ifd.entries[0].value = table(1.0, &KNOTS9, &XT4_DIST); + let ops = bad(ifd); + assert!(matches!(ops.as_slice(), [Opcode::FixVignetteRadial { .. }]), "{ops:?}"); + } + + #[test] + fn old_layout_with_eleven_knots_and_pincushion_zoom() { + // X-T2-style: 0 … 1 in tenths, 11 knots, scale = half diagonal / 11; a 3:2 6000 × 4000 frame. + let knots: Vec = (0..=10).map(|i| i as f64 / 10.0).collect(); + let dist = [0.0, 0.02, 0.05, 0.1, 0.17, 0.27, 0.39, 0.54, 0.69, 0.84, 0.98]; + let scale = 3605.0 / 11.0; + let ifd = only(DISTORTION, table(scale, &knots, &dist)); + let area = Rect::new(0, 0, 6000, 4000); + let ops = corrections("X-T2", &ifd, area); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + // pincushion bracket > 1 everywhere: the zoom shrinks the sampled radius; the corner samples exactly its edge + assert!(planes[0][0] < 1.0 && planes[0][0] > 0.98); + let nearest = 5999.0f64.min(3999.0) / 5999.0f64.hypot(3999.0); + let at_edge = evaluate(&planes[0], nearest) / nearest; + assert!((at_edge - planes[0][0]).abs() < 0.004); + } + + #[test] + fn a_vignette_table_works_without_a_distortion_table() { + let scale = 3749.0 / 9.0; + let v = corrections("X-T4", &only(VIGNETTING, table(scale, &KNOTS9, &XT4_VIG)), XT4_AREA); + assert!(matches!(v.as_slice(), [Opcode::FixVignetteRadial { .. }])); + let d = corrections("X-T4", &only(DISTORTION, table(scale, &KNOTS9, &XT4_DIST)), XT4_AREA); + assert!(matches!(d.as_slice(), [Opcode::WarpRectilinear { .. }])); + } +} diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 7fe6d0e42..0a64c9e44 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -122,7 +122,7 @@ const RAW_FORMAT: u16 = 0x002d; /// The in-camera aspect-ratio crop: top, left, bottom, right in sensor coordinates. const CROP: [u16; 4] = [0x002f, 0x0030, 0x0031, 0x0032]; const ISO_32: u16 = 0x0037; -const RAW_OFFSET: u16 = 0x0118; +pub(crate) const RAW_OFFSET: u16 = 0x0118; const HUFFMAN_CODES: u16 = 0x0040; const STRIP_OFFSETS: u16 = 0x0044; const STRIP_LEFT: u16 = 0x0045; diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs index 28bf60ed1..212533add 100644 --- a/crates/raw/src/vendor/sr2.rs +++ b/crates/raw/src/vendor/sr2.rs @@ -33,9 +33,17 @@ //! (8 bytes at position 2510, issue #148, from the plain black levels of the DSC-RX100M6 and ILCE-7M3) and the //! positional tables the black level was read with until issue #535; the unit tests pin all of them. //! -//! Only that key is supported. The one other key seen, in a DSC-R1 SR2 file, gives a different keystream that obeys -//! the same recurrence from its own first 127 words, but one sample per key cannot show how a seed follows from a -//! key, so files with another key get `None`. +//! +//! **Keys other than `11 22 33 44`: the seed derivation is a recalled scheme (generic memory, no code copied), +//! confirmed because it reproduces [`SEED`] — the keystream #575 measured from 126 CC0 files — exactly, and because +//! it turns the DSC-R1's block (another key) into a valid directory.** [`seed_from_key`] takes the key as the +//! little-endian `u32` of tag `0x7221`, fills 128 state words from the linear congruential generator +//! `k ← k·48828125 + 1` (four words, the fourth then shifted left one bit with the top bit of `w0 ^ w2` shifted in), +//! extends them with `w[i] = (w[i−4] ⊕ w[i−2]) << 1 | (w[i−3] ⊕ w[i−1]) >> 31` to word 126, and the first 127 words of +//! `w[i & 127] = w[(i+1) & 127] ⊕ w[(i+65) & 127]` from `i = 127` are the seed of the recurrence above. For key +//! `11 22 33 44` that is [`SEED`], word for word (unit test `seed_derivation_reproduces_the_measured_seed`; chance +//! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry +//! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; @@ -67,10 +75,42 @@ const SEED: [u32; 127] = [ 0xcdc6a2de, 0x26e6b330, 0x6c073d46, 0x058e2f27, 0x43833f30, 0x46d1382e, 0x5f0804ec, ]; -/// XOR `block` with the keystream from its first byte on (whole 4-byte words; a shorter tail stays as it is). -/// Encrypts and decrypts alike. +/// The seed (first 127 keystream words) for `key`, the little-endian `u32` of tag `0x7221`; see the module docs. +fn seed_from_key(key: u32) -> [u32; 127] { + let mut p = [0u32; 128]; + let mut k = key; + for w in p.iter_mut().take(4) { + k = k.wrapping_mul(48_828_125).wrapping_add(1); + *w = k; + } + p[3] = (p[3] << 1) | ((p[0] ^ p[2]) >> 31); + for i in 4..127 { + p[i] = ((p[i - 4] ^ p[i - 2]) << 1) | ((p[i - 3] ^ p[i - 1]) >> 31); + } + let mut seed = [0u32; 127]; + for (j, out) in seed.iter_mut().enumerate() { + let i = 127 + j; + p[i & 127] = p[(i + 1) & 127] ^ p[(i + 65) & 127]; + *out = p[i & 127]; + } + seed +} + +/// The seed for the four key bytes stored in the file: the measured [`SEED`] for [`KEY`], else the derived one. +fn seed_for(key: [u8; 4]) -> [u32; 127] { + if key == KEY { SEED } else { seed_from_key(u32::from_le_bytes(key)) } +} + +/// XOR `block` with the keystream of [`KEY`] from its first byte on (whole 4-byte words; a shorter tail stays as +/// it is). Encrypts and decrypts alike. +#[cfg(test)] fn apply_keystream(block: &mut [u8]) { - let mut ring = SEED; + apply_keystream_with(block, SEED); +} + +/// [`apply_keystream`] for the keystream starting with `seed`. +fn apply_keystream_with(block: &mut [u8], seed: [u32; 127]) { + let mut ring = seed; for (n, word) in block.as_chunks_mut::<4>().0.iter_mut().enumerate() { // `ring[i]` holds k[n − 127] until it is replaced by k[n]; k[n − 63] sits at (n − 63) mod 127 let i = n % SEED.len(); @@ -93,26 +133,30 @@ pub(crate) struct SubIfd { impl SubIfd { /// Find and decrypt the file's `SR2SubIFD`; `None` when it has none, it lies outside the file, is larger than - /// [`MAX_LEN`] or uses another key than [`KEY`]. + /// [`MAX_LEN`]. pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; - if private.bytes(SUBIFD_KEY)? != KEY { - return None; - } + let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?; let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; if len > MAX_LEN { return None; } - Some(Self::decrypt(bytes.get(start..start.checked_add(len)?)?, start, order)) + Some(Self::decrypt_keyed(bytes.get(start..start.checked_add(len)?)?, start, order, key)) } /// Decrypt `block`, the encrypted directory found at file offset `start`. + #[cfg(test)] pub(crate) fn decrypt(block: &[u8], start: usize, order: ByteOrder) -> Self { + Self::decrypt_keyed(block, start, order, KEY) + } + + /// [`SubIfd::decrypt`] for a block stored with `key` (tag `0x7221`). + pub(crate) fn decrypt_keyed(block: &[u8], start: usize, order: ByteOrder, key: [u8; 4]) -> Self { let mut plain = block.to_vec(); - apply_keystream(&mut plain); + apply_keystream_with(&mut plain, seed_for(key)); Self { start, plain, order } } @@ -182,6 +226,14 @@ pub(crate) mod tests { plain } + /// [`block`] encrypted with another `key`. + pub(crate) fn block_keyed(start: usize, entries: &[(u16, u16, &[u16])], order: ByteOrder, key: [u8; 4]) -> Vec { + let mut b = block(start, entries, order); + apply_keystream(&mut b); // undo the default key + apply_keystream_with(&mut b, seed_for(key)); + b + } + /// Encrypt (or decrypt) `block` in place, as a file stores an `SR2SubIFD` at its first byte. pub(crate) fn encrypt(block: &mut [u8]) { apply_keystream(block); @@ -236,6 +288,32 @@ pub(crate) mod tests { } } + /// The proof of the recalled scheme: derived from key `11 22 33 44`, the seed is exactly the keystream #575 + /// measured from the CC0 files. + #[test] + fn seed_derivation_reproduces_the_measured_seed() { + assert_eq!(seed_from_key(u32::from_le_bytes(KEY)), SEED); + assert_ne!(seed_from_key(u32::from_le_bytes(KEY)), seed_from_key(u32::from_le_bytes(KEY) ^ 1)); + assert_eq!(seed_for(KEY), SEED); + } + + #[test] + fn another_key_decrypts_a_block() { + // the DSC-R1's key and layout: black 0x7300, WB 0x7303 (G, R, B, G), crop 0x74c3 + let key = [0x63, 0xbe, 0x01, 0x3e]; + let entries: [(u16, u16, &[u16]); 3] = + [(0x7300, SHORT, &[514, 511, 512, 513]), (0x7303, SHORT, &[256, 500, 385, 256]), (0x74c3, SHORT, &[16, 8, 3904, 2600])]; + for order in [Little, Big] { + let enc = block_keyed(230812, &entries, order, key); + let sub = SubIfd::decrypt_keyed(&enc, 230812, order, key); + assert_eq!(sub.shorts(0x7300), Some(vec![514.0, 511.0, 512.0, 513.0]), "{order:?}"); + assert_eq!(sub.shorts(0x7303), Some(vec![256.0, 500.0, 385.0, 256.0])); + assert_eq!(sub.shorts(0x74c3), Some(vec![16.0, 8.0, 3904.0, 2600.0])); + // the wrong key (or the default one) gives noise + assert_eq!(SubIfd::decrypt(&enc, 230812, order).shorts(0x7300), None); + } + } + #[test] fn keystream_follows_its_recurrence() { let mut zeros = vec![0u8; 62114]; diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 8649dd703..5ca4777b7 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -445,6 +445,44 @@ fn corpus_sony_a7cr_codings() { eprintln!("ILCE-7CR codings checked on {} files", decoded.len()); } +/// Sony ILCE-7CM2 (33 MP, issue #697), two of the camera's codings on raw.pixls.us: lossless compressed L (LJ92 tiles +/// of 2×2 cells) and compressed "M-size" (ARW2). Only its lossless M and S codings are the downsized linear YCbCr +/// whose 1024 pedestal 0.5.0 missed (rendering the issue's file washed out: the darkest half of a dusk scene +/// +11 L* and blue); the compressed and uncompressed M-size files are Bayer at 4736×3132 with the recorded black +/// 512, so a size-based rule would break them. Expected values: the files' own colour-filter layout, black and +/// white levels, default crop and the as-shot gains of the camera's colour-temperature setting. +#[test] +fn corpus_sony_a7cm2_codings() { + let dir = corpus_root().join("raw"); + // (file, sensor width and height, as-shot R and B gains, default crop x, y, width, height) + let cases = [ + ("arw-sony-a7cm2-lossless-l.arw", (7168, 5120), [2.313, 1.654], (12, 8, 7008, 4672)), + ("arw-sony-a7cm2-compressed-m.arw", (4736, 3132), [2.309, 1.657], (44, 30, 4608, 3072)), + ]; + let mut checked = 0; + for (name, (width, height), [r, b], crop) in cases { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), img.info(), "{name}: header and full decode agree"); + assert_eq!(img.metadata.model.as_deref(), Some("ILCE-7CM2"), "{name}: model"); + assert_eq!((img.width, img.height, img.cpp), (width, height, 1), "{name}: sensor size"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"), "{name}: colour-filter layout"); + assert_eq!((img.black.mean(), img.white_at(0)), (512.0, 16383.0), "{name}: black and white levels"); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}"); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), crop, "{name}: default crop"); + checked += 1; + } + eprintln!("ILCE-7CM2 codings checked on {checked} files"); +} + /// Issue #535: Sony black levels against the data. The SR2SubIFD keeps the black level at a position that depends /// on its layout (the DSLR-A500 and A700 read 365 and 975 from a fixed position, where ExifTool and the data say /// 512), and the downsized lossless M and S codings (linear YCbCr) sit 512 above the recorded level (1024, with @@ -462,6 +500,9 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { ("arw-sony-a7m4-lossless-l.arw", 512.0, Some(16383.0)), ("arw-sony-a7m4-lossless-m.arw", 1024.0, Some(16895.0)), ("arw-sony-a7m4-lossless-s.arw", 1024.0, Some(16895.0)), + // ILCE-7CM2 (#697): its compressed "M-size" coding is Bayer at the recorded 512, unlike its lossless M + ("arw-sony-a7cm2-lossless-l.arw", 512.0, Some(16383.0)), + ("arw-sony-a7cm2-compressed-m.arw", 512.0, Some(16383.0)), ]; let mut seen = 0; for (name, black, white) in cases { @@ -861,6 +902,39 @@ fn corpus_fujifilm_compressed_samples() { eprintln!("verified {seen} Fujifilm compressed sensor arrays"); } +/// Checks each available `(name, width, height, sum, weighted)` case against the decoder's sensor array (the file's +/// identity is checked by `cargo xtask corpus --download`); absent corpus files are skipped. Returns how many were verified. +fn verify_sensor_arrays(cases: &[(&str, usize, usize, u64, u64)]) -> usize { + let dir = corpus_root().join("raw"); + let mut seen = 0; + for &(name, width, height, sum, weighted) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height), (width, height), "{name}"); + let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") }; + let actual = data + .iter() + .enumerate() + .fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v))))); + assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference"); + seen += 1; + } + seen +} + +/// Regression values from LightCraft's decoder with the derived companding curve; the 12-bit codes before the curve +/// were verified against the black-box oracle in Phase 0 (docs/arw6-compression.md). The files are CC0 from RawDB, one +/// per geometry and compression mode. +#[test] +fn corpus_sony_arw6_samples() { + let cases: &[(&str, usize, usize, u64, u64)] = &[ + ("arw6-sony-ilce7rm6-apsc-land-hq.arw", 6592, 4372, 85546367117, 1319631546150087499), + ("arw6-sony-ilce7rm6-ff-land-c.arw", 10016, 6672, 257718215153, 9636375794063042132), + ]; + let seen = verify_sensor_arrays(cases); + eprintln!("verified {seen} Sony ARW6 sensor arrays"); +} + /// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real /// raws: every corpus file keeps the format its maker implies, and none is described as a private block. #[test] @@ -915,6 +989,18 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0), ("arw-sony-rx100.arw", "DSC-RX100", 0), ("arw-sony-rx100m3.arw", "DSC-RX100M3", 0), + ("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR", 0), + ("arw-sony-a7cr-lossless-m.arw", "ILCE-7CR", 0), + ("arw-sony-a7cr-compressed.arw", "ILCE-7CR", 0), + ("arw-sony-a7rm4-14bit-compressed.arw", "ILCE-7RM4", 0), + ("arw-sony-a500.arw", "DSLR-A500", 0), + ("arw-sony-a700.arw", "DSLR-A700", 0), + ("arw-sony-a33.arw", "SLT-A33", 0), + ("arw-sony-a3500-5600k.arw", "ILCE-3500", 0), + ("arw-sony-a7s-shade.arw", "ILCE-7S", 0), + ("arw-sony-a7sm2-16x9.arw", "ILCE-7SM2", 0), + ("arw-sony-a580-16x9.arw", "DSLR-A580", 0), + ("arw-sony-a900-packed12.arw", "DSLR-A900", 0), ] { let path = root.join(name); if !path.exists() { @@ -931,3 +1017,119 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { } eprintln!("Sony distortion model boundary: {checked} corpus files checked"); } + +/// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the +/// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the +/// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. +#[test] +fn corpus_kodak_dcs520c() { + let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; + let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; + let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { + eprintln!("skip: DCS520C sample absent"); + return; + }; + assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}")); + assert_eq!((img.width, img.height, img.bits), (1736, 1160, 12)); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("GRBG")); + assert_eq!(img.white_at(0), 4095.0); + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + assert_eq!(d.len(), 1736 * 1160); + // stored 191..=622 expands to 191..=4095 + assert_eq!((d.iter().min().copied(), d.iter().max().copied()), (Some(191), Some(4095))); + // black: G and R about 220, B about 197 (the darkest 0.01% of each position) + let b = &img.black.values; + assert_eq!(b.len(), 4); + assert!( + (200.0..=235.0).contains(&b[0]) && (200.0..=235.0).contains(&b[1]) && (185.0..=210.0).contains(&b[2]) && (200.0..=235.0).contains(&b[3]), + "{b:?}" + ); + let wb = img.wb_multipliers.expect("white balance"); + assert!((wb[0] - 0.9587).abs() < 1e-3 && wb[1] == 1.0 && (wb[2] - 1.9913).abs() < 1e-3, "{wb:?}"); + // no column stripes: the two greens sit in different JPEG components (G1 on even columns, G2 on odd columns), so a component mismatch would split them + let mean = |y0: usize, x0: usize| { + let (mut s, mut n) = (0f64, 0f64); + for y in (y0..1160).step_by(2) { + for x in (x0..1736).step_by(2) { + s += f64::from(d[y * 1736 + x]); + n += 1.0; + } + } + s / n + }; + let (g1, g2) = (mean(0, 0), mean(1, 1)); + assert!((g1 - g2).abs() < 0.01 * g1, "greens {g1} vs {g2}"); +} + +/// Fujifilm RAF lens tables (`0xf00b` distortion, `0xf010` vignetting) become a warp and a vignette gain for the +/// cameras whose geometry was checked against the camera JPEG (docs/fuji-lens-corrections.md). Reads `LIGHTCRAFT_FUJI` (a folder +/// of RAFs or of per-model folders, e.g. the CC0 pixls set) or the corpus' `raf-fuji-*` files; skips when absent. +#[test] +fn fuji_embedded_lens_tables_follow_the_validated_models() { + fn collect(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() && depth < 2 { + collect(&p, depth + 1, out); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("raf")) { + out.push(p); + } + } + } + let mut files = Vec::new(); + match std::env::var_os("LIGHTCRAFT_FUJI") { + Some(dir) => collect(Path::new(&dir), 0, &mut files), + None => { + collect(&corpus_root().join("raw"), 1, &mut files); + files.retain(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("raf-fuji-"))); + } + } + files.sort(); + // Bodies whose tables did not match the camera JPEG (or have no usable geometry sample): never corrected. + let unvalidated = ["FinePix F550EXR", "FinePix S1", "FinePix SL1000", "X10", "XF1", "XQ1"]; + let (mut warped, mut vignetted, mut checked) = (0, 0, 0); + for path in &files { + let Ok(bytes) = std::fs::read(path) else { continue }; + if lightcraft_raw::probe(&bytes) != Some(RawFormat::Raf) { + continue; + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Ok(full) = decode(&bytes) else { continue }; + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}: header and full decode agree"); + let model = full.metadata.model.clone().unwrap_or_default(); + let ops = &full.opcodes.list3; + if unvalidated.contains(&model.as_str()) { + assert!(ops.is_empty(), "{name} ({model}) is not a validated model"); + } + for op in ops { + match op { + lightcraft_raw::Opcode::WarpRectilinear { planes, center } => { + assert_eq!(*center, [0.5, 0.5], "{name}"); + assert_eq!(planes.len(), 1, "{name}: lateral CA is not applied"); + // a sane radial mapping: close to 1 at the centre, monotone, never folding + let k = planes[0]; + assert!((0.85..1.15).contains(&k[0]), "{name}: {k:?}"); + let source = |r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + assert!((0..100).all(|i| source((i + 1) as f64 / 100.0) > source(i as f64 / 100.0)), "{name}"); + warped += 1; + } + lightcraft_raw::Opcode::FixVignetteRadial { k, center } => { + assert_eq!(*center, [0.5, 0.5], "{name}"); + let gain = 1.0 + k.iter().enumerate().map(|(i, c)| c * 1.0f64.powi(2 * i as i32 + 2)).sum::(); + assert!((1.0..3.0).contains(&gain), "{name}: corner gain {gain}"); + vignetted += 1; + } + other => panic!("{name}: unexpected opcode {other:?}"), + } + } + eprintln!( + "{model}: {} opcode(s) {}", + ops.len(), + if ops.iter().any(|o| matches!(o, lightcraft_raw::Opcode::WarpRectilinear { .. })) { "warp" } else { "no warp" } + ); + checked += 1; + } + eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains"); +} diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index 6cd28c3b2..ebf669307 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -16,6 +16,10 @@ fn corpus() -> PathBuf { } fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize)) { + check_against(name, hash, sensor, Some(developed), "the independent reference"); +} + +fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { eprintln!("skip: {} absent", path.display()); @@ -25,12 +29,14 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize assert_eq!(raw.format, RawFormat::Cr3); raw.validate().unwrap(); assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1)); - assert_eq!(raw.info().developed_size(), developed); + if let Some(developed) = developed { + assert_eq!(raw.info().developed_size(), developed); + } assert_eq!(raw.info(), probe_info(&bytes).unwrap()); let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; let actual = samples.iter().flat_map(|v| v.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); - assert_eq!(actual, hash, "{name}: sensor pixels differ from the independent reference"); + assert_eq!(actual, hash, "{name}: sensor pixels differ from {reference}"); assert!(raw.wb_multipliers.is_some(), "{name}: as-shot white balance missing"); assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); } @@ -79,6 +85,33 @@ fn canon_r8_craw_sensor_is_exact() { check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120), (6000, 4000)); } +/// EOS R5 Mark II (raw.pixls.us 7881–7884, CC0). No external decoder was run on these: the hashes pin +/// LightCraft's own decode. Lossless RAW is CRX 0x100, as in the oracle-checked fixtures above, with +/// odd-height (2825- and 1787-row) Bayer planes; every entropy stream decodes to its exact end. These +/// low-ISO C-RAW files need no QP-map escape (most of the camera's files do; that is covered by the unit +/// tests in `crx.rs`, see docs/cr3.md). The developed images match the camera's own previews. +fn check_r5m2(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>) { + check_against(name, hash, sensor, developed, "the pinned LightCraft decode"); +} + +#[test] +fn canon_r5m2_sensor_is_pinned() { + // raw.pixls.us/getfile.php/7881; SHA256 d6e6aa3b493ea8936dff0efb0cc17c87471437e7317bce37c8c82ba08b9cab7b + check_r5m2("cr3-canon-r5m2-raw.cr3", 0xff71_6fb3_0248_496f, (8480, 5650), Some((8192, 5464))); + // raw.pixls.us/getfile.php/7882; SHA256 b3c08a5c7a97299dfd58896a12b939e7b43ec78682761812524825e6e5d087be + check_r5m2("cr3-canon-r5m2-craw.cr3", 0xaf32_b453_57b5_34e5, (8480, 5650), Some((8192, 5464))); +} + +/// Crop mode (1.6×): the sensor samples only. Its developed framing depends on how the file's misplaced valid +/// area is read, which is not part of the CRX decode. +#[test] +fn canon_r5m2_crop_mode_sensor_is_pinned() { + // raw.pixls.us/getfile.php/7883; SHA256 9f88c0ff012924193b7f4497559d133dfe626766d600b5d67689156721739ead + check_r5m2("cr3-canon-r5m2-apsc-raw.cr3", 0x31cf_630a_fe86_8958, (5376, 3574), None); + // raw.pixls.us/getfile.php/7884; SHA256 6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7 + check_r5m2("cr3-canon-r5m2-apsc-craw.cr3", 0x4721_605b_2033_9332, (5376, 3574), None); +} + #[test] fn canon_m50_craw_crop_is_the_shot_aspect() { // raw.pixls.us/getfile.php/2663: the 1:1 square starts 1000 px right of the recommended crop's origin, diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs new file mode 100644 index 000000000..3ab735299 --- /dev/null +++ b/crates/raw/tests/sparse_corpus.rs @@ -0,0 +1,108 @@ +//! [`lightcraft_raw::sparse::header_plan`] over `corpus/raw/**` (git-ignored CC0 samples from +//! raw.pixls.us, fetched with `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the +//! corpus root). Skips cleanly when absent. +//! +//! For every file the plan reaches `Ready` on, probing the partly loaded buffer must give exactly +//! what probing the whole file gives — with the skipped bytes zero and again with them 0xFF, so a +//! reader that looked at skipped data would be caught. CR3 and DNG files must be described from a +//! small part of the file (`cargo test -p lightcraft-raw --release --test sparse_corpus -- --nocapture` +//! prints what each format reads). + +use lightcraft_raw::sparse::{HeaderPlan, Ranges, header_plan, preview_plan}; +use lightcraft_raw::{embedded_preview, probe, probe_info}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn corpus_root() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Run the plan (with the embedded preview when `preview`), loading from `file` what it asks for +/// into a buffer whose other bytes are `fill`. +fn run(file: &[u8], fill: u8, preview: bool) -> (HeaderPlan, Vec, Ranges) { + let mut buf = vec![fill; file.len()]; + let mut loaded = Ranges::new(); + let prefix = file.len().min(512 * 1024); + buf[..prefix].copy_from_slice(&file[..prefix]); + loaded.insert(0, prefix as u64); + for _ in 0..32 { + match if preview { preview_plan(&buf, &loaded) } else { header_plan(&buf, &loaded) } { + HeaderPlan::Need(r) => { + for &(a, b) in r.spans() { + assert!(!loaded.overlaps(a, b), "asked again for {a}..{b}"); + buf[a as usize..b as usize].copy_from_slice(&file[a as usize..b as usize]); + loaded.insert(a, b); + } + } + done => return (done, buf, loaded), + } + } + panic!("no answer after 32 rounds"); +} + +/// What an import learns from a file: the raw description and the metadata. +fn describe(bytes: &[u8]) -> String { + let e = lightcraft_meta::embedded(bytes); + format!("{:?}\n{:?}\n{:?}", probe_info(bytes), lightcraft_meta::extract(bytes), e.xmp) +} + +#[test] +fn partial_reads_describe_raws_as_whole_files_do() { + let dir = corpus_root().join("raw"); + let mut paths: Vec = std::fs::read_dir(corpus_root().join("leica")) + .into_iter() + .chain(std::fs::read_dir(&dir)) + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) + .collect(); + if paths.is_empty() { + eprintln!("skip: {} absent", dir.display()); + return; + } + paths.sort(); + // format → (files ready, bytes read, bytes total, files read whole) + let mut stats: BTreeMap = BTreeMap::new(); + for p in paths { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let file = std::fs::read(&p).unwrap(); + let format = probe(&file).map_or("?".to_string(), |f| format!("{f:?}")); + let (plan, zeros, loaded) = run(&file, 0, false); + let entry = stats.entry(format.clone()).or_default(); + match plan { + HeaderPlan::Ready => { + let (plan_ff, ones, loaded_ff) = run(&file, 0xff, false); + assert_eq!((plan_ff, &loaded_ff), (HeaderPlan::Ready, &loaded), "{name}: the plan read skipped bytes"); + let whole = describe(&file); + assert_eq!(describe(&zeros), whole, "{name}: differs from the whole file (skipped bytes 0)"); + assert_eq!(describe(&ones), whole, "{name}: differs from the whole file (skipped bytes 0xFF)"); + entry.0 += 1; + entry.1 += loaded.total(); + entry.2 += file.len() as u64; + // with the preview (for a raw shown from it), the preview is what the whole file gives too + let preview = embedded_preview(&file); + for fill in [0, 0xff] { + let (plan, buf, _) = run(&file, fill, true); + assert_eq!(plan, HeaderPlan::Ready, "{name}: preview plan"); + assert_eq!(describe(&buf), whole, "{name}: preview plan, skipped bytes {fill}"); + assert!(embedded_preview(&buf) == preview, "{name}: a different embedded preview (skipped bytes {fill})"); + } + if loaded.total() * 4 > file.len() as u64 { + eprintln!("{name}: read {:.0} %", loaded.total() as f64 * 100.0 / file.len() as f64); + } + if matches!(format.as_str(), "Cr3" | "Dng") && file.len() > 8 << 20 { + assert!(loaded.total() * 4 < file.len() as u64, "{name}: read {} of {} bytes", loaded.total(), file.len()); + } + } + HeaderPlan::Whole(why) => { + entry.3 += 1; + eprintln!("{name}: whole ({why})"); + } + HeaderPlan::Need(_) => unreachable!(), + } + } + for (format, (ready, read, total, whole)) in stats { + eprintln!("{format:>10}: {ready} described from {:.1} % of their bytes, {whole} read whole", read as f64 * 100.0 / total.max(1) as f64); + } +} diff --git a/crates/raw/tests/vendor_robust.rs b/crates/raw/tests/vendor_robust.rs index 81a751c87..cb79c87da 100644 --- a/crates/raw/tests/vendor_robust.rs +++ b/crates/raw/tests/vendor_robust.rs @@ -232,6 +232,7 @@ fn samples() -> Vec> { cfa_tiff("NIKON CORPORATION", 34713, 14, ByteOrder::Big), cfa_tiff("SONY", 32767, 12, ByteOrder::Little), cfa_tiff("SONY", 1, 14, ByteOrder::Little), + cfa_tiff("SONY", 32766, 14, ByteOrder::Little), pef(1), pef(65535), cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little), @@ -282,7 +283,7 @@ proptest! { #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })] #[test] - fn mutated_vendor_files_never_panic(kind in 0usize..17, flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { + fn mutated_vendor_files_never_panic(kind in 0usize..18, flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { let mut data = samples().swap_remove(kind); let n = data.len(); for (i, v) in flips { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ed7549c32..484752fb6 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Kanal {} zurücksetzen", "Restore {}": "{} wiederherstellen", "Restore {} Photo{}": "{} Foto{} wiederherstellen", + "Restored {} photo{} from Recently Deleted": "{} Foto{} aus „Zuletzt gelöscht“ wiederhergestellt", "Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).", "Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — Fotos dieser Person anzeigen", "· {dups} already in the library": "· {dups} bereits in der Bibliothek", "· {n} photo{}": "· {n} Foto{}", + "· {trashed} in Recently Deleted": "· {trashed} in „Zuletzt gelöscht“", "… and {more} more": "… und {more} weitere", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", "Filtered: {label}": "Gefiltert: {label}", @@ -229,6 +231,9 @@ "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)", + "New Folder in “{name}”": "Neuer Ordner in „{name}“", + "Moved {} photo(s)": "{} Foto(s) verschoben", "Applied {name}": "{name} angewendet", "Delete “{name}”?": "„{name}“ löschen?", "Updated {name}": "{name} aktualisiert", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 78b215d7e..f0cdf9cc5 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -581,6 +581,7 @@ "Friday": "Freitag", "Full": "Vollständig", "Full Screen Preview": "Vollbildvorschau", + "Lights Out": "Licht aus", "Full screen mode": "Vollbildmodus", "Full size": "Volle Größe", "GPS Track Log": "GPS-Track", @@ -660,6 +661,7 @@ "History & Activity (Y)": "Verlauf und Aktivität (Y)", "Home": "Persönlicher Ordner", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Horizontaler Abstand (% der kurzen Kante)", "Horizontal text": "Horizontaler Text", "Hours": "Stunden", "Hue": "Farbton", @@ -796,6 +798,7 @@ "List Presets": "Vorgaben auflisten", "List Profiles": "Profile auflisten", "List this folder in Local from now on": "Diesen Ordner ab jetzt unter „Lokal“ auflisten", + "Loading preview…": "Vorschau wird geladen…", "Local": "Lokal", "Local folders": "Lokale Ordner", "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", @@ -1076,6 +1079,7 @@ "Preview Build Progress": "Fortschritt der Vorschauerstellung", "Preview only": "Nur Vorschau", "Preview size": "Vorschaugröße", + "Preview unavailable": "Vorschau nicht verfügbar", "Previews": "Vorschauen", "Previous Photo": "Vorheriges Foto", "Print": "Drucken", @@ -1570,6 +1574,7 @@ "Versions": "Versionen", "Versions (Shift+V)": "Versionen (Umschalt+V)", "Vertical": "Vertikal", + "Vertical inset (% of short edge)": "Vertikaler Abstand (% der kurzen Kante)", "Vertical text": "Vertikaler Text", "Vibrance": "Dynamik", "Videos": "Videos", @@ -2025,6 +2030,19 @@ "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Appearance": "Erscheinungsbild", + "Appearance Mode": "Erscheinungsmodus", + "Sync with System": "Mit System synchronisieren", + "Light Mode": "Heller Modus", + "Dark Mode": "Dunkler Modus", + "Next Appearance Mode": "Nächster Erscheinungsmodus", + "Light Theme": "Helles Design", + "Dark Theme": "Dunkles Design", + "In Use": "In Verwendung", + "Charcoal": "Anthrazit", + "Midnight": "Mitternacht", + "Silver": "Silber", + "Paper": "Papier", "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", @@ -2040,6 +2058,53 @@ "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", "Looking for changes": "Änderungen werden gesucht", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek. Stattdessen in eine LightCraft-Bibliothek importieren?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Der Import liest den Katalog, ohne ihn zu ändern, und fügt seine Fotos, Bearbeitungen, Stichwörter und Sammlungen der jetzt geöffneten Bibliothek hinzu.", + "Albums hold library photos: Add to My Photos first": "Alben enthalten Bibliotheksfotos: zuerst zu „Meine Fotos“ hinzufügen", + "Add to My Photos First": "Zuerst zu „Meine Fotos“ hinzufügen", + "Fill Location from GPS": "Ort aus GPS ausfüllen", + "Download Place Names (GeoNames)": "Ortsnamen herunterladen (GeoNames)", + "Place Names Status": "Status der Ortsnamen", + "Fill Location": "Ort ausfüllen", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Leere Felder Stadt, Bundesland und Land aus GPS ausfüllen (GeoNames-Ortsnamen, offline)", + "Looking up places": "Orte werden nachgeschlagen", + "Downloading place names": "Ortsnamen werden heruntergeladen", + "Place names downloaded": "Ortsnamen heruntergeladen", + "Place names download cancelled": "Download der Ortsnamen abgebrochen", + "Other…": "Andere…", + "Intent:": "Priorität:", + "Perceptual": "Perzeptiv", + "Relative": "Relativ", + "Simulate Paper & Ink": "Papier und Druckfarbe simulieren", + "Content-Aware": "Inhaltsbasiert", + "imported earlier switch over with Photo ▸ Reload from Disk.": "Bereits importierte Fotos wechseln mit Foto → Vom Datenträger neu laden zur RAW-Bearbeitung.", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "C-RAW-Dateien der Canon EOS R5 Mark II, die als „Nur Vorschau“ geöffnet wurden (je nach Motiv mehr als die Hälfte),", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "werden jetzt wie die übrigen RAW-Dateien der Kamera aus ihren Sensordaten mit allen Werkzeugen bearbeitet.", + "Sidebar Sections": "Seitenleistenbereiche", + "Reset Sidebar Sections": "Seitenleistenbereiche zurücksetzen", + "Hide This Section": "Diesen Bereich ausblenden", + "No folders to browse": "Keine Ordner zum Durchsuchen", + "No photos yet": "Noch keine Fotos", + "No imported folders yet": "Noch keine importierten Ordner", + "Show Sidebar Section": "Seitenleistenbereich anzeigen", + "Move Sidebar Section": "Seitenleistenbereich verschieben", + "New Library…": "Neue Bibliothek…", + "Open Recent Library": "Zuletzt verwendete Bibliothek öffnen", + "Recent Libraries": "Zuletzt verwendete Bibliotheken", + "Clear Recent Libraries": "Liste leeren", + "No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken", + "New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen", + "New Library": "Neue Bibliothek", + "Library name": "Name der Bibliothek", + "New Folder on Disk": "Neuer Ordner auf dem Datenträger", + "New Folder on Disk…": "Neuer Ordner auf dem Datenträger…", + "Move to Folder": "In Ordner verschieben", + "Move to Folder…": "In Ordner verschieben…", + "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", + "Appearance Settings…": "Erscheinungsbild-Einstellungen…", + "Add to favorites": "Zu Favoriten hinzufügen", + "Remove from favorites": "Aus Favoriten entfernen", "Playback": "Wiedergabe", "Layout": "Layout", "Link all margins": "Alle Ränder verknüpfen", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 167288981..b55db9157 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Restablecer canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{}", + "Restored {} photo{} from Recently Deleted": "{0} foto{1} restaurada{1} desde Eliminadas recientemente", "Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).", "Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{}: mostrar sus fotos", "· {dups} already in the library": "· ya en la biblioteca: {dups}", "· {n} photo{}": "· {n} foto{}", + "· {trashed} in Recently Deleted": "· {trashed} en Eliminadas recientemente", "… and {more} more": "… y {more} más", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.", "Filtered: {label}": "Filtrado: {label}", @@ -229,6 +231,9 @@ "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", "{what} merge cancelled": "Combinación {what} cancelada", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)", + "New Folder in “{name}”": "Nueva carpeta en «{name}»", + "Moved {} photo(s)": "{} foto(s) movida(s)", "Applied {name}": "Se aplicó {name}", "Delete “{name}”?": "¿Eliminar «{name}»?", "Updated {name}": "Se actualizó {name}", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e826a8393..a72618dff 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -548,6 +548,7 @@ "Friday": "Viernes", "Full": "Completa", "Full Screen Preview": "Vista previa a pantalla completa", + "Lights Out": "Luces apagadas", "Full screen mode": "Modo de pantalla completa", "Full size": "Tamaño completo", "GPS Track Log": "Registro de ruta GPS", @@ -627,6 +628,7 @@ "History & Activity (Y)": "Historial y actividad (Y)", "Home": "Carpeta personal", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Margen horizontal (% del lado corto)", "Horizontal text": "Texto horizontal", "Hours": "Horas", "How to install the model": "Cómo instalar el modelo", @@ -757,6 +759,7 @@ "List Presets": "Enumerar ajustes preestablecidos", "List Profiles": "Enumerar perfiles", "List this folder in Local from now on": "Mostrar esta carpeta en Local a partir de ahora", + "Loading preview…": "Cargando vista previa…", "Local": "Local", "Local folders": "Carpetas locales", "Local library — no cloud account needed": "Biblioteca local: no requiere cuenta en la nube", @@ -1029,6 +1032,7 @@ "Preview Build Progress": "Progreso de generación de vistas previas", "Preview only": "Solo vista previa", "Preview size": "Tamaño de la vista previa", + "Preview unavailable": "Vista previa no disponible", "Previews": "Vistas previas", "Previous Photo": "Foto anterior", "Print": "Imprimir", @@ -1509,6 +1513,7 @@ "Versions": "Versiones", "Versions (Shift+V)": "Versiones (Mayús+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Margen vertical (% del lado corto)", "Vertical text": "Texto vertical", "Vibrance": "Intensidad", "Videos": "Vídeos", @@ -1870,6 +1875,19 @@ "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", "Wait for the import to finish": "Espera a que termine la importación", + "Appearance": "Apariencia", + "Appearance Mode": "Modo de apariencia", + "Sync with System": "Sincronizar con el sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo oscuro", + "Next Appearance Mode": "Siguiente modo de apariencia", + "Light Theme": "Tema claro", + "Dark Theme": "Tema oscuro", + "In Use": "En uso", + "Charcoal": "Carbón", + "Midnight": "Medianoche", + "Silver": "Plata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", @@ -1885,6 +1903,50 @@ "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", "Looking for changes": "Buscando cambios", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora.", + "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añádelas primero a Mis fotos", + "Add to My Photos First": "Añadir primero a Mis fotos", + "Fill Location from GPS": "Rellenar ubicación desde GPS", + "Download Place Names (GeoNames)": "Descargar nombres de lugares (GeoNames)", + "Place Names Status": "Estado de los nombres de lugares", + "Fill Location": "Rellenar ubicación", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Rellenar Ciudad, Estado y País vacíos desde el GPS (nombres de lugares de GeoNames, sin conexión)", + "Looking up places": "Buscando lugares", + "Downloading place names": "Descargando nombres de lugares", + "Place names downloaded": "Nombres de lugares descargados", + "Place names download cancelled": "Descarga de nombres de lugares cancelada", + "Other…": "Otro…", + "Intent:": "Propósito:", + "Perceptual": "Perceptual", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simular papel y tinta", + "Content-Aware": "Según el contenido", + "Sidebar Sections": "Secciones de la barra lateral", + "Reset Sidebar Sections": "Restablecer secciones de la barra lateral", + "Hide This Section": "Ocultar esta sección", + "No folders to browse": "No hay carpetas para explorar", + "No photos yet": "Aún no hay fotos", + "No imported folders yet": "Aún no hay carpetas importadas", + "Show Sidebar Section": "Mostrar sección de la barra lateral", + "Move Sidebar Section": "Mover sección de la barra lateral", + "New Library…": "Nueva biblioteca…", + "Open Recent Library": "Abrir biblioteca reciente", + "Recent Libraries": "Bibliotecas recientes", + "Clear Recent Libraries": "Borrar bibliotecas recientes", + "No Recent Libraries": "No hay bibliotecas recientes", + "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla", + "New Library": "Nueva biblioteca", + "Library name": "Nombre de la biblioteca", + "New Folder on Disk": "Nueva carpeta en el disco", + "New Folder on Disk…": "Nueva carpeta en el disco…", + "Move to Folder": "Mover a carpeta", + "Move to Folder…": "Mover a carpeta…", + "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", + "Appearance Settings…": "Ajustes de apariencia…", + "Add to favorites": "Añadir a favoritos", + "Remove from favorites": "Quitar de favoritos", "Playback": "Reproducción", "Layout": "Diseño", "Link all margins": "Vincular todos los márgenes", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index f5c7823fa..a350f4caa 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Réinitialiser le canal {}", "Restore {}": "Restaurer {}", "Restore {} Photo{}": "Restaurer {} photo{}", + "Restored {} photo{} from Recently Deleted": "{} photo{} restaurée(s) depuis « Supprimés récemment »", "Saved export preset “{}”": "Préréglage d’exportation « {} » enregistré", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Évalue la mise au point et l’exposition de {scope}, puis repère les prises similaires réalisées à quelques secondes d’intervalle (rafales).", "Show {hidden} hidden location{}": "Afficher {hidden} emplacement{} masqué(s)", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — afficher leurs photos", "· {dups} already in the library": "· déjà dans la bibliothèque : {dups}", "· {n} photo{}": "· {n} photo{}", + "· {trashed} in Recently Deleted": "· {trashed} dans « Supprimés récemment »", "… and {more} more": "… et {more} de plus", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ne peut pas encore décoder cette variante RAW ({}). Vous modifiez l’aperçu JPEG intégré de l’appareil photo, qui inclut déjà le style d’image (par ex. monochrome) et la balance des blancs de l’appareil.", "Filtered: {label}": "Filtré : {label}", @@ -229,6 +231,9 @@ "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", "{what} merge cancelled": "Fusion {what} annulée", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)", + "New Folder in “{name}”": "Nouveau dossier dans « {name} »", + "Moved {} photo(s)": "{} photo(s) déplacée(s)", "Applied {name}": "{name} appliqué", "Delete “{name}”?": "Supprimer « {name} » ?", "Updated {name}": "{name} mis à jour", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index dece0397c..ea3edc8d1 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -545,6 +545,7 @@ "Friday": "Vendredi", "Full": "Complet", "Full Screen Preview": "Aperçu en plein écran", + "Lights Out": "Lumières éteintes", "Full screen mode": "Mode plein écran", "Full size": "Taille complète", "GPS Track Log": "Trace GPS", @@ -624,6 +625,7 @@ "History & Activity (Y)": "Historique et activité (Y)", "Home": "Dossier personnel", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Marge horizontale (% du petit côté)", "Horizontal text": "Texte horizontal", "Hours": "Heures", "How to install the model": "Comment installer le modèle", @@ -754,6 +756,7 @@ "List Presets": "Lister les préréglages", "List Profiles": "Lister les profils", "List this folder in Local from now on": "Afficher désormais ce dossier dans Local", + "Loading preview…": "Chargement de l'aperçu…", "Local": "Local", "Local folders": "Dossiers locaux", "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", @@ -1024,6 +1027,7 @@ "Preview Build Progress": "Progression de la génération des aperçus", "Preview only": "Aperçu uniquement", "Preview size": "Taille de l'aperçu", + "Preview unavailable": "Aperçu indisponible", "Previews": "Aperçus", "Previous Photo": "Photo précédente", "Print": "Imprimer", @@ -1499,6 +1503,7 @@ "Versions": "Versions", "Versions (Shift+V)": "Versions (Maj+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Marge verticale (% du petit côté)", "Vertical text": "Texte vertical", "Vibrance": "Vibrance", "Videos": "Vidéos", @@ -1885,6 +1890,63 @@ "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", "Resize panels": "Resize panels", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte.", + "Albums hold library photos: Add to My Photos first": "Les albums contiennent des photos de la bibliothèque : ajoutez-les d’abord à Mes photos", + "Add to My Photos First": "Ajouter d’abord à Mes photos", + "Fill Location from GPS": "Remplir le lieu depuis le GPS", + "Download Place Names (GeoNames)": "Télécharger les noms de lieux (GeoNames)", + "Place Names Status": "État des noms de lieux", + "Fill Location": "Remplir le lieu", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Remplir Ville, État et Pays vides depuis le GPS (noms de lieux GeoNames, hors ligne)", + "Looking up places": "Recherche des lieux", + "Downloading place names": "Téléchargement des noms de lieux", + "Place names downloaded": "Noms de lieux téléchargés", + "Place names download cancelled": "Téléchargement des noms de lieux annulé", + "Other…": "Autre…", + "Intent:": "Mode de rendu :", + "Perceptual": "Perceptif", + "Relative": "Relatif", + "Simulate Paper & Ink": "Simuler le papier et l'encre", + "Content-Aware": "Contenu pris en compte", + "Sidebar Sections": "Sections de la barre latérale", + "Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale", + "Hide This Section": "Masquer cette section", + "No folders to browse": "Aucun dossier à parcourir", + "No photos yet": "Aucune photo pour l’instant", + "No imported folders yet": "Aucun dossier importé pour l’instant", + "Show Sidebar Section": "Afficher la section de la barre latérale", + "Move Sidebar Section": "Déplacer la section de la barre latérale", + "New Library…": "Nouvelle bibliothèque…", + "Open Recent Library": "Ouvrir une bibliothèque récente", + "Recent Libraries": "Bibliothèques récentes", + "Clear Recent Libraries": "Effacer les bibliothèques récentes", + "No Recent Libraries": "Aucune bibliothèque récente", + "New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement", + "New Library": "Nouvelle bibliothèque", + "Library name": "Nom de la bibliothèque", + "New Folder on Disk": "Nouveau dossier sur le disque", + "New Folder on Disk…": "Nouveau dossier sur le disque…", + "Move to Folder": "Déplacer vers un dossier", + "Move to Folder…": "Déplacer vers un dossier…", + "Move Selected Photos Here": "Déplacer ici les photos sélectionnées", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien", + "Appearance Settings…": "Réglages d’apparence…", + "Add to favorites": "Ajouter aux favoris", + "Remove from favorites": "Retirer des favoris", + "Appearance": "Apparence", + "Appearance Mode": "Mode d’apparence", + "Sync with System": "Synchroniser avec le système", + "Light Mode": "Mode clair", + "Dark Mode": "Mode sombre", + "Next Appearance Mode": "Mode d’apparence suivant", + "Light Theme": "Thème clair", + "Dark Theme": "Thème sombre", + "In Use": "Utilisé", + "Charcoal": "Anthracite", + "Midnight": "Minuit", + "Silver": "Argent", + "Paper": "Papier", "Playback": "Lecture", "Layout": "Disposition", "Link all margins": "Lier toutes les marges", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e40560762..817592216 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -61,6 +61,7 @@ "Reset {} Channel": "{}チャンネルをリセット", "Restore {}": "{} に戻す", "Restore {} Photo{}": "{}枚の写真を復元{:.0}", + "Restored {} photo{} from Recently Deleted": "最近削除した写真から{}枚を復元しました{:.0}", "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", "Showing {n} of {}": "{}枚中{n}枚を表示", @@ -105,6 +106,7 @@ "{} spot(s) on this photo": "この写真に{}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", "· {n} photo{}": "・{n}枚{:.0}", + "· {trashed} in Recently Deleted": "・最近削除した写真内 {trashed} 枚", "… and {more} more": "…ほか{more}件", "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", @@ -229,6 +231,9 @@ "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}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)", + "New Folder in “{name}”": "「{name}」に新規フォルダ", + "Moved {} photo(s)": "{} 枚の写真を移動しました", "Applied {name}": "{name} を適用しました", "Delete “{name}”?": "「{name}」を削除しますか?", "Updated {name}": "{name} を更新しました", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 62ce87d27..4a4ded888 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -510,6 +510,7 @@ "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", "Home": "ホーム", + "Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)", "Hours": "時間", "ISO Speed": "ISO感度", "ISO speed": "ISO感度", @@ -564,6 +565,7 @@ "List History": "履歴を一覧表示", "List Presets": "プリセットを一覧表示", "List Profiles": "プロファイルを一覧表示", + "Loading preview…": "プレビューを読み込み中…", "Locate Photo": "写真の場所を指定", "Lock": "ロック", "Lock Crop Aspect": "切り抜きの縦横比を固定", @@ -645,6 +647,7 @@ "Press keys…": "キーを押してください…", "Preview Build Progress": "プレビュー作成の進行状況", "Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中", + "Preview unavailable": "プレビューを表示できません", "Previous Photo": "前の写真", "Profile Browser": "プロファイルブラウザー", "Profile Menu": "プロファイルメニュー", @@ -736,6 +739,7 @@ "Free": "自由", "Full": "全体", "Full Screen Preview": "全画面プレビュー", + "Lights Out": "消灯モード", "Full size": "元のサイズ", "GPS Track Log": "GPS軌跡ファイル", "General": "一般", @@ -1429,6 +1433,7 @@ "Versions": "バージョン", "Versions (Shift+V)": "バージョン(⇧V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直方向の余白(短辺に対する%)", "Vibrance": "自然な彩度", "Videos": "動画", "View": "表示", @@ -1872,6 +1877,19 @@ "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Appearance": "外観", + "Appearance Mode": "外観モード", + "Sync with System": "システムと同期", + "Light Mode": "ライトモード", + "Dark Mode": "ダークモード", + "Next Appearance Mode": "次の外観モード", + "Light Theme": "ライトテーマ", + "Dark Theme": "ダークテーマ", + "In Use": "使用中", + "Charcoal": "チャコール", + "Midnight": "ミッドナイト", + "Silver": "シルバー", + "Paper": "ペーパー", "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", @@ -1887,6 +1905,50 @@ "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", "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": "アルバムに入れられるのはライブラリの写真です:先にマイフォトに追加してください", + "Add to My Photos First": "先にマイフォトに追加", + "Fill Location from GPS": "GPS から場所を入力", + "Download Place Names (GeoNames)": "地名データをダウンロード (GeoNames)", + "Place Names Status": "地名データの状態", + "Fill Location": "場所を入力", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "空の市区町村・都道府県・国を GPS から入力 (GeoNames の地名、オフライン)", + "Looking up places": "場所を検索中", + "Downloading place names": "地名データをダウンロード中", + "Place names downloaded": "地名データをダウンロードしました", + "Place names download cancelled": "地名データのダウンロードを中止しました", + "Other…": "その他…", + "Intent:": "マッチング方法:", + "Perceptual": "知覚的", + "Relative": "相対的", + "Simulate Paper & Ink": "紙とインキをシミュレート", + "Content-Aware": "コンテンツに応じる", + "Sidebar Sections": "サイドバーのセクション", + "Reset Sidebar Sections": "サイドバーのセクションをリセット", + "Hide This Section": "このセクションを隠す", + "No folders to browse": "参照できるフォルダがありません", + "No photos yet": "写真はまだありません", + "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 Folder on Disk": "ディスクに新規フォルダ", + "New Folder on Disk…": "ディスクに新規フォルダ…", + "Move to Folder": "フォルダへ移動", + "Move to Folder…": "フォルダへ移動…", + "Move Selected Photos Here": "選択した写真をここへ移動", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません", + "Appearance Settings…": "外観設定…", + "Add to favorites": "お気に入りに追加", + "Remove from favorites": "お気に入りから削除", "Playback": "再生", "Layout": "レイアウト", "Link all margins": "すべての余白をリンク", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 6b5b32988..90df29fbc 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -82,6 +82,7 @@ "Reset {} Channel": "Redefinir canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Restauradas {} foto{:.0} de Excluídos recentemente", "Saved export preset “{}”": "Predefinição de exportação “{}” salva", "Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto", "Showing {n} of {}": "Mostrando {n} de {}", @@ -133,6 +134,7 @@ "{} spot(s) on this photo": "{} ponto(s) nesta foto", "· {dups} already in the library": "· {dups} já na biblioteca", "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} em Excluídos recentemente", "… and {more} more": "… e mais {more}", " ({unsure} with several look-alike files: use Locate)": " ({unsure} com vários arquivos parecidos: use Localizar)", " · {:.0}% clipped": " · {:.0}% recortado", @@ -229,6 +231,9 @@ "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", "{what} merge cancelled": "{what} mesclagem cancelada", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)", + "New Folder in “{name}”": "Nova pasta em “{name}”", + "Moved {} photo(s)": "{} foto(s) movida(s)", "Applied {name}": "{name} aplicado", "Delete “{name}”?": "Excluir “{name}”?", "Updated {name}": "{name} atualizado", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9329b2d23..3fd78ad42 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -554,6 +554,7 @@ "Friday": "Sexta-feira", "Full": "Completa", "Full Screen Preview": "Pré-visualização em tela cheia", + "Lights Out": "Luzes apagadas", "Full screen mode": "Modo tela cheia", "Full size": "Tamanho completo", "GPS Track Log": "Registro de trajeto GPS", @@ -633,6 +634,7 @@ "History & Activity (Y)": "Histórico e atividade (Y)", "Home": "Pasta pessoal", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Margem horizontal (% da aresta curta)", "Horizontal text": "Texto horizontal", "Hours": "Horas", "How to install the model": "Como instalar o modelo", @@ -765,6 +767,7 @@ "List Presets": "Listar predefinições", "List Profiles": "Listar perfis", "List this folder in Local from now on": "Listar esta pasta em Local a partir de agora", + "Loading preview…": "Carregando prévia…", "Local": "Local", "Local folders": "Pastas locais", "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", @@ -1040,6 +1043,7 @@ "Preview only": "Somente pré-visualização", "Preview only: editing the camera's embedded JPEG": "Somente prévia: editando o JPEG incorporado da câmera", "Preview size": "Tamanho da pré-visualização", + "Preview unavailable": "Prévia indisponível", "Previews": "Pré-visualizações", "Previous Photo": "Foto anterior", "Print": "Imprimir", @@ -1520,6 +1524,7 @@ "Versions": "Versões", "Versions (Shift+V)": "Versões (Shift+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Margem vertical (% da aresta curta)", "Vertical text": "Texto vertical", "Vibrance": "Intensidade", "Videos": "Vídeos", @@ -1870,6 +1875,19 @@ "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", "Wait for the import to finish": "Aguarde a importação terminar", + "Appearance": "Aparência", + "Appearance Mode": "Modo de aparência", + "Sync with System": "Sincronizar com o sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo escuro", + "Next Appearance Mode": "Próximo modo de aparência", + "Light Theme": "Tema claro", + "Dark Theme": "Tema escuro", + "In Use": "Em uso", + "Charcoal": "Carvão", + "Midnight": "Meia-noite", + "Silver": "Prata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", @@ -1885,6 +1903,50 @@ "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", "Looking for changes": "Procurando alterações", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Este é um catálogo do Lightroom, não uma biblioteca do LightCraft. Importá-lo para uma biblioteca do LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "A importação lê o catálogo sem alterá-lo e adiciona as fotos, edições, palavras-chave e coleções dele à biblioteca aberta agora.", + "Albums hold library photos: Add to My Photos first": "Álbuns contêm fotos da biblioteca: adicione-as às Minhas fotos primeiro", + "Add to My Photos First": "Adicionar às Minhas fotos primeiro", + "Fill Location from GPS": "Preencher local pelo GPS", + "Download Place Names (GeoNames)": "Baixar nomes de lugares (GeoNames)", + "Place Names Status": "Status dos nomes de lugares", + "Fill Location": "Preencher local", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Preencher Cidade, Estado e País vazios pelo GPS (nomes de lugares GeoNames, offline)", + "Looking up places": "Procurando lugares", + "Downloading place names": "Baixando nomes de lugares", + "Place names downloaded": "Nomes de lugares baixados", + "Place names download cancelled": "Download dos nomes de lugares cancelado", + "Other…": "Outro…", + "Intent:": "Tipo de conversão:", + "Perceptual": "Perceptual", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simular papel e tinta", + "Content-Aware": "Sensível ao conteúdo", + "Sidebar Sections": "Seções da barra lateral", + "Reset Sidebar Sections": "Redefinir seções da barra lateral", + "Hide This Section": "Ocultar esta seção", + "No folders to browse": "Nenhuma pasta para navegar", + "No photos yet": "Ainda não há fotos", + "No imported folders yet": "Ainda não há pastas importadas", + "Show Sidebar Section": "Mostrar seção da barra lateral", + "Move Sidebar Section": "Mover seção da barra lateral", + "New Library…": "Nova biblioteca…", + "Open Recent Library": "Abrir biblioteca recente", + "Recent Libraries": "Bibliotecas recentes", + "Clear Recent Libraries": "Limpar bibliotecas recentes", + "No Recent Libraries": "Nenhuma biblioteca recente", + "New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la", + "New Library": "Nova biblioteca", + "Library name": "Nome da biblioteca", + "New Folder on Disk": "Nova pasta no disco", + "New Folder on Disk…": "Nova pasta no disco…", + "Move to Folder": "Mover para pasta", + "Move to Folder…": "Mover para pasta…", + "Move Selected Photos Here": "Mover as fotos selecionadas para cá", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve", + "Appearance Settings…": "Configurações de aparência…", + "Add to favorites": "Adicionar aos favoritos", + "Remove from favorites": "Remover dos favoritos", "Playback": "Reprodução", "Layout": "Layout", "Link all margins": "Vincular todas as margens", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 177857dc4..ee8b6aaa4 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -114,6 +114,7 @@ "Reset {} Channel": "Сбросить канал: {}", "Restore {}": "Восстановить {}", "Restore {} Photo{}": "Восстановить фото: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Восстановлено из «Недавно удалённых» фото: {}{:.0}", "Saved export preset “{}”": "Пресет экспорта «{}» сохранён", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).", "Show {hidden} hidden location{}": "Показать скрытые места: {hidden}{:.0}", @@ -187,6 +188,7 @@ "{} — show their photos": "{} — показать фото этого человека", "· {dups} already in the library": "· уже в библиотеке: {dups}", "· {n} photo{}": "· фото: {n}{:.0}", + "· {trashed} in Recently Deleted": "· в «Недавно удалённых»: {trashed}", "… and {more} more": "… и ещё {more}", "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", "{:.1} MB": "{:.1} MB", @@ -229,6 +231,9 @@ "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {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} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)", + "New Folder in “{name}”": "Новая папка в «{name}»", + "Moved {} photo(s)": "Перемещено фото: {}", "Applied {name}": "Применено: {name}", "Delete “{name}”?": "Удалить «{name}»?", "Updated {name}": "Обновлено: {name}", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2a0b609eb..6ab2cc66a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -455,6 +455,7 @@ "Friday": "Пятница", "Full": "Полный", "Full Screen Preview": "Полноэкранный просмотр", + "Lights Out": "Выключить свет", "Full screen mode": "Полноэкранный режим", "Full size": "Полный размер", "GPS Track Log": "GPS-трек", @@ -522,6 +523,7 @@ "History": "История", "History & Activity (Y)": "История и активность (Y)", "Horizontal": "По горизонтали", + "Horizontal inset (% of short edge)": "Отступ по горизонтали (% от короткой стороны)", "Horizontal text": "Горизонтальный текст", "How to install the model": "Как установить модель", "Hue": "Тон", @@ -630,6 +632,7 @@ "List Presets": "Список пресетов", "List Profiles": "Список профилей", "List this folder in Local from now on": "Показывать эту папку в «Локальном» и дальше", + "Loading preview…": "Загрузка просмотра…", "Local": "Локальное", "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", @@ -860,6 +863,7 @@ "Preview Build Progress": "Ход создания просмотров", "Preview only": "Только просмотр", "Preview size": "Размер просмотра", + "Preview unavailable": "Просмотр недоступен", "Previews": "Просмотры", "Previous Photo": "Предыдущее фото", "Print": "Печать", @@ -1266,6 +1270,7 @@ "Versions": "Версии", "Versions (Shift+V)": "Версии (Shift+V)", "Vertical": "По вертикали", + "Vertical inset (% of short edge)": "Отступ по вертикали (% от короткой стороны)", "Vertical text": "Вертикальный текст", "Vibrance": "Красочность", "Videos": "Видео", @@ -1583,6 +1588,19 @@ "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", + "Appearance": "Оформление", + "Appearance Mode": "Режим оформления", + "Sync with System": "Как в системе", + "Light Mode": "Светлый режим", + "Dark Mode": "Тёмный режим", + "Next Appearance Mode": "Следующий режим оформления", + "Light Theme": "Светлая тема", + "Dark Theme": "Тёмная тема", + "In Use": "Используется", + "Charcoal": "Графит", + "Midnight": "Полночь", + "Silver": "Серебро", + "Paper": "Бумага", "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", @@ -1599,6 +1617,52 @@ "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", "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": "В альбомах только фото из библиотеки: сначала добавьте их в «Мои фото»", + "Add to My Photos First": "Сначала добавить в «Мои фото»", + "Fill Location from GPS": "Заполнить место по GPS", + "Download Place Names (GeoNames)": "Загрузить названия мест (GeoNames)", + "Place Names Status": "Состояние названий мест", + "Fill Location": "Заполнить место", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заполнить пустые поля «Город», «Регион» и «Страна» по GPS (названия мест GeoNames, офлайн)", + "Looking up places": "Поиск мест", + "Downloading place names": "Загрузка названий мест", + "Place names downloaded": "Названия мест загружены", + "Place names download cancelled": "Загрузка названий мест отменена", + "Other…": "Другой…", + "Intent:": "Метод:", + "Perceptual": "Перцепционный", + "Relative": "Относительный", + "Simulate Paper & Ink": "Имитировать бумагу и краску", + "Content-Aware": "С учётом содержимого", + "Sidebar Sections": "Разделы боковой панели", + "Reset Sidebar Sections": "Сбросить разделы боковой панели", + "Hide This Section": "Скрыть этот раздел", + "No folders to browse": "Нет папок для просмотра", + "No photos yet": "Фотографий пока нет", + "No imported folders yet": "Импортированных папок пока нет", + "Show Sidebar Section": "Показать раздел боковой панели", + "Move Sidebar Section": "Переместить раздел боковой панели", + "Move Up": "Переместить вверх", + "Move Down": "Переместить вниз", + "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": "Переместить в папку", + "Move to Folder…": "Переместить в папку…", + "Move Selected Photos Here": "Переместить выбранные фото сюда", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает", + "Appearance Settings…": "Настройки оформления…", + "Add to favorites": "Добавить в избранное", + "Remove from favorites": "Удалить из избранного", "Playback": "Воспроизведение", "Layout": "Макет", "Link all margins": "Связать все поля", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index d334f2649..65abda2b4 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Скинути канал: {}", "Restore {}": "Відновити {}", "Restore {} Photo{}": "Відновити фото: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Відновлено з нещодавно видалених фото: {}{:.0}", "Saved export preset “{}”": "Набір налаштувань експорту «{}» збережено", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оцінює фокус і експозицію ({scope}) та знаходить подібні знімки з різницею в кілька секунд (серії).", "Show {hidden} hidden location{}": "Показати приховані розташування: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — показати фото цієї особи", "· {dups} already in the library": "· уже в бібліотеці: {dups}", "· {n} photo{}": "· фото: {n}{:.0}", + "· {trashed} in Recently Deleted": "· у нещодавно видалених: {trashed}", "… and {more} more": "… і ще {more}", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ще не може декодувати цей варіант RAW ({}). Ви редагуєте вбудований JPEG-перегляд камери, який уже містить її стиль зображення (наприклад, монохромний) і баланс білого.", "Filtered: {label}": "Відфільтровано: {label}", @@ -229,6 +231,9 @@ "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} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)", + "New Folder in “{name}”": "Нова тека в «{name}»", + "Moved {} photo(s)": "Переміщено фото: {}", "Applied {name}": "Застосовано: {name}", "Delete “{name}”?": "Видалити «{name}»?", "Updated {name}": "Оновлено: {name}", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index e7b3687bf..39e9879f6 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -97,6 +97,7 @@ "Grab bag": "Випадкова добірка", "Group name": "Назва групи", "Home": "Домашня тека", + "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", "Imported": "Імпортовані", "Join the ArtCraft Discord": "Приєднатися до Discord ArtCraft", @@ -109,6 +110,7 @@ "Lines +/−": "Рядки +/−", "Lines added": "Додані рядки", "Lines deleted": "Видалені рядки", + "Loading preview…": "Завантаження перегляду…", "Lock": "Заблокувати", "Merged PRs": "Об’єднані PR", "Model": "Модель", @@ -120,6 +122,7 @@ "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", "Press keys…": "Натисніть клавіші…", + "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", "Random": "Випадковий", "Real name": "Справжнє ім’я", @@ -294,6 +297,7 @@ "Variance": "Дисперсія", "Version": "Версія", "Vertical": "Вертикальна", + "Vertical inset (% of short edge)": "Відступ по вертикалі (% короткого краю)", "Vibrance": "Соковитість", "View": "Вигляд", "Vignetting": "Віньєтування", @@ -931,6 +935,7 @@ "Forget unchanged photos": "Забути незмінені фото", "Forward": "Уперед", "Full Screen Preview": "Повноекранний перегляд", + "Lights Out": "Вимкнути світло", "Full size": "Повний розмір", "GPS Track Log": "Журнал маршруту GPS", "Geometry": "Геометрія", @@ -2079,6 +2084,63 @@ "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", "Resize panels": "Resize panels", + "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": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото", + "Add to My Photos First": "Спершу додати до моїх фото", + "Fill Location from GPS": "Заповнити місце за GPS", + "Download Place Names (GeoNames)": "Завантажити назви місць (GeoNames)", + "Place Names Status": "Стан назв місць", + "Fill Location": "Заповнити місце", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заповнити порожні поля «Місто», «Регіон» і «Країна» за GPS (назви місць GeoNames, офлайн)", + "Looking up places": "Пошук місць", + "Downloading place names": "Завантаження назв місць", + "Place names downloaded": "Назви місць завантажено", + "Place names download cancelled": "Завантаження назв місць скасовано", + "Other…": "Інший…", + "Intent:": "Метод:", + "Perceptual": "Перцепційний", + "Relative": "Відносний", + "Simulate Paper & Ink": "Імітувати папір і фарбу", + "Content-Aware": "З урахуванням вмісту", + "Sidebar Sections": "Розділи бічної панелі", + "Reset Sidebar Sections": "Скинути розділи бічної панелі", + "Hide This Section": "Сховати цей розділ", + "No folders to browse": "Немає тек для перегляду", + "No photos yet": "Фотографій ще немає", + "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 Folder on Disk": "Нова тека на диску", + "New Folder on Disk…": "Нова тека на диску…", + "Move to Folder": "Перемістити до теки", + "Move to Folder…": "Перемістити до теки…", + "Move Selected Photos Here": "Перемістити вибрані фото сюди", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує", + "Appearance Settings…": "Налаштування вигляду…", + "Add to favorites": "Додати до вибраного", + "Remove from favorites": "Видалити з вибраного", + "Appearance": "Вигляд", + "Appearance Mode": "Режим вигляду", + "Sync with System": "Як у системі", + "Light Mode": "Світлий режим", + "Dark Mode": "Темний режим", + "Next Appearance Mode": "Наступний режим вигляду", + "Light Theme": "Світла тема", + "Dark Theme": "Темна тема", + "In Use": "Використовується", + "Charcoal": "Графіт", + "Midnight": "Опівніч", + "Silver": "Срібло", + "Paper": "Папір", "Playback": "Відтворення", "Layout": "Макет", "Link all margins": "Зв’язати всі поля", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 0d6aa11ef..e58415aca 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -61,6 +61,7 @@ "Reset {} Channel": "重置 {0} 通道", "Restore {}": "恢复为 {}", "Restore {} Photo{}": "恢复 {0} 张照片{1:.0}", + "Restored {} photo{} from Recently Deleted": "已从“最近删除”恢复 {0} 张照片{1:.0}", "Saved export preset “{}”": "已保存导出预设“{0}”", "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", "Showing {n} of {}": "显示 {0} 张中的 {n} 张", @@ -105,6 +106,7 @@ "{} spot(s) on this photo": "此照片上有 {0} 处污点", "· {dups} already in the library": "· {dups} 张已在图库中", "· {n} photo{}": "· {n} 张照片{0:.0}", + "· {trashed} in Recently Deleted": "· {trashed} 张在“最近删除”中", "… and {more} more": "…以及另外 {more} 项", "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}", "Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张", @@ -229,6 +231,9 @@ "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}:相册只能收录图库中的照片(请先添加到我的照片)", + "New Folder in “{name}”": "在“{name}”中新建文件夹", + "Moved {} photo(s)": "已移动 {} 张照片", "Applied {name}": "已应用 {name}", "Delete “{name}”?": "删除“{name}”?", "Updated {name}": "已更新 {name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0780ecb42..2e69dd71f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -516,6 +516,7 @@ "Has Edits": "有编辑", "Has GPS": "有 GPS", "Home": "主文件夹", + "Horizontal inset (% of short edge)": "水平边距 (占短边 %)", "Hours": "小时", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", @@ -551,6 +552,7 @@ "Lines +/−": "行数 +/−", "Lines added": "新增行数", "Lines deleted": "删除行数", + "Loading preview…": "正在加载预览…", "Lock": "锁定", "Lock Crop Aspect": "锁定裁剪长宽比", "Locked": "已锁定", @@ -599,6 +601,7 @@ "Presets and profiles": "预设和配置文件", "Press keys…": "请按键…", "Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG", + "Preview unavailable": "预览不可用", "Proof copy created": "已创建校样副本", "Quit Anyway": "仍然退出", "Quit with unsaved changes?": "有未保存的更改,确定退出吗?", @@ -690,6 +693,7 @@ "Friday": "星期五", "Full": "完整", "Full Screen Preview": "全屏预览", + "Lights Out": "关灯模式", "Full screen mode": "全屏模式", "Full size": "完整大小", "GPS Track Log": "GPS 轨迹日志", @@ -1503,6 +1507,7 @@ "Versions": "版本", "Versions (Shift+V)": "版本 (Shift+V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直边距 (占短边 %)", "Vertical text": "竖排文字", "Vibrance": "鲜艳度", "Videos": "视频", @@ -1870,6 +1875,19 @@ "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", + "Appearance": "外观", + "Appearance Mode": "外观模式", + "Sync with System": "与系统同步", + "Light Mode": "浅色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一个外观模式", + "Light Theme": "浅色主题", + "Dark Theme": "深色主题", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "银色", + "Paper": "纸白", "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", @@ -1885,6 +1903,50 @@ "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", "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": "相册只能收录图库中的照片:请先添加到我的照片", + "Add to My Photos First": "先添加到我的照片", + "Fill Location from GPS": "根据 GPS 填写位置", + "Download Place Names (GeoNames)": "下载地名数据 (GeoNames)", + "Place Names Status": "地名数据状态", + "Fill Location": "填写位置", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根据 GPS 填写空白的城市、省/州和国家(GeoNames 地名,离线)", + "Looking up places": "正在查找地点", + "Downloading place names": "正在下载地名数据", + "Place names downloaded": "地名数据已下载", + "Place names download cancelled": "已取消下载地名数据", + "Other…": "其他…", + "Intent:": "方法:", + "Perceptual": "可感知", + "Relative": "相对", + "Simulate Paper & Ink": "模拟纸张和油墨", + "Content-Aware": "内容识别", + "Sidebar Sections": "边栏分区", + "Reset Sidebar Sections": "重置边栏分区", + "Hide This Section": "隐藏此分区", + "No folders to browse": "没有可浏览的文件夹", + "No photos yet": "还没有照片", + "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 Folder on Disk": "在磁盘上新建文件夹", + "New Folder on Disk…": "在磁盘上新建文件夹…", + "Move to Folder": "移动到文件夹", + "Move to Folder…": "移动到文件夹…", + "Move Selected Photos Here": "将所选照片移到此处", + "Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖", + "Appearance Settings…": "外观设置…", + "Add to favorites": "添加到收藏", + "Remove from favorites": "从收藏中移除", "Playback": "播放", "Layout": "布局", "Link all margins": "链接所有边距", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index e3e30a19b..5b021a9e5 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -78,6 +78,7 @@ "Reset {} Channel": "重設 {} 通道", "Restore {}": "還原為 {}", "Restore {} Photo{}": "還原 {} 張照片{:.0}", + "Restored {} photo{} from Recently Deleted": "已從「最近刪除」還原 {} 張照片{:.0}", "Saved export preset “{}”": "已儲存匯出預設集「{}」", "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", "Showing {n} of {}": "顯示 {n}/{} 張", @@ -129,6 +130,7 @@ "{} spot(s) on this photo": "此照片有 {} 個斑點", "· {dups} already in the library": "· {dups} 張已在照片庫中", "· {n} photo{}": "· {n} 張照片{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} 張在「最近刪除」中", "… and {more} more": "…以及另外 {more} 個", " ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)", "Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}", @@ -229,6 +231,9 @@ "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}:相簿只能收錄圖庫中的照片(請先加入我的照片)", + "New Folder in “{name}”": "在「{name}」中新增資料夾", + "Moved {} photo(s)": "已移動 {} 張照片", "Applied {name}": "已套用 {name}", "Delete “{name}”?": "刪除「{name}」?", "Updated {name}": "已更新 {name}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 66e802ef9..268fcebaa 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -516,6 +516,7 @@ "Has Edits": "有編輯", "Has GPS": "有 GPS", "Home": "個人資料夾", + "Horizontal inset (% of short edge)": "水平邊距(短邊百分比)", "Hours": "小時", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", @@ -551,6 +552,7 @@ "Lines +/−": "行數 +/−", "Lines added": "新增行數", "Lines deleted": "刪除行數", + "Loading preview…": "正在載入預覽…", "Lock": "鎖定", "Lock Crop Aspect": "鎖定裁切長寬比", "Locked": "已鎖定", @@ -599,6 +601,7 @@ "Presets and profiles": "預設集與描述檔", "Press keys…": "請按鍵…", "Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG", + "Preview unavailable": "無法使用預覽", "Proof copy created": "已建立校樣副本", "Quit Anyway": "仍要結束", "Quit with unsaved changes?": "有未儲存的變更,確定要結束嗎?", @@ -690,6 +693,7 @@ "Friday": "星期五", "Full": "完整", "Full Screen Preview": "全螢幕預覽", + "Lights Out": "關燈模式", "Full screen mode": "全螢幕模式", "Full size": "完整尺寸", "GPS Track Log": "GPS 軌跡記錄", @@ -1503,6 +1507,7 @@ "Versions": "版本", "Versions (Shift+V)": "版本(Shift+V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直邊距(短邊百分比)", "Vertical text": "垂直文字", "Vibrance": "自然飽和度", "Videos": "影片", @@ -1870,6 +1875,19 @@ "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", + "Appearance": "外觀", + "Appearance Mode": "外觀模式", + "Sync with System": "與系統同步", + "Light Mode": "淺色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一個外觀模式", + "Light Theme": "淺色主題", + "Dark Theme": "深色主題", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "銀色", + "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", @@ -1885,6 +1903,50 @@ "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", "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": "相簿只能收錄圖庫中的照片:請先加入我的照片", + "Add to My Photos First": "先加入我的照片", + "Fill Location from GPS": "根據 GPS 填寫位置", + "Download Place Names (GeoNames)": "下載地名資料 (GeoNames)", + "Place Names Status": "地名資料狀態", + "Fill Location": "填寫位置", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根據 GPS 填寫空白的城市、州/省和國家(GeoNames 地名,離線)", + "Looking up places": "正在查找地點", + "Downloading place names": "正在下載地名資料", + "Place names downloaded": "地名資料已下載", + "Place names download cancelled": "已取消下載地名資料", + "Other…": "其他…", + "Intent:": "方式:", + "Perceptual": "感知", + "Relative": "相對", + "Simulate Paper & Ink": "模擬紙張和油墨", + "Content-Aware": "內容感知", + "Sidebar Sections": "側邊欄區段", + "Reset Sidebar Sections": "重設側邊欄區段", + "Hide This Section": "隱藏此區段", + "No folders to browse": "沒有可瀏覽的資料夾", + "No photos yet": "還沒有照片", + "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 Folder on Disk": "在磁碟上新增資料夾", + "New Folder on Disk…": "在磁碟上新增資料夾…", + "Move to Folder": "移至資料夾", + "Move to Folder…": "移至資料夾…", + "Move Selected Photos Here": "將所選照片移至此處", + "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫", + "Appearance Settings…": "外觀設定…", + "Add to favorites": "加入最愛", + "Remove from favorites": "從最愛移除", "Playback": "播放", "Layout": "版面", "Link all margins": "連結所有邊界", diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs new file mode 100644 index 000000000..614b5bed3 --- /dev/null +++ b/crates/ui-egui/src/brush_live.rs @@ -0,0 +1,251 @@ +//! The brush stroke being painted, drawn on the photo while the button is held (issue #517). +//! +//! The committed stroke is rasterized by the pipeline ([`lightcraft_pipeline::masks::brush_dabs`] +//! and friends): dabs a quarter radius apart along the path, each with a hard core and a smooth +//! feather, flow accumulating up to density. The live stroke places and paints its dabs with the +//! same functions, at screen resolution, so letting go of the button doesn't change what's +//! covered. It's incremental: each frame stamps only the dabs of the samples added since the last +//! one and uploads only the rectangle they touched. + +use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; +use lightcraft_geom::Point; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, push_dabs}; + +/// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. +const MAX_SIDE: usize = 4096; + +/// What a stroke is painted with; a change (a new canvas size, zoom, brush) repaints it from its +/// first sample. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LiveParams { + /// The area the stroke may cover (screen points): the canvas. + pub clip: Rect, + /// Buffer pixels per screen point. + pub scale: f32, + /// Brush radius (screen points) and its hard core (no feather), as the pipeline computes them. + pub r: f32, + pub hard: f32, + /// Flow and density, 0..1. + pub flow: f32, + pub density: f32, + /// Overlay colour and its opacity (0..1) at full coverage. + pub color: [u8; 3], + pub opacity: f32, +} + +/// An in-progress stroke's coverage at screen resolution and the texture showing it. +pub struct LiveStroke { + params: LiveParams, + w: usize, + h: usize, + alpha: Vec, + /// Samples stamped so far, and the last one (buffer pixels). + placed: usize, + last: Option, + /// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends. + dirty: Option<(usize, usize, usize, usize)>, + texture: Option, +} + +impl LiveStroke { + fn new(params: LiveParams) -> LiveStroke { + let scale = buffer_scale(params.clip, params.scale); + let params = LiveParams { scale, ..params }; + let (w, h) = buffer_size(params.clip, scale); + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, dirty: None, texture: None } + } + + /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. + pub fn coverage(&self, x: usize, y: usize) -> f32 { + if x >= self.w { + return 0.0; + } + y.checked_mul(self.w).and_then(|i| i.checked_add(x)).and_then(|i| self.alpha.get(i)).copied().unwrap_or(0.0) + } + + /// Buffer size (pixels) and pixels per screen point. + pub fn size(&self) -> ([usize; 2], f32) { + ([self.w, self.h], self.params.scale) + } + + /// Stamp the dabs of `points` (screen positions of the stroke's samples so far) not yet + /// painted. Returns `true` when anything new was painted. + pub fn stamp(&mut self, points: &[Pos2]) -> bool { + let Some(new) = points.get(self.placed..) else { return false }; + if new.is_empty() { + return false; + } + let LiveParams { clip, scale, r, hard, flow, density, .. } = self.params; + let (r, hard) = ((r * scale) as f64, (hard * scale) as f64); + let mut dabs = Vec::new(); + for q in new { + let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); + if push_dabs(&mut dabs, self.last, q, r) { + self.last = Some(q); + } + } + self.placed = points.len(); + for d in dabs { + if !(d.x.is_finite() && d.y.is_finite()) { + continue; + } + let x0 = (d.x - r).floor().clamp(0.0, self.w as f64) as usize; + let x1 = (d.x + r).ceil().clamp(0.0, self.w as f64) as usize; + let y0 = (d.y - r).floor().clamp(0.0, self.h as f64) as usize; + let y1 = (d.y + r).ceil().clamp(0.0, self.h as f64) as usize; + if x0 >= x1 || y0 >= y1 { + continue; + } + for y in y0..y1 { + let Some(row) = self.alpha.get_mut(y * self.w + x0..y * self.w + x1) else { continue }; + for (x, v) in (x0..x1).zip(row.iter_mut()) { + let dd = Point::new(x as f64 + 0.5, y as f64 + 0.5).dist(d); + if dd > r { + continue; + } + *v = accumulate(*v, dab_alpha(dd, r, hard), flow, density); + } + } + self.dirty = Some(match self.dirty { + Some((a, b, c, e)) => (a.min(x0), b.min(y0), c.max(x1), e.max(y1)), + None => (x0, y0, x1, y1), + }); + } + true + } + + /// The overlay colour of coverage `a`. + fn pixel(&self, a: f32) -> Color32 { + let [r, g, b] = self.params.color; + let alpha = (a * self.params.opacity * 255.0).round().clamp(0.0, 255.0) as u8; + Color32::from_rgba_unmultiplied(r, g, b, alpha) + } + + fn region(&self, (x0, y0, x1, y1): (usize, usize, usize, usize)) -> ColorImage { + let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0)); + for y in y0..y1 { + for x in x0..x1 { + px.push(self.pixel(self.coverage(x, y))); + } + } + ColorImage::new([x1 - x0, y1 - y0], px) + } + + /// Bring the texture up to date (the whole buffer the first time, then only what changed). + fn upload(&mut self, ctx: &egui::Context) -> Option<&TextureHandle> { + match (&mut self.texture, self.dirty.take()) { + (None, _) => { + let image = self.region((0, 0, self.w, self.h)); + self.texture = Some(ctx.load_texture("brush-live-stroke", image, TextureOptions::LINEAR)); + } + (Some(_), Some(rect)) => { + let image = self.region(rect); + if let Some(t) = &mut self.texture { + t.set_partial([rect.0, rect.1], image, TextureOptions::LINEAR); + } + } + (Some(_), None) => {} + } + self.texture.as_ref() + } +} + +/// Pixels per screen point of a buffer covering `clip`: `want`, lowered so no edge exceeds +/// [`MAX_SIDE`]. +fn buffer_scale(clip: Rect, want: f32) -> f32 { + let want = if want.is_finite() && want > 0.0 { want } else { 1.0 }; + let long = clip.width().max(clip.height()); + if long.is_finite() && long * want > MAX_SIDE as f32 { MAX_SIDE as f32 / long } else { want } +} + +fn buffer_size(clip: Rect, scale: f32) -> (usize, usize) { + let side = |v: f32| { + let v = (v * scale).ceil(); + if v.is_finite() { (v.max(1.0) as usize).min(MAX_SIDE) } else { 1 } + }; + (side(clip.width()), side(clip.height())) +} + +/// Paint the live stroke whose samples so far are `points` (screen positions) into `painter`, +/// keeping its buffer in `slot` (a new stroke, or new parameters, start it afresh). +pub fn paint(slot: &mut Option, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) { + let fresh = match slot { + Some(s) => { + // the same stroke when painted with the same parameters (at the scale it settled on) + let same = s.params == LiveParams { scale: buffer_scale(params.clip, params.scale), ..params }; + !same || points.len() < s.placed + } + None => true, + }; + if fresh { + *slot = Some(LiveStroke::new(params)); + } + let Some(s) = slot else { return }; + s.stamp(points); + let clip = s.params.clip; + // the buffer's pixels may overhang the canvas by a fraction of a pixel: map them exactly + let (w, h, scale) = (s.w as f32, s.h as f32, s.params.scale); + let rect = Rect::from_min_size(clip.min, egui::vec2(w / scale, h / scale)); + if let Some(t) = s.upload(ctx) { + painter.image(t.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + + fn params() -> LiveParams { + LiveParams { + clip: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)), + scale: 1.0, + r: 10.0, + hard: 5.0, + flow: 1.0, + density: 1.0, + color: [230, 30, 40], + opacity: 0.5, + } + } + + /// Issue #517: two samples far apart (a fast stroke) paint a continuous band between them, + /// not two separate dabs. + #[test] + fn a_fast_segment_is_painted_continuously() { + let mut s = LiveStroke::new(params()); + s.stamp(&[pos2(20.0, 100.0)]); + s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)]); + for x in 20..380 { + assert!(s.coverage(x, 100) > 0.99, "a gap at x = {x}: {}", s.coverage(x, 100)); + } + // the feather: full inside the core, fading to nothing at the radius + assert!(s.coverage(200, 104) > 0.99); + assert!((0.01..0.99).contains(&s.coverage(200, 108)), "{}", s.coverage(200, 108)); + assert_eq!(s.coverage(200, 111), 0.0); + } + + /// Painting the samples one frame at a time gives the same coverage as all at once (what the + /// committed stroke does). + #[test] + fn incremental_stamping_matches_painting_the_whole_path() { + let pts = [pos2(30.0, 40.0), pos2(200.0, 150.0), pos2(210.0, 152.0), pos2(390.0, 20.0)]; + let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); + once.stamp(&pts); + let mut steps = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); + for n in 1..=pts.len() { + steps.stamp(&pts[..n]); + } + assert_eq!(once.alpha, steps.alpha); + } + + #[test] + fn hostile_input_paints_nothing_and_does_not_panic() { + let mut s = LiveStroke::new(params()); + s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)]); + assert!(s.alpha.iter().all(|a| *a == 0.0)); + // an enormous canvas is painted at a capped resolution + let big = LiveStroke::new(LiveParams { clip: Rect::from_min_size(Pos2::ZERO, vec2(1e6, 10.0)), scale: 2.0, ..params() }); + assert!(big.w <= MAX_SIDE && big.h >= 1); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a48e74cd1..360893be8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -316,6 +316,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ok(Value::Null) } "ui.set" => { + // an arrangement of the sidebar is checked, not made whole as a settings file's is + let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() { + Ok(s) => s, + Err(e) => return err(e), + }; let mut v = serde_json::to_value(&app.ui).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); match serde_json::from_value::(v) { @@ -323,6 +328,23 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest if let Err(error) = crate::docking::set_widths(&mut u, p) { return err(error); } + if let Some(sidebar) = sidebar { + u.sidebar = sidebar; + } + // `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections + if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) { + use crate::sidebar::SidebarSection; + if p.get("sidebar").is_some() { + return err("ui.set: give `sidebar` or the older `collapsedSidebar`, not both".to_string()); + } + if let Some(unknown) = ids.iter().find(|id| id.as_str().and_then(SidebarSection::from_id).is_none()) { + return err(format!("ui.set: `collapsedSidebar`: unknown section {unknown} ({})", SidebarSection::ids())); + } + for section in SidebarSection::ALL { + u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id()))); + } + } + u.collapsed_sidebar.clear(); u.toast = app.ui.toast.clone(); u.dialog = app.ui.dialog.clone(); u.status = app.ui.status.clone(); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 62ac811c7..964396850 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -266,7 +266,8 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim)); ui.separator(); - egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT); + egui::ScrollArea::both().id_salt("credits_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| { if list.is_empty() { ui.label(crate::i18n::tr("No contributor data was built into this copy.")); } else if v.table { @@ -326,7 +327,8 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT); + egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { ui.label(RichText::new(crate::i18n::tr(h)).strong()); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 639589429..364fac08f 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -457,6 +457,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } else { t.grid_bg }; + crate::panels::compare::prepare(app); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view { crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui), crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui), diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6df6ed50b..521b007e2 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -175,6 +175,19 @@ impl HeadlessView { } /// Every piece of text the last frame painted (labels, tooltips, values), in paint order. + /// Every shape of the last frame, with `Shape::Vec` flattened. + pub fn painted_shapes(&self) -> Vec { + fn flat(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| flat(shape, out)), + other => out.push(other.clone()), + } + } + let mut out = Vec::new(); + self.shapes.iter().for_each(|s| flat(&s.shape, &mut out)); + out + } + pub fn painted_text(&self) -> Vec { fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { match shape { @@ -1767,6 +1780,29 @@ mod tests { assert!(w1 < 700.0, "{w1}"); } + /// With a watermark on, the Export dialog shows the first photo with the watermark on it. + #[test] + fn export_dialog_previews_the_watermark() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + let preview = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) + }; + h.settle(SETTLE); + assert!(preview(&mut h).is_none(), "no preview without a watermark"); + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); + } + let t0 = Instant::now(); + while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + let rect = preview(&mut h).expect("the watermark preview is shown"); + assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); + } + #[test] fn export_dialog_runs_in_the_background() { let mut h = demo([1200.0, 900.0]); @@ -1886,6 +1922,49 @@ mod tests { assert!(ids(&mut h, "notice:previewOnly:").is_empty()); } + #[test] + fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { + let mut h = demo([1200.0, 800.0]); + let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); + h.app.ui.right = crate::state::RightPanel::Crop; + h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); + let saved = serde_json::to_value(&h.app.ui).unwrap(); + for &photo in &photos[1..3] { + h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); + let before = h.app.session.catalog.photo(photo).unwrap().clone(); + let undo = h.app.session.undo.len(); + h.settle(SETTLE); + let after = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len()); + assert!(!after.is_edited()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + } + let photo = photos[2]; + let before = h.app.session.develop_of(photo).unwrap(); + let undo = h.app.session.undo.len(); + h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); + h.app.begin_crop().unwrap(); + h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.7]})).unwrap(); + h.app.run("develop.endInteraction", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui = serde_json::from_value(saved).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui.crop_default_aspect = Some(json!("original")); + let p = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); + h.settle(SETTLE); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + } + /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. #[test] fn crop_keys() { @@ -2230,6 +2309,50 @@ mod tests { assert_eq!(h.app.session.undo.len(), undo0 + 1); } + /// Reset takes precedence over the value being typed when its button is clicked. + #[test] + fn reset_button_wins_over_an_active_numeric_draft() { + let mut h = demo([960.0, 600.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{method}: {r}"); + r["result"].clone() + }; + ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); + ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); + ask( + &mut h, + "ui.set", + json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), + ); + let active = h.app.session.active().unwrap(); + for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": text})); + ask(&mut h, "ui.key", json!({"key": terminal})); + let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": "2.0"})); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); + assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); + let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!( + reset["light"]["exposure"].as_f64(), + Some(0.0), + "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", + h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), + h.app.session.undo.last().map(|entry| &entry.op), + h.view.ctx.memory(|m| m.focused()), + ); + } + /// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value /// (selected when the field opens) and Return applies it; the menu doesn't close the field. #[test] @@ -2610,9 +2733,9 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// The import review offers what to do with files that are in Recently Deleted (issue #298): - /// they are unchecked until Restore or Import as new is chosen; then confirming restores the - /// photo (with its edits) or imports the file afresh. + /// The import review offers what to do with files that are in Recently Deleted (issues #298, + /// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming + /// restores the photo (with its edits) or, with Import as new, imports the file afresh. #[test] fn import_review_offers_recently_deleted_files() { for (choice, tag) in [("restore", "r"), ("fresh", "f")] { @@ -2639,9 +2762,12 @@ mod tests { h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.selected_paths().len(), 1, "only the new file: the trashed one waits for a choice"); + assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked once a choice is made"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); // a per-cell choice survives switching between the two options, and "Leave them" unchecks let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); opts.checked[trashed] = false; @@ -2726,9 +2852,10 @@ mod tests { assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); } - /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo - /// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once - /// restored a re-add selects it in All Photos. + /// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted + /// brings the photo back, selected in All Photos, and says so (issue #706) — it is not + /// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a + /// re-add of a photo that is in the library selects it in All Photos. #[test] fn readding_a_deleted_photo_shows_it_in_recently_deleted() { let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); @@ -2755,6 +2882,7 @@ mod tests { crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); h.settle(SETTLE); let id = h.app.session.selection.active.expect("imported photo selected"); + let n0 = h.app.session.catalog.len(); h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); assert!(h.app.session.catalog.photo(id).unwrap().deleted); let photo_items = |app: &LightcraftApp| -> Vec { @@ -2764,14 +2892,22 @@ mod tests { }; assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); - h.app.ui.left_panel = false; crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); finish_import(&mut h); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); - assert_eq!(h.app.session.selection.ids, vec![id]); - assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); + assert_eq!(h.app.session.catalog.len(), n0, "no second record"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}"); + assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); + // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); + h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); let items = photo_items(&h.app); assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); @@ -3129,6 +3265,56 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// With no profile chosen, previews follow the profile of the screen showing the window (the + /// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so + /// a move to another screen is followed. A chosen profile wins; choosing none goes back to the + /// screen's at once; a screen without a profile is sRGB. + #[test] + fn previews_follow_the_screen_profile_while_none_is_chosen() { + use lightcraft_codecs::{NamedSpace, icc::write_named}; + use std::sync::{Arc, Mutex}; + let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); + let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let chosen = dir.join("prophoto.icc"); + std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); + let chosen = chosen.to_string_lossy().to_string(); + + let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); + let mut h = demo([1000.0, 700.0]); + let read = screen.clone(); + h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); + let t = Duration::from_secs(10); + h.step(); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); + assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); + assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); + + // the window moves to another screen + *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); + + // a chosen profile wins over the screen's + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); + assert_eq!(r["ok"], true, "{r}"); + *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); + for _ in 0..90 { + h.step(); + } + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); + + // none chosen again: the screen's profile, at once + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["result"]["screen"], true, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); + + // a screen without a profile: sRGB + *screen.lock().unwrap() = None; + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); + let _ = std::fs::remove_dir_all(&dir); + } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through /// the engine; the delete confirmation guards ⌫. #[test] @@ -3150,10 +3336,21 @@ mod tests { assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); // Interface: filmstrip names off, grid badges always h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards (one row per theme, with its favorite star) sit above these controls; + // scroll the compact settings viewport past them. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -700.0}), t); + for _ in 0..4 { + h.step(); + } h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); assert!(!h.app.ui.settings.film_names); h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 700.0}), t); + for _ in 0..4 { + h.step(); + } // Performance: the thumbnail cache size goes to the library preferences h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e24341d72..8f71a3ea8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -87,6 +87,20 @@ pub enum Icon { Play, /// Pause (two bars). Pause, + /// Colour grading, all three wheels: three small circles. + GradeAll, + /// Colour grading, shadows wheel: a filled circle. + GradeShadows, + /// Colour grading, midtones wheel: a half-filled circle. + GradeMidtones, + /// Colour grading, highlights wheel: an open circle with rays. + GradeHighlights, + /// Appearance Mode ▸ Auto (follow the system): a monitor on its stand. + Monitor, + /// Appearance Mode ▸ Light: a sun (a disc with eight rays). + Sun, + /// Appearance Mode ▸ Dark: a crescent moon. + Moon, } struct Pen<'a> { @@ -182,6 +196,24 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(4.0, 10.0), (16.0, 10.0)]); pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); } + Monitor => { + pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); + pen.line(&[(10.0, 13.5), (10.0, 16.5)]); + pen.line(&[(6.5, 16.5), (13.5, 16.5)]); + } + Sun => { + pen.circle(10.0, 10.0, 3.2); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(10.0 + 5.4 * c, 10.0 + 5.4 * s), (10.0 + 7.4 * c, 10.0 + 7.4 * s)]); + } + } + Moon => { + // outer arc of the disc, then the inner arc of the bite, meeting at the horns + pen.arc(10.0, 10.0, 6.5, 2.9, 263.8); + pen.arc(13.4, 6.4, 5.0, 214.9, 51.8); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); @@ -244,6 +276,32 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); pen.circle(10.0, 10.0, 2.5); } + GradeAll => { + pen.circle(10.0, 6.0, 3.2); + pen.circle(5.5, 13.5, 3.2); + pen.circle(14.5, 13.5, 3.2); + } + GradeShadows => { + pen.circle(10.0, 10.0, 6.0); + pen.dot(10.0, 10.0, 6.0); + } + GradeMidtones => { + pen.circle(10.0, 10.0, 6.0); + let half: Vec<(f32, f32)> = (0..=12) + .map(|i| { + let a = (90.0 + 180.0 * i as f32 / 12.0).to_radians(); + (10.0 + 6.0 * a.cos(), 10.0 + 6.0 * a.sin()) + }) + .collect(); + pen.fill(&half, pen.s.color); + } + GradeHighlights => { + pen.circle(10.0, 10.0, 4.0); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + pen.line(&[(10.0 + 6.0 * a.cos(), 10.0 + 6.0 * a.sin()), (10.0 + 8.0 * a.cos(), 10.0 + 8.0 * a.sin())]); + } + } EyeOff => { pen.arc(10.0, 16.0, 9.0, 212.0, 328.0); pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index af0a10adb..5bc7b9746 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -81,9 +81,10 @@ pub struct ImportDialog { pub metadata_preset: String, /// Copy: raws are copied as DNG. pub dng: bool, - /// Per candidate: the library already has it in Recently Deleted. + /// Per candidate: the library already has it in Recently Deleted ([`ImportDialog::set_trashed`]). pub trashed: Vec, - /// What to do with those: "" (leave them), `restore` or `fresh` (the `onDeleted` param). + /// What to do with those: `restore` (the default: the photo comes back with its edits), `fresh` + /// (imported as a new photo, the old record deleted) or "" (leave them) — the `onDeleted` param. pub on_deleted: String, /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). #[serde(skip)] @@ -109,7 +110,22 @@ fn file_type(c: &ImportCandidate) -> String { impl ImportDialog { pub fn new(candidates: Vec) -> Self { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); - ImportDialog { candidates, checked, ..Default::default() } + ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } + } + /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice + /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by + /// mistake comes back by importing its file again (issue #706). + pub fn set_trashed(&mut self, trashed: Vec) { + self.trashed = trashed; + for i in 0..self.candidates.len() { + if !self.is_trashed(i) { + continue; + } + let on = self.importable(i); + if let Some(c) = self.checked.get_mut(i) { + *c = on; + } + } } pub fn importable(&self, i: usize) -> bool { self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty())) @@ -542,7 +558,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.renderer.forget_imports(); let mut d = ImportDialog::new(out.candidates); - d.trashed = d + let trashed = d .candidates .iter() .map(|c| { @@ -550,6 +566,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) }) .collect(); + d.set_trashed(trashed); d.copy = task.copy; d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); @@ -754,10 +771,12 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + } else if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) } else { crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) }; - if task.restored > 0 { + if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); } if task.params["mode"] == "move" { @@ -820,13 +839,18 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); let n = d.candidates.len(); - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count(); + let trashed = d.trashed.iter().filter(|t| **t).count(); + // files of photos in Recently Deleted are not "already in the library": they are counted apart + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed); let sel = d.selected_paths().len(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); if dups > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); } + if trashed > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { d.checked.iter_mut().for_each(|c| *c = false); @@ -1048,7 +1072,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { }; let mut pick = None; egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (v, label) in [("", "Leave them"), ("restore", "Restore them"), ("fresh", "Import as new")] { + for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { pick = Some(v); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 42f3c1d37..16d0c421f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod album_picker; +pub mod brush_live; pub mod control; pub mod credits; pub mod date_picker; @@ -28,6 +29,7 @@ pub mod pick; pub mod region; pub mod render; pub mod shortcuts; +pub mod sidebar; pub mod softpaint; pub mod state; pub mod sync; @@ -42,6 +44,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_appearance; +#[cfg(test)] mod tests_before_after; #[cfg(test)] mod tests_compare; @@ -56,10 +60,14 @@ mod tests_curve; #[cfg(test)] mod tests_date_picker; #[cfg(test)] +mod tests_dialog_fit; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_grading; +#[cfg(test)] mod tests_grid; #[cfg(test)] mod tests_info_dialogs; @@ -78,6 +86,8 @@ mod tests_labels; #[cfg(test)] mod tests_library_problem; #[cfg(test)] +mod tests_lights_out; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; @@ -86,8 +96,12 @@ mod tests_menubar; #[cfg(test)] mod tests_offline; #[cfg(test)] +mod tests_open_lightroom; +#[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_preset_import; +#[cfg(test)] mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; @@ -119,6 +133,8 @@ pub use control::{ControlRequest, ControlResponse}; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; +/// The `path` of a display profile read from the screen rather than chosen as a file. +pub const SCREEN_PROFILE: &str = "screen"; pub type PickFiles = Box Vec>; /// A save dialog: suggested file name → chosen path (`None` = cancelled). @@ -135,10 +151,21 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: +/// not known). +pub type ScreenProfileFn = Box Option>>; +/// Whether an external editor can be started (its name as in [`Services::open_with`]); the +/// error says why not and what to do (e.g. PhotoCraft isn't installed). +pub type CheckEditorFn = Box Result<(), String>>; /// Something the host does with the session (web: back up or restore the library in browser /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -158,6 +185,9 @@ pub struct Services { pub pick_tracklog: Option, /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). pub pick_display_profile: Option, + /// The profile of the screen showing the window, used while none is chosen in Settings ▸ + /// Display (macOS, whose windows aren't colour-managed: issue #787). + pub screen_profile: Option, /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). @@ -190,12 +220,17 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = + /// assume it can). + pub check_editor: Option, /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the /// user keeps (web only: there the library lives in browser storage, which the browser may /// clear; on the desktop it is a folder backed up like any other). pub backup_library: Option, /// File ▸ Restore Library from Backup… (web only; keeps the current library). pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -295,6 +330,8 @@ pub struct LightcraftApp { pub widgets: Vec<(String, egui::Rect)>, /// In-progress on-canvas gesture (brush stroke points, gradient drag…). pub gesture: Option, + /// The brush stroke being painted, as drawn on the photo until it's committed (issue #517). + pub brush_live: Option, /// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small", /// "thumb", "none"). pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, @@ -308,6 +345,13 @@ pub struct LightcraftApp { /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, + /// The window render each pane of Compare / Reference asked for last (left, right), as + /// [`Self::region_view`] is for the loupe. + pub compare_windows: [Option; 2], + /// Compare / Reference window renders by (pane, job key): which texture shows what. + pub(crate) compare_tiles: std::collections::HashMap<(u8, u64), region::RegionView>, + /// Until when (s) a pinch or scroll in Compare / Reference holds the windows there are. + pub(crate) compare_hold_until: f64, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, /// An import in progress (the import review dialog's batches). @@ -324,6 +368,8 @@ pub struct LightcraftApp { pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option, + /// The library generation the window title and the recent list were last updated for. + pub shown_library: Option, /// A background export in progress. pub export: Option, /// Background file-system work of other commands (Find Missing Photos, auto import…). @@ -343,6 +389,10 @@ pub struct LightcraftApp { pub(crate) display_applied: Option, /// Why the display profile setting could not be applied (shown in Settings ▸ Display). pub display_error: Option, + /// The screen profile in use while no display profile is chosen (`services.screen_profile`), + /// and when it was last read (egui time, s). + pub(crate) screen_profile: Option>, + pub(crate) screen_profile_read: Option, /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, @@ -390,12 +440,16 @@ impl LightcraftApp { film_scroll: None, widgets: vec![], gesture: None, + brush_live: None, loupe_shown: None, region_view: None, region_before_view: None, size_hold: Default::default(), window_refused: None, region_tiles: Default::default(), + compare_windows: [None; 2], + compare_tiles: Default::default(), + compare_hold_until: f64::NEG_INFINITY, merge: merge::MergeState::default(), import: None, scan: None, @@ -404,6 +458,7 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + shown_library: None, export: None, tasks: Default::default(), last_export_result: None, @@ -413,6 +468,8 @@ impl LightcraftApp { memory_applied: None, display_applied: None, display_error: None, + screen_profile: None, + screen_profile_read: None, library_problem: None, activity_expanded: false, model_setup: Default::default(), @@ -445,7 +502,26 @@ impl LightcraftApp { } return r; } + let params = if id == "crop.aspect" + && params.get("aspect").and_then(Value::as_str) == Some("toggle") + && self.crop_tool_aspect().is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + { + serde_json::json!({"aspect": "free"}) + } else { + params + }; let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { + self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { + Some(serde_json::json!("original")) + } else { + self.session + .active() + .and_then(|photo| self.session.develop_of(photo)) + .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) + }; + } if r.is_ok() && id == "mask.adjust" { // Judge local adjustments on the photo, without the selection overlay obscuring them. // Keep it hidden after release; O / the overlay eye can show it again. @@ -495,6 +571,46 @@ impl LightcraftApp { r } + /// The remembered ratio belongs to the tool until the user starts editing the crop. + pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { + let photo = self.session.active()?; + let d = self.session.develop_of(photo)?; + if d.crop != lightcraft_develop::Crop::default() { + return d.crop.aspect; + } + match self.ui.crop_default_aspect.as_ref()? { + Value::String(s) if s == "original" => { + let p = self.session.catalog.photo(photo)?; + let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); + Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) + } + Value::Array(a) => { + let number = |i: usize| { + let v = a.get(i)?.as_f64()? * 100.0; + (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) + }; + Some((number(0)?, number(1)?)) + } + _ => None, + } + } + + /// Called only after a crop gesture begins, inside its undo transaction. + pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + if self.session.interaction.is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + && let Some(aspect) = self.ui.crop_default_aspect.clone() + && aspect != serde_json::json!("free") + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + { + // A remembered value the command refuses (a hand-edited settings file) would refuse + // every crop drag on an untouched photo: forget it and let this drag go ahead. + self.ui.crop_default_aspect = None; + self.ui.status = e.to_string(); + } + Ok(()) + } + /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). /// Advance a running slideshow. fn slideshow_tick(&mut self, ctx: &egui::Context) { @@ -567,6 +683,18 @@ impl LightcraftApp { self.ui.toast = Some((text.into(), t + secs, None)); } + /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). + fn place_names_outcome(&mut self, ctx: &egui::Context) { + use lightcraft_engine::cmd::geocode::DownloadOutcome; + match self.session.place_names.take_outcome() { + Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), + Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), + Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), + None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), + None => {} + } + } + /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and /// show their errors; start a zoomed-in detail pass once the clicking stops /// (`ui.detail_due`); watch the model download. Never waits for the model. @@ -720,6 +848,20 @@ impl LightcraftApp { } } + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it /// on the CPU (photo textures come from the renderer's CPU copies). pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { @@ -823,6 +965,7 @@ impl LightcraftApp { } else { self.fonts_ready = true; } + self.sync_theme(ctx); panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { @@ -970,6 +1113,12 @@ impl LightcraftApp { } } self.display_applied = Some(path); + // none chosen again: the screen's profile is read afresh + self.screen_profile = None; + self.screen_profile_read = None; + } + if self.ui.settings.display_profile.is_empty() { + self.follow_screen_profile(ctx); } if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { self.window_is_fullscreen = fs; @@ -980,6 +1129,37 @@ impl LightcraftApp { } } + /// While no display profile is chosen, show previews through the profile of the screen the + /// window is on (`services.screen_profile`), read about once a second so moving the window to + /// another screen is followed. Without the service (or a profile) the display is sRGB. + fn follow_screen_profile(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { + return; + } + self.screen_profile_read = Some(now); + self.apply_screen_profile(); + } + + /// Read the screen's profile now and show previews through it (sRGB without one). + pub(crate) fn apply_screen_profile(&mut self) { + let Some(read) = self.services.screen_profile.as_mut() else { return }; + let icc = read(); + if icc == self.screen_profile { + return; + } + let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { + Ok(d) => Some(std::sync::Arc::new(d)), + Err(msg) => { + // a profile we can't use: previews stay sRGB, as before #787 + log::warn!("screen profile: {msg}"); + None + } + }); + self.renderer.set_display(display); + self.screen_profile = icc; + } + /// Inject synthetic events (pointer events one per frame; key sequences up to the release). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { // the frame after a synthetic release / key: modifiers back up @@ -1015,6 +1195,30 @@ impl LightcraftApp { raw.events.extend(self.synthetic.drain(..n)); } + /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / + /// Survey area) in black. Painted above the panels, below dialogs and notices. + fn lights_out(&self, ctx: &egui::Context) { + let a = self.ui.lights_out.veil(); + if a <= 0.0 { + return; + } + let screen = ctx.content_rect(); + let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; + let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("lights-out"))); + let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); + if !k.is_positive() { + p.rect_filled(screen, 0.0, veil); + return; + } + // four bands around the photo + use egui::{Rect, pos2}; + p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); + } + /// Frame timings once layout is done (`t0`: when layout started). fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { ctx.output(|o| { @@ -1029,6 +1233,29 @@ impl LightcraftApp { self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); } + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { i18n::set_language(self.ui.language); @@ -1042,9 +1269,11 @@ impl LightcraftApp { if std::mem::take(&mut self.ui.quit) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + self.library_changed(&ctx); // panels set it again this frame while the pointer rests on a preset or profile self.hover_preview = None; self.ai_mask_detail(&ctx); + self.place_names_outcome(&ctx); if self.ui.fullscreen { // full-screen preview: the photo alone on black egui::CentralPanel::default() @@ -1065,6 +1294,7 @@ impl LightcraftApp { panels::library_problem::banner(self, ui); panels::strip::show(self, ui); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui)); + self.lights_out(&ctx); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); @@ -1074,6 +1304,7 @@ impl LightcraftApp { pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); + panels::left::section_drag_feedback(self, &ctx); panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 28101ed2c..815e98576 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -227,7 +227,12 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> }) } -fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) { +/// The task is over: keep its result (with the catalog's `path`) for `status` and say how it went. +fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, mut value: Value) { + let kind = task.kind; + if let Some(o) = value.as_object_mut() { + o.entry("path").or_insert_with(|| json!(task.path)); + } let message = if let Some(e) = value.get("error").and_then(Value::as_str) { let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}")); if matches!(kind, Kind::Inspect) { @@ -343,26 +348,26 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { return; } Err(mpsc::TryRecvError::Disconnected) => { - terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"})); + terminal(app, ctx, &task, json!({"error": "Lightroom worker stopped unexpectedly"})); return; } }; match message { Message::Inspected(result) => match result { - Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report), - Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})), - Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})), - Err(e) => terminal(app, ctx, task.kind, json!({"error": e})), + Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, report), + Ok(_) => terminal(app, ctx, &task, json!({"cancelled": true})), + Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, &task, json!({"cancelled": true})), + Err(e) => terminal(app, ctx, &task, json!({"error": e})), }, Message::Prepared(result) => { let prepared = match result { Ok(p) => p, Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => { - terminal(app, ctx, task.kind, json!({"cancelled": true})); + terminal(app, ctx, &task, json!({"cancelled": true})); return; } Err(e) => { - terminal(app, ctx, task.kind, json!({"error": e})); + terminal(app, ctx, &task, json!({"error": e})); return; } }; @@ -371,7 +376,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { terminal( app, ctx, - task.kind, + &task, if task.cancel.load(Ordering::Relaxed) { json!({"cancelled": true}) } else { @@ -394,7 +399,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let (tx, rx) = mpsc::channel(); let commit_error = committed.as_ref().err().map(ToString::to_string); let Some(finalization) = finalization else { - terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())})); + terminal(app, ctx, &task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())})); return; }; if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) { @@ -402,7 +407,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(error) = commit_error { result["error"] = json!(error); } - terminal(app, ctx, task.kind, result); + terminal(app, ctx, &task, result); return; } task.rx = rx; @@ -418,14 +423,14 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let pending = app.lightroom_last.take().unwrap_or(Value::Null); let report = pending.get("report").cloned().unwrap_or(Value::Null); match result { - Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report), - Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})), + Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &task, report), + Ok(()) => terminal(app, ctx, &task, json!({"report": report, "error": pending["error"].clone()})), Err(e) => { let mut value = json!({"report": report, "indexWarning": e}); if let Some(error) = pending.get("error") { value["error"] = error.clone(); } - terminal(app, ctx, task.kind, value); + terminal(app, ctx, &task, value); } } } @@ -509,7 +514,7 @@ mod tests { tx.send(Message::Prepared(Err("cancelled".into()))).unwrap(); h.step(); assert!(h.app.lightroom.is_none()); - assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true}))); + assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true, "path": "fixture.lrcat"}))); assert!(h.app.session.activity.list().is_empty()); } diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs index df9bdf09b..379a2dd0b 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menu_level.rs @@ -86,10 +86,14 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn // The popup's `Ui` is only as tall as the popup was last frame (egui's default area size on // the first), and a scroll area never grows past that. Ask for the room the window has; // auto-shrink then fits the area to its rows, so a menu scrolls only when they don't fit. + // No scrollbar: the ▲ / ▼ strips, the wheel and dragging scroll a menu, like native menus. + // egui's floating bar is drawn over the rows' right edge, where the shortcut text ends, and in + // its wide (hovered) state its background hides the last character (issue #691). let out = egui::ScrollArea::vertical() .id_salt(("menu-level", depth)) .max_height(height) .min_scrolled_height(height) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .scroll_source(egui::containers::scroll_area::ScrollSource::ALL) .show(ui, rows); let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e6..58bcb390f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -45,7 +45,10 @@ impl MenuNode { /// User-defined names in parameterized menus are data, not message keys. pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str { - if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) { + if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") + || (id == "app.export" && params.get("preset").is_some()) + || (id == "app.openLibrary" && params.get("path").is_some()) + { label } else { crate::i18n::tr(label) @@ -66,7 +69,9 @@ const LAYOUT: &[(&str, &[&str])] = &[ "file.addFolder", "@Import from Device", "---", + "app.newLibrary", "app.openLibrary", + "@Open Recent Library", "file.backupLibrary", "file.restoreLibrary", "---", @@ -124,14 +129,17 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.people", "---", "view.leftPanel", + "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", "view.filmstrip", "view.histogram", "view.navigator", "view.infoOverlay", + "@Appearance", "---", "view.fullScreenPreview", + "view.lightsOut", "view.enterFullScreen", "---", "view.showOriginal", @@ -320,6 +328,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.filterBar" => Some(u.filter_bar), "view.navigator" => Some(u.navigator), "view.fullScreenPreview" => Some(u.fullscreen), + "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), "panel.edit" => panel(RightPanel::Edit), "panel.profiles" => panel(RightPanel::Profiles), @@ -460,6 +469,42 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None)); v } + "Sidebar Sections" => { + // one checkable item per section, in the order the sidebar lists them + let layout = &app.ui.sidebar; + let mut v: Vec = layout + .sections() + .iter() + .filter(|s| s.id.available()) + .map(|s| item("view.sidebarSection", json!({"section": s.id.id()}), s.id.title(), None, true, Some(!s.hidden))) + .collect(); + v.push(MenuNode::Separator); + v.push(item("view.sidebarReset", Value::Null, "Reset Sidebar Sections", None, !layout.is_default(), None)); + v + } + "Appearance" => { + use crate::state::AppearanceMode; + use crate::theme::ThemeKind; + let s = &app.ui.settings; + let mut v: Vec = [ + ("auto", "Sync with System", AppearanceMode::Auto), + ("light", "Light Mode", AppearanceMode::Light), + ("dark", "Dark Mode", AppearanceMode::Dark), + ] + .into_iter() + .map(|(mode, label, m)| item("view.appearance", json!({"mode": mode}), label, None, true, Some(s.appearance_mode == m))) + .collect(); + v.push(item("view.appearance", Value::Null, "Next Appearance Mode", None, true, None)); + // the saved theme of each family is checked + for group in [[ThemeKind::Charcoal, ThemeKind::Midnight], [ThemeKind::Silver, ThemeKind::Paper]] { + v.push(MenuNode::Separator); + for k in group { + let on = if k.is_dark() { s.dark_theme.kind() == k } else { s.light_theme.kind() == k }; + v.push(item("view.theme", json!({"theme": k.id()}), k.label(), None, true, Some(on))); + } + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) @@ -511,6 +556,30 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Open Recent Library" => { + let can = !crate::lightroom_import::is_running(app); + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let mut v: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + // a missing library (moved, drive disconnected) is shown greyed out + let label = + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); + item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + }) + .collect(); + if v.is_empty() { + v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); + } else { + v.push(MenuNode::Separator); + v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); + } + v + } "Import from Device" => { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { @@ -573,15 +642,23 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v } "Add to Album" => { - let sel = !app.session.selection.ids.is_empty() || has; - let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); - albums.sort_by_key(|(n, _)| n.to_lowercase()); - let mut v: Vec = - albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect(); - if !v.is_empty() { + let targets = app.session.targets(&Value::Null); + let sel = !targets.is_empty(); + // albums hold library photos (issue #684): a Local-only selection is told what to do first + let local_only = crate::panels::grid::local_only(app, &targets); + let mut v: Vec = vec![]; + if local_only { + v.push(item("photo.addToLibrary", Value::Null, crate::i18n::tr("Add to My Photos First"), None, false, None)); v.push(MenuNode::Separator); } - v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None)); + let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); + albums.sort_by_key(|(n, _)| n.to_lowercase()); + let any = !albums.is_empty(); + v.extend(albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel && !local_only, None))); + if any { + v.push(MenuNode::Separator); + } + v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, !local_only, None)); v } _ => return None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d373674c2..6c3e9a3e1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -106,6 +106,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("local.hide", "Remove from Local", None, ""), ("local.restoreHidden", "Show Hidden Local Locations", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), + ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), ("view.navigator", "Navigator", None, "View"), @@ -140,6 +141,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("view.photoCounts", "Show Photo Counts", None, "View"), + ("view.sidebarSection", "Show Sidebar Section", None, ""), + ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), + ("view.sidebarReset", "Reset Sidebar Sections", None, ""), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), ("view.slideshowOptions", "Slideshow Options…", None, "View"), // the slideshow's playback controls (Space, ←/→ and Esc while it plays) @@ -150,6 +154,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.faceModel", "Add Face Model…", None, ""), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -161,7 +170,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.export", "Export…", None, "File"), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"), + ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), + ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), @@ -186,7 +196,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.newLibrary", "New Library…", None, "File"), ("app.openLibrary", "Open Library…", None, "File"), + ("app.recentLibraries", "Recent Libraries", None, ""), + ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display ("app.displayProfile", "Display Profile", None, ""), ("app.about", "About LightCraft", None, "Help"), @@ -273,6 +286,63 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { Some([c(0)?, c(1)?, c(2)?]) } +/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have. +fn sidebar_section_param(command: &str, p: &Value) -> Result { + use crate::sidebar::SidebarSection; + let Some(name) = p.get("section").and_then(Value::as_str) else { + return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids())); + }; + let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?; + if !section.available() { + return Err(format!("{command}: there is no `{name}` section here")); + } + Ok(section) +} + +/// An optional true / false parameter: absent or null is `None`, anything else is refused. +fn bool_param(command: &str, p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")), + } +} + +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if matches!(id, "ui.dock" | "ui.dock.reset") { @@ -336,6 +406,62 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or + // unfold it; with neither, a shown section is hidden and a hidden one shown + let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?))); + let (section, show, collapsed) = match args { + Ok(args) => args, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + match (show, collapsed) { + (None, None) => layout.set_hidden(section, !layout.is_hidden(section)), + _ => { + if let Some(show) = show { + layout.set_hidden(section, !show); + } + if let Some(collapsed) = collapsed { + layout.set_collapsed(section, collapsed); + } + } + } + Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)})) + } + "view.sidebarMoveSection" => { + // {section, direction: up | down} one place among the shown sections, or + // {section, before:
| null} just above another section (null: last) + let section = match sidebar_section_param(id, p) { + Ok(s) => s, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + let moved = match (p.get("direction"), p.get("before")) { + (Some(d), None) => match d.as_str() { + Some("up") => layout.move_by(section, true), + Some("down") => layout.move_by(section, false), + _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), + }, + (None, Some(Value::Null)) => layout.move_before(section, None), + (None, Some(b)) => { + let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); + let Some(before) = before else { + return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); + }; + layout.move_before(section, Some(before)) + } + _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), + }; + let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); + Ok(json!({"section": section.id(), "moved": moved, "order": order})) + } + "view.sidebarReset" => { + // every section shown and open, in the usual order + app.ui.sidebar = Default::default(); + Ok(json!({"sidebar": app.ui.sidebar})) + } + "view.appearance" => appearance_command(app, p), + "view.theme" => theme_command(app, p), "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) @@ -410,6 +536,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { if app.ui.dialog.is_some() { app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; } else if app.ui.keyword_painter.is_some() { app.ui.keyword_painter = None; } else if app.ui.fullscreen { @@ -465,6 +593,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {mode?: on | dim | off} (cycles when omitted) + match p.get("mode").and_then(Value::as_str) { + Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), + None => Ok(app.ui.lights_out.next()), + } + .map(|mode| { + app.ui.lights_out = mode; + json!({"lightsOut": mode}) + }) + } "view.enterFullScreen" => { // applied by the host's frame logic (it knows the window's current state) let on = p.get("on").and_then(Value::as_bool); @@ -501,6 +640,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::settings::open_library(app, p), + "app.newLibrary" => crate::panels::settings::new_library(app, p), + "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), + "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), "app.displayProfile" => crate::panels::settings::display_profile(app, p), "file.backupLibrary" | "file.restoreLibrary" => { let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; @@ -611,14 +753,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {on?, space?, destWarning?, displayWarning?}; no params toggles + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles let has = |k: &str| p.get(k).is_some(); if let Some(s) = p.get("space").and_then(Value::as_str) { match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => app.ui.proof.space = sp, + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), } } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Some(Err(format!("view.softProof: {e}"))), + }, + None => app.ui.proof.profile = 0, + } if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { app.ui.proof.dest_warning = b; } @@ -627,14 +802,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option b, - None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, None => !app.ui.soft_proof, }; if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { app.ui.view = ViewMode::Detail; } let pr = app.ui.proof; - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) } "view.histogram" => { app.ui.histogram = !app.ui.histogram; @@ -1042,30 +1219,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // render an edit copy (stacked on the original), then open it in the editor - let mut params = p.clone(); - if !params.is_object() { - params = json!({}); - } - let r = match app.session.execute("photo.editExternal", ¶ms) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let path = r["path"].as_str().unwrap_or_default().to_string(); - if let Some(id) = r["id"].as_u64() { - app.ui.external_edits.push(id); - } - let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); - if let Some(f) = app.services.open_with.as_mut() - && let Err(e) = f(&path, &editor) - { - app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - } - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); - Ok(r) - } + "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), + "photo.editInExternal" => edit_in(app, &ctx, p, false), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1368,64 +1523,100 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option = Vec::new(); + // `name: reason` for each file that couldn't be imported + let mut problems: Vec = Vec::new(); + let mut profiles_result = Value::Null; + if !lut_paths.is_empty() { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + if profiles == 1 { "" } else { "s" }, + profiles = profiles + )); + } + problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + })); + profiles_result = v; + } + Err(e) => { + problems.push(e.to_string()); + profiles_result = json!({"error": e.to_string()}); + } + } + } let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); let r = if preset_paths.is_empty() { Ok(json!({"imported": [], "failed": [], "skipped": 0})) } else { app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) }; - if profiles > 0 { - app.toast( - &ctx, - crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - ), - ); - } - if let Ok(v) = &r { + if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = match (n, failed) { - (0, 0) => "No new presets".to_string(), - (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n), - (n, f) => crate::i18n::tr_format!( + let profile_news = !parts.is_empty() || !problems.is_empty(); + let msg = match (n, failed) { + // a zip of LUTs only: the profile half already says what happened + (0, 0) if profile_news => None, + (0, 0) => Some("No new presets".to_string()), + (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), + (n, f) => Some(crate::i18n::tr_format!( "Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }, f = f, n = n - ), + )), }; - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + if let Some(mut msg) = msg { + // settings with no counterpart here (the other editor's profiles, masks…) + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + } + parts.push(msg); } - app.toast(&ctx, msg); if n > 0 { app.ui.presets = true; } } + let mut msg = parts.join(" · "); + if !problems.is_empty() { + let detail = + format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); + } + app.toast_error(&ctx, msg); + } else if !msg.is_empty() { + app.toast(&ctx, msg); + } + let r = r.map(|mut v| { + if !lut_paths.is_empty() + && let Some(o) = v.as_object_mut() + { + o.insert("profiles".into(), profiles_result); + } + v + }); return Some(r); } "file.importKeywords" => { @@ -1615,6 +1806,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { app.session.active().is_some() } "photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(), + "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "app.showInFinder" => { @@ -1629,10 +1821,11 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, - "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), + "app.openLibrary" | "app.newLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), "file.backupLibrary" => app.services.backup_library.is_some(), "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(), s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { @@ -1722,3 +1915,41 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { reveal(&path)?; Ok(json!({"path": path})) } + +/// Photo ▸ Edit In: render a copy of the active photo with its edits (stacked on the original) +/// as Settings ▸ External Editing asks for that editor, then open it there. `p` may override the +/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, +/// so a missing editor leaves no copy behind. +fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + let settings = &app.ui.settings; + let (file, configured) = if photocraft { + let path = settings.photocraft_path.trim(); + (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) + } else { + (settings.external_file, settings.external_editor.clone()) + }; + let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); + if let Some(check) = app.services.check_editor.as_ref() + && let Err(e) = check(&editor) + { + app.toast(ctx, e.clone()); + return Err(e); + } + let mut params = file.params(); + if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { + o.insert(k.clone(), v.clone()); + } + } + let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; + let path = r["path"].as_str().unwrap_or_default().to_string(); + if let Some(id) = r["id"].as_u64() { + app.ui.external_edits.push(id); + } + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + match app.services.open_with.as_mut().map(|f| f(&path, &editor)) { + Some(Err(e)) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + _ => app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)), + } + Ok(r) +} diff --git a/crates/ui-egui/src/model_setup.rs b/crates/ui-egui/src/model_setup.rs index e70949542..bb088eaa9 100644 --- a/crates/ui-egui/src/model_setup.rs +++ b/crates/ui-egui/src/model_setup.rs @@ -104,10 +104,30 @@ pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) { ui.add_space(6.0); } +/// The model setup was dismissed with nothing on the way: neither Settings nor a model's terms +/// are showing, no model download or installation is running, and the photo still has no model. +fn abandoned(app: &mut LightcraftApp) -> bool { + if matches!(app.ui.dialog, Some(Dialog::Settings { .. } | Dialog::DenoiseModel { .. })) { + return false; + } + let Some(photo) = app.model_setup.denoise.as_ref().map(|r| r.photo) else { return false }; + if !matches!(app.session.denoise_photo_state(photo), PhotoState::NoModel) { + return false; + } + let Ok(v) = app.session.execute("denoise.models.downloads", &json!({})) else { return false }; + !v["downloads"].as_array().into_iter().flatten().any(|d| matches!(d["state"].as_str(), Some("running" | "done" | "installing" | "installed"))) +} + pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { if app.model_setup.denoise.is_none() { return; } + if abandoned(app) { + // the setup was closed without installing anything: the switch goes back off (issue #922) + app.model_setup.denoise = None; + app.ui.status = tr("Pending photo action cancelled").into(); + return; + } if app.session.interaction.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(250)); return; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index d758a148d..4d5e7453b 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -188,12 +188,16 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) { img = super::detail::fit_rect(img_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + // a pinch changes the zoom every frame: no new windows until it is quiet + app.compare_hold_until = ui.input(|i| i.time) + crate::region::HOLD_SECS; + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } else if resp.dragged() { super::detail::pan_image(app, img_area, img, resp.drag_delta()); } } - let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp) - .min(2560.0_f32.min(super::detail::texture_side(ui.ctx()) as f32)) as usize; + let texture_side = super::detail::texture_side(ui.ctx()); + let want = + (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0_f32.min(texture_side as f32)) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { app.renderer.request(slot, job, 60); @@ -201,6 +205,35 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if let Some(job) = app.session.thumb_job(id, 256) { app.renderer.request(Slot::Thumb(id), job, 30); } + // zoomed past what that render holds (1:1 to check focus): the part on screen as a window, as + // in the loupe. Compare and Reference only; Survey tiles are fit views. + let pane = match slot { + Slot::Compare(i) if i < WINDOW_PANES && matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) => Some(i), + _ => None, + }; + let shown_aspect = app.renderer.textures.get(&slot).filter(|x| x.photo == id).map(|t| t.size[0].max(1) as f32 / t.size[1].max(1) as f32); + let window = pane.and_then(|i| { + let tile = TileView { + pane: i, + id, + img, + visible: img_area, + aspect, + native_long: native[0].max(native[1]), + whole_edge: want, + ppp, + texture_side, + now: ui.input(|i| i.time), + }; + let plain = shown_aspect.is_none_or(|a| crate::region::same_aspect(a, aspect)); + let view = if plain { request_window(app, &tile) } else { None }; + app.compare_windows[i as usize] = view; + if view.is_none() { + app.compare_tiles.retain(|(p, _), _| *p != i); + app.renderer.release(Slot::CompareRegion(i)); + } + view.map(|_| i) + }); let p = ui.painter_at(img_area); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); let mut display = img; @@ -208,6 +241,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32; display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); p.image(tex.tex.id(), display, uv, Color32::WHITE); + if let Some(i) = window { + draw_window(&p, app, i, id, photo.develop.hash64(), display); + } } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32; display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); @@ -258,6 +294,103 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo (display, resp) } +/// Panes that get window renders: Compare's Select and Candidate, Reference's two sides. +const WINDOW_PANES: u8 = 2; + +/// One pane of Compare or Reference, as [`request_window`] needs it. +struct TileView { + pane: u8, + id: PhotoId, + /// The rect the photo is drawn in (the zoomed frame). + img: Rect, + /// The area whose pixels are on screen. + visible: Rect, + aspect: f32, + /// The photo's own long edge as shown. + native_long: usize, + /// Long edge of the pane's whole-frame render. + whole_edge: usize, + ppp: f32, + texture_side: usize, + /// The frame's time (s). + now: f64, +} + +/// Ask for the window of a pane's frame that is on screen, once the view is zoomed past what the +/// pane's whole-frame render holds (issue #323): cut from a frame of at most the photo's own pixels +/// (beyond 100 % the GPU magnifies it) and drawn over the whole-frame render. Remembers it so its +/// texture can be drawn where it belongs. `None`: no window is wanted (or possible): the pane shows +/// its whole-frame render alone, as before windows. +/// +/// While a pinch or scroll runs, and while the photo is being edited (a slider drag in Reference +/// view), the window there is stays: a new one per frame would render the photo's own pixels each +/// time. It is drawn magnified or with the old value until the gesture is quiet. +fn request_window(app: &mut LightcraftApp, v: &TileView) -> Option { + let drawn_long = v.img.width().max(v.img.height()) * v.ppp; + let frame_edge = app.ui.settings.window_frame_edge(drawn_long, v.native_long); + if frame_edge as f32 <= v.whole_edge as f32 * crate::region::WINDOW_RATIO { + return None; + } + let editing = app.session.interaction.is_some() && app.session.active() == Some(v.id); + if app.compare_hold_until > v.now || editing { + return app.compare_windows[v.pane as usize].filter(|w| w.photo == v.id); + } + let (fw, fh) = + if v.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / v.aspect) } else { (frame_edge as f32 * v.aspect, frame_edge as f32) }; + let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = v.ppp * frame_edge as f32 / drawn_long.max(1.0); + let visible = ( + to_px * (v.visible.left() - v.img.left()), + to_px * (v.visible.top() - v.img.top()), + to_px * (v.visible.right() - v.img.left()), + to_px * (v.visible.bottom() - v.img.top()), + ); + let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(v.texture_side))?; + // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one + // render; the pane keeps its whole-frame render + let job = app.session.region_job(v.id, fw, fh, win, true)?; + let look = job.settings.hash64(); + let view = crate::region::RegionView { photo: v.id, before: false, key: job.key, full: (fw, fh), window: win, settings: look }; + // windows of other photos, looks and zooms of this pane can't be drawn any more + app.compare_tiles.retain(|(p, _), t| *p != v.pane || t.is_current(v.id, (fw, fh), look, false)); + app.compare_tiles.insert((v.pane, job.key), view); + // after both panes' whole-frame renders (60): those are what a step to the next photo shows first + app.renderer.request(Slot::CompareRegion(v.pane), job, 58); + Some(view) +} + +/// Draw pane `pane`'s window texture over its whole-frame render (`display`), if it shows this +/// photo with its current look (any look of it while it is being edited). A window made at another +/// zoom is drawn at its place in the frame, magnified or shrunk, until the new one arrives. +fn draw_window(p: &egui::Painter, app: &LightcraftApp, pane: u8, id: PhotoId, look: u64, display: Rect) { + let Some(tex) = app.renderer.textures.get(&Slot::CompareRegion(pane)).filter(|t| t.photo == id) else { return }; + let editing = app.session.interaction.is_some() && app.session.active() == Some(id); + let Some(tile) = app.compare_tiles.get(&(pane, tex.key)).filter(|t| t.photo == id && (editing || t.settings == look)) else { + return; + }; + let (fw, fh) = (tile.full.0.max(1) as f32, tile.full.1.max(1) as f32); + let at = display.min + vec2(tile.window.x as f32 / fw * display.width(), tile.window.y as f32 / fh * display.height()); + let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * display.width(), tile.window.h as f32 / fh * display.height())); + p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); +} + +/// Per frame, before the central view is shown: Compare and Reference keep two photos' originals +/// decoded (their windows are cut from both); other views keep one and free the panes' windows. +pub fn prepare(app: &mut LightcraftApp) { + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { + app.session.media.set_full_capacity(WINDOW_PANES as usize); + return; + } + app.session.media.set_full_capacity(1); + for i in 0..WINDOW_PANES { + if app.compare_windows[i as usize].take().is_some() || app.renderer.textures.contains_key(&Slot::CompareRegion(i)) { + app.renderer.release(Slot::CompareRegion(i)); + } + } + app.compare_tiles.clear(); +} + fn slot_index(s: Slot) -> u8 { match s { Slot::Compare(i) => i, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d7e4c8e1c..234476167 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -249,9 +249,17 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// Whether an overlay can be rendered on a region tile without changing what the tile means. +/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic +/// overlays either need whole-frame context or deliberately replace the loupe. +pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { + matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) +} + /// What a window render of the loupe needs to know about the view it belongs to. struct WindowCtx { id: PhotoId, + overlay: lightcraft_pipeline::Overlay, /// Long edge of the frame the windows are cut from (`None`: no windows). frame_edge: Option, drawn_long: f32, @@ -317,9 +325,15 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt app.window_refused = Some((c.id, c.look, frame_edge)); return None; }; - // a drag renders drafts of the window, as it does of the whole frame + // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays + // plain; the After tile carries the same mask overlay as the whole-frame render. let job = if c.interacting { job.draft() } else { job }; - let look = job.settings.hash64(); + let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; + let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; + let job = job.with_overlay(overlay); + // The overlay is part of the tile's identity too, so changing mask view/colour never leaves a + // stale region tile on screen while its replacement renders. + let look = job.settings.hash64() ^ overlay.key().rotate_left(17); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); @@ -428,15 +442,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); - // (modes that draw another picture over the loupe, or reads too far for one window, have the - // whole frame rendered at the size it is drawn, as they did before windows) + let overlay = view_overlay(app, &d); + // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one + // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. let sizes = crate::region::ViewSizes { drawn_long, canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, draft_scale: scale, - windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None, + windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { @@ -455,7 +470,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; - let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); } // hovering a preset or profile: the photo with that look, shown instead of the loupe render @@ -503,17 +519,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more - // than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a - // look or showing an overlay (those draw another image), nor where the shown picture isn't the - // frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are - // the frame's). - let plain_view = hover_key.is_none() - && !app.ui.soft_proof - && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None - && crate::region::same_aspect(display_aspect, aspect); + // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use + // the same window; diagnostic overlays that need whole-frame context do not. + let windowable_view = + hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect); let window_ctx = WindowCtx { id, - frame_edge: plan.window_edge.filter(|_| plain_view), + overlay, + frame_edge: plan.window_edge.filter(|_| windowable_view), drawn_long, aspect, ppp, @@ -1253,25 +1266,12 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let quad = frame_crop_quad(d, frame); let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); let p = ui.painter(); - // darken outside the crop - let img = map.rect; - let shade = Color32::from_black_alpha(150); - let mut mesh = egui::epaint::Mesh::default(); - let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; - // the box's corners in screen order (a flipped photo shows its top-left corner elsewhere) - let on_screen = flipped_corners(&pts, frame.flip_h, frame.flip_v); - for i in 0..4 { - let a = outer[i]; - let b = outer[(i + 1) % 4]; - let c = on_screen[(i + 1) % 4]; - let dd = on_screen[i]; - let base = mesh.vertices.len() as u32; - for v in [a, b, c, dd] { - mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade }); - } - mesh.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]); + // darken outside the crop (the box's corners in screen order: a flipped photo shows its + // top-left corner elsewhere) + let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v); + if pts.len() == 4 { + p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150))); } - p.add(mesh); p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); // overlay guides let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t; @@ -1346,7 +1346,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons if resp.drag_started() && let Some(q) = resp.interact_pointer_pos() { - let _ = app.run("develop.beginInteraction", json!({"label": "Crop"})); + if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) + { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return; + } + let d = app.session.develop_of(id).unwrap_or_default(); app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, @@ -1365,9 +1371,11 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is. let n = to_straight(map.norm(q), angle, frame); let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n); + // Shift keeps the current proportions for this drag only; the Lock toggle is untouched. + let keep_ratio = ui.input(|i| i.modifiers.shift); let _ = app.run( "crop.drag", - json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1]}), + json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}), ); } Some(Gesture::CropRotate { start_angle, a0 }) => { @@ -1527,6 +1535,62 @@ fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { order.map(at) } +/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of +/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are +/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i` +/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles. +/// +/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while +/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which +/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle. +/// A degenerate quad (a point, a line, a non-finite corner) dims nothing. +fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh { + let mut mesh = egui::epaint::Mesh::default(); + if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() { + return mesh; + } + let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0); + // signed distance from edge i's line, positive towards the crop's centre + let side = |i: usize, q: Pos2| -> Option { + let (a, b) = (quad[i % 4], quad[(i + 1) % 4]); + let n = vec2(a.y - b.y, b.x - a.x); + let orient = n.dot(centre - a); + // an edge the centre lies on: the box is flat + (orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum()) + }; + // Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero + let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option| -> Option> { + let mut out = Vec::with_capacity(poly.len() + 2); + for (k, &a) in poly.iter().enumerate() { + let b = *poly.get((k + 1) % poly.len())?; + let (da, db) = (keep(a)?, keep(b)?); + if da >= 0.0 { + out.push(a); + } + if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON { + let t = da / (da - db); + out.push(a + (b - a) * t); + } + } + Some(out) + }; + let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; + for i in 0..4 { + let outside = |q: Pos2| side(i, q).map(|d| -d); + let inside_next = |q: Pos2| side(i + 1, q); + let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() }; + if strip.len() < 3 { + continue; + } + let base = mesh.vertices.len() as u32; + mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color })); + for k in 1..(strip.len() as u32).saturating_sub(1) { + mesh.indices.extend([base, base + k, base + k + 1]); + } + } + mesh +} + fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { let f = Frame { crop: d.crop.geometry, ..frame.clone() }; f.crop_quad_norm() @@ -1638,10 +1702,6 @@ fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Respons /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let tint = { - let [r, g, b] = app.ui.mask_overlay_color; - Color32::from_rgba_unmultiplied(r, g, b, 110) - }; let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); @@ -1778,15 +1838,29 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), } } - // the stroke being painted (the render shows it once it's committed) + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) if let Some(Gesture::Brush { points }) = &app.gesture { - for q in points { - p.circle_filled(map.screen(*q), r, tint); - } + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen); + } else { + app.brush_live = None; } if (resp.drag_stopped() || resp.clicked()) && let Some(Gesture::Brush { points }) = app.gesture.take() { + app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt); @@ -1977,6 +2051,7 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo let mode = match app.ui.tool.as_str() { "heal" => "heal", "clone" => "clone", + "fill" => "fill", _ => "remove", }; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); @@ -2284,8 +2359,94 @@ mod tests { assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians())); } - use super::{film_label, flipped_corners, texture_side}; - use egui::pos2; + use super::{film_label, flipped_corners, outside_crop_mesh, texture_side}; + use egui::{Color32, Pos2, Rect, pos2}; + + /// A rotated `w` × `h` box centred on `centre`. + fn box_at(centre: Pos2, w: f32, h: f32, angle: f32) -> [Pos2; 4] { + let (s, c) = angle.to_radians().sin_cos(); + [(-w, -h), (w, -h), (w, h), (-w, h)].map(|(x, y)| pos2(centre.x + (x * c - y * s) / 2.0, centre.y + (x * s + y * c) / 2.0)) + } + + /// Signed distances from a convex polygon's edges, positive inwards (its winding either way). + fn sides(poly: &[Pos2], p: Pos2) -> Vec { + let n = poly.len() as f32; + let centre = pos2(poly.iter().map(|q| q.x).sum::() / n, poly.iter().map(|q| q.y).sum::() / n); + (0..poly.len()) + .map(|i| { + let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); + let nrm = egui::vec2(a.y - b.y, b.x - a.x).normalized(); + nrm.dot(p - a) * nrm.dot(centre - a).signum() + }) + .collect() + } + + /// Issue #731: the dimming is the photo minus the crop box at any angle. Every point of the + /// photo outside the box lies in exactly one of the mesh's triangles; no point inside the box + /// or outside the photo lies in any. (Four quads joining photo and box corners crossed and + /// overlapped once the box was rotated.) + #[test] + fn the_dimming_outside_a_rotated_crop_is_exactly_the_photo_minus_the_box() { + let img = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 650.0)); + // boxes inside the photo, poking out of it, and bigger than it + for (w, h) in [(240.0, 360.0), (420.0, 300.0), (700.0, 900.0)] { + for angle in [0.0f32, 5.0, 15.0, 30.0, -45.0, 90.0, 137.0, 200.0] { + let quad = box_at(img.center() + egui::vec2(30.0, -20.0), w, h, angle); + let mesh = outside_crop_mesh(img, &quad, Color32::BLACK); + let tris: Vec<[Pos2; 3]> = mesh + .indices + .chunks(3) + .map(|t| [mesh.vertices[t[0] as usize].pos, mesh.vertices[t[1] as usize].pos, mesh.vertices[t[2] as usize].pos]) + .collect(); + let (mut inside, mut outside) = (0, 0); + let mut y = img.top() - 20.0; + while y < img.bottom() + 20.0 { + let mut x = img.left() - 20.0; + while x < img.right() + 20.0 { + let p = pos2(x, y); + let in_box = sides(&quad, p); + let in_img = sides(&[img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()], p); + // a pixel on an edge may fall either way: skip the boundaries themselves + if in_box.iter().chain(&in_img).any(|d| d.abs() < 1.0) { + x += 7.0; + continue; + } + let expected = if in_img.iter().all(|d| *d > 0.0) && !in_box.iter().all(|d| *d > 0.0) { 1 } else { 0 }; + // a sample on the seam between two of a strip's fan triangles is in both + // or in neither, depending on rounding: count it both ways + let covered = |eps: f32| tris.iter().filter(|t| sides(*t, p).iter().all(|d| *d > eps)).count(); + let (at_least, at_most) = (covered(0.01), covered(-0.01)); + assert!( + at_least <= expected && expected <= at_most, + "{w}×{h} box at {angle}°: ({x}, {y}) lies in {at_least}..={at_most} triangle(s), not {expected}" + ); + if expected == 1 { + outside += 1 + } else if in_img.iter().all(|d| *d > 0.0) { + inside += 1 + } + x += 7.0; + } + y += 7.0; + } + // (a box bigger than the photo swallows it whole at some angles) + assert!(inside > 50 && (outside > 50 || w > img.width()), "{w}×{h} at {angle}°: {outside} dimmed, {inside} clear samples"); + } + } + } + + /// A flat or broken box dims nothing rather than something arbitrary (or panicking). + #[test] + fn a_degenerate_crop_box_dims_nothing() { + let img = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0)); + let point = [pos2(10.0, 10.0); 4]; + let line = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 10.0), pos2(10.0, 10.0)]; + let nan = [pos2(f32::NAN, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)]; + for quad in [point, line, nan] { + assert!(outside_crop_mesh(img, &quad, Color32::BLACK).indices.is_empty(), "{quad:?}"); + } + assert!(outside_crop_mesh(Rect::NAN, &box_at(pos2(50.0, 50.0), 20.0, 20.0, 10.0), Color32::BLACK).indices.is_empty()); + } /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. /// The loupe must still know it in every later frame (it read the raw input, found nothing and diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a90b4bb46..6af883bc6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -54,14 +54,131 @@ pub(crate) fn rename_preview( (read(&rows), total) } +/// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever +/// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = +/// full size). On a worker thread, once per photo, catalog change and size step, the cached +/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size +/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture +/// changes, which is quick at this size, so the preview follows the sliders. +fn watermark_preview( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + wm: &lightcraft_engine::export::Watermark, + resize: Option, +) { + use std::hash::{Hash, Hasher}; + // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) + type Base = std::sync::Arc>)>>; + type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); + let t = Tokens::get(ui.ctx()); + let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); + let export_short = out_w.min(out_h); + // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render + let step = 128.0; + let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, app.session.catalog.revision, px).hash(&mut h); + let key = h.finish(); + let ctx = ui.ctx().clone(); + let (base_id, shown_id) = (egui::Id::new("export-wm-preview-base"), egui::Id::new("export-wm-preview-shown")); + let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { + Some((k, base)) if k == key => base, + _ => { + let base: Base = Default::default(); + // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) + let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); + // the web build has no worker thread: a full render there would hold up the page + #[cfg(target_arch = "wasm32")] + let jobs: Vec<_> = jobs.into_iter().take(1).collect(); + if jobs.is_empty() { + *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); + } else { + let (out, repaint) = (base.clone(), ctx.clone()); + let work = move || { + for job in jobs { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run().rendered.map(|r| r.image))) + .unwrap_or_else(|_| Err("the preview failed unexpectedly".into())); + let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // a failed sharp render keeps the thumbnail + if r.is_ok() || !matches!(got.1, Some(Ok(_))) { + *got = (got.0.saturating_add(1), Some(r)); + repaint.request_repaint(); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { + log::warn!("watermark preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + } + ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); + base + } + }; + let shown = ctx.data(|d| d.get_temp::(shown_id)); + let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; + let tex = match shown { + Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, + _ => { + let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { + Some(Ok(img)) => Ok(img.clone()), + Some(Err(e)) => Err(Some(e.clone())), + None => Err(None), + }; + let mut img = match photo_px { + Ok(img) => img, + Err(e) => { + let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; + let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + if let Some(e) = e { + r.on_hover_text(e); + } + return; + } + }; + lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("display profile: {e}"); + } + let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = match shown { + Some((.., mut tex)) => { + tex.set(color, egui::TextureOptions::LINEAR); + tex + } + None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), + }; + ctx.data_mut(|d| d.insert_temp(shown_id, (key, stage, wm.clone(), export_short, tex.clone()))); + tex + } + }; + let size = tex.size_vec2(); + let scale = ui.available_width() / size.x.max(1.0); + let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; + crate::widgets::register(ui.ctx(), "image:exportWatermarkPreview", r.rect); +} + /// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`. pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")]; /// Help ▸ What's New (docs/whats-new.md). pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +/// The import review and Settings keep window ids of their own, so the size each is given +/// doesn't carry over to the other dialogs. fn window_id(dialog: &Dialog) -> egui::Id { - egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" }) + egui::Id::new(match dialog { + Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Settings { .. } => "lightcraft-settings-dialog", + _ => "lightcraft-dialog", + }) } /// Block the underlying panels from the first frame, before the window establishes its modal @@ -137,6 +254,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", + Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", Dialog::SynchronizeFolder { .. } => "Synchronize Folder", @@ -153,6 +271,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); let window_id = window_id(&dlg); + // Every other dialog fits the window (#781): its options scroll when they don't, and the title + // bar and the button row stay outside the scroll area. The room they take is measured on the + // previous frame (a guess on the first one). + let chrome_id = window_id.with("chrome"); + let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); + let body_max = (screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome).max(DIALOG_MIN_BODY); + let mut body_h = None; let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) @@ -165,11 +290,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, Dialog::FaceModel { .. } => 460.0, + Dialog::LightroomCatalog { .. } => 440.0, _ => 380.0, }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - match &mut dlg { + body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( @@ -198,7 +324,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } Dialog::WhatsNew => { - egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { for line in WHATS_NEW.lines() { let l = line.trim_end(); if let Some(h) = l.strip_prefix("### ") { @@ -258,7 +384,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !xmp.is_empty() { groups.push(("XMP".into(), xmp)); } - egui::ScrollArea::vertical().max_height(440.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { if groups.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); } @@ -285,7 +411,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let now = (app.session.clock)(); let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; - egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); }); let n = view.count; @@ -521,7 +647,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer."); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { - use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); // Preset: load a built-in or saved set of options into the dialog @@ -556,7 +682,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui, "Format", "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], + &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], &mut opts.format, ); if !opts.format.is_rendered() { @@ -578,8 +704,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); opts.bit_depth = Some(bd); } - if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit Rec. 2020 PQ; TIFF needs 32-bit float"; + if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png) { + let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit and PNG as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); if opts.hdr && !opts.hdr_output() { ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); @@ -588,6 +714,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !rendered { } else if opts.format == F::Avif && opts.hdr { ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + } else if opts.format == F::Png && opts.hdr { + ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) + .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { @@ -654,6 +783,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); + } if let Some(wm) = &mut opts.watermark { // text, or a graphic (a logo with transparency) let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::new("wm-graphic"))).unwrap_or(false); @@ -691,9 +824,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui, "Position", "exportWmAnchor", - &[(P::TopLeft, "↖"), (P::TopRight, "↗"), (P::Center, "•"), (P::BottomLeft, "↙"), (P::BottomRight, "↘")], + &WM_POSITIONS, &mut wm.anchor, ); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); let mut size = wm.size as f64 * 100.0; if !graphic && num(ui, &WM_SIZE, &mut size) { wm.size = (size / 100.0) as f32; @@ -702,10 +836,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if num(ui, &WM_OPACITY, &mut op) { wm.opacity = (op / 100.0) as f32; } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } if !graphic { ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); } } + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } } ui.add_space(4.0); if opts.format == F::Tiff { @@ -789,6 +938,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { export_synonyms, person, add_to_selected, + .. } => { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); // several fields: the name takes the focus when the dialog opens, not after @@ -907,6 +1057,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), + Dialog::LightroomCatalog { folder, catalog } => { + ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); + ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr( + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.", + )) + .color(t.text_dim), + ); + } Dialog::RemoveFolder { name, count, path, disk } => { ui.label(crate::i18n::tr_format!( "Remove “{name}” and its {count} photo{} from the library?", @@ -970,7 +1130,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), - } + }); ui.add_space(4.0); ui.horizontal(|ui| { // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that @@ -1018,6 +1178,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", + Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -1053,6 +1214,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); if let Some(w) = shown { ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); + if let Some(body_h) = body_h { + let measured = (w.response.rect.height() - body_h).max(0.0); + if (measured - chrome).abs() > 0.5 { + ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); + ctx.request_repaint(); + } + } ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } @@ -1087,6 +1255,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { *error = Some(e); } } + Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { + // albums hold library photos (issue #684): say which part of the selection stayed out + crate::panels::grid::toast_skipped(app, ctx, &r); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1102,6 +1275,66 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = if close { None } else { Some(dlg) }; } +/// Room kept free above and below a dialog (#781). +const DIALOG_SCREEN_MARGIN: f32 = 12.0; +/// A dialog's title bar, frame and button row before the first frame measures them. +const DIALOG_CHROME_GUESS: f32 = 100.0; +/// A dialog's options never get less room than this, however short the window. +const DIALOG_MIN_BODY: f32 = 80.0; + +/// A list that scrolls on its own never gets shorter than this to make its dialog fit. +const DIALOG_MIN_LIST: f32 = 120.0; + +fn list_shrink_id() -> egui::Id { + egui::Id::new("dialog-list-shrink") +} + +fn list_applied_id() -> egui::Id { + egui::Id::new("dialog-list-applied") +} + +/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the +/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so +/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. +pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { + let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); + ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); + height +} + +/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names +/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; +/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height +/// they took when scrolled. +fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { + let Some(name) = scroll else { + ui.data_mut(|d| d.remove_temp::(list_shrink_id())); + body(ui); + return None; + }; + ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); + let top = ui.cursor().top(); + // (an auto-sized window offers its content last frame's height; the minimum lets a dialog + // that grows take its full height at once, and the area still shrinks to fit its content) + let out = egui::ScrollArea::vertical() + .id_salt(("dialog-body", name)) + .max_height(max_height) + .min_scrolled_height(max_height) + .auto_shrink([true, true]) + .show(ui, body); + // what the lists that scroll on their own gave up this frame, and what they should next frame + let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); + let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let shrink = (out.content_size.y + applied - max_height).max(0.0); + if (shrink - was).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); + ui.ctx().request_repaint(); + } + // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) + Some(ui.min_rect().bottom() - top) +} + /// Apply a dialog's action (also used by `ui.dialog.confirm`). /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { @@ -1291,6 +1524,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { let mut params = json!({ "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), @@ -1317,7 +1551,11 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result Result crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it + Dialog::LightroomCatalog { catalog, .. } => { + app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) + } Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), @@ -1539,9 +1781,20 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex *ppi = p as u16; } } +/// All nine positions supported by both the text and graphic watermark renderer. +/// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. +const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { + use lightcraft_engine::export::Anchor::*; + [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] +}; + const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); +const WM_INSET_X: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; +const WM_INSET_Y: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; /// A themed slider row editing `v`; true when it changed. fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { @@ -1614,3 +1867,28 @@ fn just_opened(ui: &egui::Ui, key: &str) -> bool { ui.data_mut(|d| d.insert_temp(id, pass)); last.and_then(|l| l.checked_add(1)) != Some(pass) } + +#[cfg(test)] +mod watermark_position_tests { + use super::WM_POSITIONS; + use lightcraft_engine::export::Anchor; + + #[test] + fn export_dialog_offers_every_rendered_anchor_exactly_once() { + let anchors = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, + ]; + assert_eq!(WM_POSITIONS.len(), anchors.len()); + for anchor in anchors { + assert_eq!(WM_POSITIONS.iter().filter(|(position, _)| *position == anchor).count(), 1); + } + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index fcbd02412..f6c4424bf 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -82,6 +82,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let wb = &mut std::sync::Arc::make_mut(&mut d).wb; wb.temp = 6500.0; wb.tint = 0.0; + } else if raw { + // the Kelvin sliders show the white balance in effect: As Shot reads the file's as-shot white (the stored + // numbers can predate a decoder update, or the camera's matrices giving the file its Kelvin scale), a + // preset its Kelvin value, and a value written on the old relative scale the Kelvin it renders as + let info = app.session.source_info(id); + let (temp, tint) = lightcraft_engine::pipeline::local::effective_wb(&info, &d); + let wb = &mut std::sync::Arc::make_mut(&mut d).wb; + wb.temp = temp; + wb.tint = tint; } let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone()); @@ -92,6 +101,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { soft_proofing(app, ui, id); } // header: Edit + Auto / B&W / HDR + let mut reset_clicked = false; let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| { @@ -105,10 +115,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() { let _ = app.run("develop.treatment", json!({})); } + // HDR editing, next to B&W as Lightroom has it + if text_button(ui, "hdr", crate::i18n::tr("HDR"), d.hdr.enabled) + .on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays")) + .clicked() + { + let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked() { - let _ = app.run("develop.reset", json!({})); + reset_clicked = true; } }); }); @@ -259,7 +276,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.toggle_flyout("grading"); } if open { - grading(app, ui, d); + grading(app, ui, d, id); } ui.add_space(8.0); }); @@ -378,6 +395,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); }); ui.add_space(40.0); + // A pending numeric edit commits on blur before the explicit Reset takes precedence. + if reset_clicked { + let _ = app.run("develop.reset", json!({})); + } } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and @@ -613,18 +634,53 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); let cur = app.ui.proof.space; - egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(cur.label()).show_ui(ui, |ui| { + let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); + let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); + let mut pick = false; + egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { for s in OutputSpace::ALL { - if ui.selectable_label(cur == s, s.label()).clicked() { + if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { let _ = app.run("view.softProof", json!({"space": s})); } } + if let Some(n) = &device { + let _ = ui.selectable_label(true, n); + } + if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { + pick = true; + } }); + if pick + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + && let Err(e) = app.run("view.softProof", json!({"profile": path})) + { + app.toast(ui.ctx(), e); + } }); + if app.ui.proof_profile.is_some() { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); + use lightcraft_engine::proof::ProofIntent; + for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { + let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); + register(ui.ctx(), format!("button:proofIntent-{}", i.name()), r.rect); + if r.clicked() && app.ui.proof_intent != i { + let _ = app.run("view.softProof", json!({"intent": i.name()})); + } + } + }); + let mut paper = app.ui.proof_paper_ink; + let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); + register(ui.ctx(), "button:proofPaperInk", r.rect); + if r.changed() { + let _ = app.run("view.softProof", json!({"paperInk": paper})); + } + } let r = ui.button(crate::i18n::tr("Create Proof Copy")); register(ui.ctx(), "button:createProofCopy", r.rect); if r.clicked() { - let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label()); + let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); + let name = crate::i18n::tr_format!("Proof Copy ({})", target); if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); } @@ -1093,83 +1149,191 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) // ------------------------------------------------------------------------------ HDR /// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. +/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button +/// next to B&W): Visualize HDR range and the headroom limit. fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - let mut on = d.hdr.enabled; - if ui.checkbox(&mut on, "HDR").on_hover_text("Edit in HDR: highlights above SDR white, for HDR exports and displays").changed() { - let _ = app.run("develop.hdr", json!({"enabled": on})); - } - if d.hdr.enabled { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut v = app.ui.hdr_visualize; - if ui - .checkbox(&mut v, "Visualize HDR") - .on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it") - .changed() - { - app.ui.hdr_visualize = v; - } - }); - } - }); - }); - if d.hdr.enabled { - control(app, ui, d, "hdr.maxEv", true); + if !d.hdr.enabled { + return; } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { + let mut v = app.ui.hdr_visualize; + let r = ui.checkbox(&mut v, "Visualize HDR"); + register(ui.ctx(), "check:hdrVisualize", r.rect); + if r.on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it").changed() { + app.ui.hdr_visualize = v; + } + }); + control(app, ui, d, "hdr.maxEv", true); } // ------------------------------------------------------------------------------ colour grading -fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); +/// The colour grading tabs: key, tooltip, icon. +const GRADING_TABS: [(&str, &str, Icon); 4] = [ + ("3way", "3-Way", Icon::GradeAll), + ("shadows", "Shadows", Icon::GradeShadows), + ("midtones", "Midtones", Icon::GradeMidtones), + ("highlights", "Highlights", Icon::GradeHighlights), +]; + +/// The wheels, in the order of `UiState::grading_hidden`. +const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; + +fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { + let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (key, tip, icon) in GRADING_TABS { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, tip).clicked() { + app.ui.grading_mode = key.to_string(); + } + } + }); + }); let w = ui.available_width(); - let (area, _) = ui.allocate_exact_size(vec2(w, 250.0), Sense::hover()); - let big = 44.0; - let small = 38.0; - let wheels = [ - ("midtones", "Midtones", pos2(area.center().x, area.top() + 60.0), big), - ("shadows", "Shadows", pos2(area.left() + w * 0.28, area.top() + 170.0), small), - ("highlights", "Highlights", pos2(area.left() + w * 0.72, area.top() + 170.0), small), - ]; - for (key, label, c, rad) in wheels { - let wh = match key { - "shadows" => d.grading.shadows, - "midtones" => d.grading.midtones, - _ => d.grading.highlights, - }; - let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); - let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); - register(ui.ctx(), format!("wheel:{key}"), r); - paint_wheel(ui.painter(), c, rad); - let a = (wh.hue as f32).to_radians(); - let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - ui.painter().circle_filled(dot, 5.0, Color32::WHITE); - ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); - ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); - if resp.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + if mode == "3way" { + // three wheels of one size: midtones on top, shadows and highlights below + let rad = 40.0; + let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); + let at = [ + pos2(area.left() + w * 0.28, area.top() + 196.0), + pos2(area.center().x, area.top() + 70.0), + pos2(area.left() + w * 0.72, area.top() + 196.0), + ]; + for (i, c) in at.into_iter().enumerate() { + grading_wheel(app, ui, d, photo, i, c, rad, true); } - if (resp.dragged() || resp.clicked()) - && let Some(q) = resp.interact_pointer_pos() - { - let v = q - c; - let hue = ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; - let sat = (v.length() / rad * 100.0).min(100.0); - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { + control(app, ui, d, c, true); } - if resp.drag_stopped() || resp.clicked() { - let _ = app.run("develop.endInteraction", json!({})); - } - if resp.double_clicked() { - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } else { + let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); + let rad = (w * 0.3).clamp(40.0, 80.0); + let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); + grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); + for part in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("grading.{mode}.{part}"), true); } } - for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum", "grading.blending", "grading.balance"] { + for c in ["grading.blending", "grading.balance"] { control(app, ui, d, c, true); } } +/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and +/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect +/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while +/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. +#[allow(clippy::too_many_arguments)] +fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { + let t = Tokens::get(ui.ctx()); + let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; + let wh = match key { + "shadows" => d.grading.shadows, + "midtones" => d.grading.midtones, + _ => d.grading.highlights, + }; + let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); + let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); + // the corners (eye, readout) count as the wheel for hovering + let zone = r.expand(10.0); + let hovering = ui.rect_contains_pointer(zone); + let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + register(ui.ctx(), format!("wheel:{key}"), r); + paint_wheel(ui.painter(), c, rad); + if hidden { + ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); + } + // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye + if !readout { + for f in [0.25f32, 0.5, 0.75] { + let ring_rad = rad * f; + let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; + let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); + ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); + } + } + let a = (wh.hue as f32).to_radians(); + let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); + // Shift-drag: saturation only, the hue locked to the spoke the point is on (as in Lightroom). + // Pressing or releasing Shift mid-drag turns the lock on or off there. + let lock_id = egui::Id::new(("wheelHueLock", key)); + let shift = ui.input(|i| i.modifiers.shift); + let lock = match resp.interact_pointer_pos() { + Some(q) if resp.dragged() && shift => { + // a point at the centre has no hue yet: lock to where the pointer is + let start = if wh.sat >= 1.0 { wh.hue as f32 } else { wheel_pick(q - c, rad, None).0 }; + Some(ui.data_mut(|m| *m.get_temp_mut_or_insert_with(lock_id, || start))) + } + _ => { + ui.data_mut(|m| m.remove::(lock_id)); + None + } + }; + // while locked: the whole spoke, a guide for the saturation-only move + if let Some(h) = lock { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], Stroke::new(1.5, Color32::from_white_alpha(170))); + } + // the neutral centre (issue #559), under the point + ui.painter().circle_filled(c, 2.0, Color32::WHITE); + ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); + ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); + ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); + if resp.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + } + if (resp.dragged() || resp.clicked()) + && let Some(q) = resp.interact_pointer_pos() + { + let (hue, sat) = wheel_pick(q - c, rad, lock); + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + } + if resp.drag_stopped() || resp.clicked() { + let _ = app.run("develop.endInteraction", json!({})); + } + if resp.double_clicked() { + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } + // hue and saturation readout: centred above the wheel, shown like the eye (no fade) + if readout && (hovering || resp.dragged()) { + // set a little below the gap's midpoint: the eye reads it as centred there + ui.painter().text( + pos2(c.x, r.top() - 13.0), + Align2::CENTER_CENTER, + format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), + t.font(11.0), + t.text_label, + ); + } + // the eye: on top of the wheel, shown while hovering or while the wheel is hidden + if hovering || hidden { + let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); + let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); + register(ui.ctx(), format!("wheelEye:{key}"), eye); + paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); + let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); + if er.clicked() { + app.ui.set_grading_hidden(photo, i, !hidden); + } + } +} + +/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. +/// `lock`: keep this hue and take the saturation from `v` projected onto its spoke (Shift-drag). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { + let rad = rad.max(1.0); + match lock { + Some(h) => { + let a = h.to_radians(); + let along = v.x * a.cos() - v.y * a.sin(); + (h, (along / rad * 100.0).clamp(0.0, 100.0)) + } + None => (((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0, (v.length() / rad * 100.0).min(100.0)), + } +} + fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { let mut mesh = Mesh::default(); let n = 48; diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 13b4b1329..2b3fe0329 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -205,15 +205,25 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; } let mut on = list["enabled"].as_bool().unwrap_or(false); - if check(ui, "settings.facesEnabled", &mut on, "Recognise faces (experimental)") { - let _ = app.run("faces.enable", json!({"enabled": on})); - app.caches.faces_epoch += 1; + let runtime = list["runtime"].as_bool() == Some(true); + // a chosen recognition model that is installed: without one the switch has nothing to run + // (issue #678), so it can only be switched off, with the hint below saying what to do first + let usable = + list["embedder"].as_str().is_some_and(|id| list["models"].as_array().into_iter().flatten().any(|m| m["id"] == id && m["installed"] == true)); + let r = ui + .add_enabled(on || (runtime && usable), egui::Checkbox::new(&mut on, crate::i18n::tr("Recognise faces (experimental)"))) + .on_disabled_hover_text(crate::i18n::tr("Choose a model below (Use) to start.")); + register(ui.ctx(), "check:settings.facesEnabled", r.rect); + if r.changed() { + match app.run("faces.enable", json!({"enabled": on})) { + Ok(_) => app.caches.faces_epoch += 1, + Err(e) => app.toast(ui.ctx(), e), + } } hint(ui, t, "Suggests who is in a photo from the faces you have named. Everything stays on your computer."); - let runtime = list["runtime"].as_bool() == Some(true); if !runtime { hint(ui, t, "This build cannot run recognition models: they can be added and chosen, not used."); - } else if on && list["embedder"].is_null() { + } else if !usable { hint(ui, t, "Choose a model below (Use) to start."); } else if on && app.caches.faces_active { scan_progress(app, ui, t); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 4264e4290..a03fbe944 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -801,6 +801,32 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { pub use super::filterbar::label_color; +/// Albums hold library photos (issue #684): true when `ids` are photos and all of them are +/// browsed (Local) ones, which album views never show. +pub fn local_only(app: &LightcraftApp, ids: &[PhotoId]) -> bool { + !ids.is_empty() && ids.iter().all(|id| app.session.catalog.photo(*id).is_some_and(|p| p.local)) +} + +/// Why an album action is off for Local photos. +pub fn albums_hint() -> String { + crate::i18n::tr("Albums hold library photos: Add to My Photos first").to_string() +} + +/// Tell the user when an album command skipped Local photos of the selection (`skipped` in its +/// result). +pub fn toast_skipped(app: &mut LightcraftApp, ctx: &egui::Context, r: &serde_json::Value) { + if let Some(n) = r.get("skipped").and_then(serde_json::Value::as_u64).filter(|n| *n > 0) { + app.toast( + ctx, + crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if n == 1 { "" } else { "s" }, + n = n + ), + ); + } +} + /// "Set Color Label" items for the active photo, shared by context menus. pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label); @@ -881,6 +907,18 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Open in Detail")).clicked() { let _ = app.run("view.detail", json!({})); } + // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor + // (desktop only: the web can't start programs) + ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { + ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { + let _ = app.run("photo.editInPhotoCraft", json!({})); + } + if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { + let _ = app.run("photo.editInExternal", json!({})); + } + }); + }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { match app.run("library.findSimilar", json!({"id": id.0})) { Ok(r) => { @@ -913,14 +951,37 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { - let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); - for (aid, name) in albums { - if ui.button(name).clicked() { - let _ = app.run("album.addPhotos", json!({"id": aid})); + if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + // albums hold library photos (issue #684): nothing here to add until they join My Photos + let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); + register(ui.ctx(), "button:contextAddToAlbum", add.rect); + } else { + let add = ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { + let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); + let any = !albums.is_empty(); + for (aid, name) in albums { + let b = ui.button(name); + register(ui.ctx(), format!("button:contextAlbum:{aid}"), b.rect); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": aid})) { + Ok(r) => toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } } - } - }); + if any { + ui.separator(); + } + // as in the Photo menu: a new album holding the selection, also when there is none yet + // to pick (issue #676: the submenu came up empty) + let new = ui.button(crate::i18n::tr("New Album…")); + register(ui.ctx(), "button:contextNewAlbum", new.rect); + if new.clicked() { + let _ = app.run("dialog.newAlbum", json!({})); + } + }); + register(ui.ctx(), "button:contextAddToAlbum", add.response.rect); + } // in a (non-smart) album: take the selection out of it, or make this photo its cover if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source && app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart()) @@ -935,6 +996,22 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + let can_pick = app.services.pick_folder.is_some() || app.services.picker.is_some(); + if can_pick && ui.button(crate::i18n::tr("Move to Folder…")).clicked() { + // the folder dialog goes through the shared picker (off the UI thread where the host has + // one: then photo.moveToFolder runs when it closes) + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Move to Folder…")); + let picked = + crate::pick::ask(app, "photo.moveToFolder", &json!({}), "folder", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])); + if let crate::pick::Picked::Now(v) = picked + && let Some(into) = v.into_iter().next() + { + match app.run("photo.moveToFolder", json!({"folder": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { let _ = app.run("photo.reload", json!({})); } diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index eef65ef89..a844b7b24 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -58,7 +58,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase()); // a fixed height, so the dialog doesn't jump around while the search narrows the list - egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { + // (shorter on a short window, so the dialog fits it) + let h = crate::panels::dialogs::list_height(ui, 400.0); + egui::ScrollArea::vertical().max_height(h).min_scrolled_height(h).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| { for (b, sc) in &rows { row(app, ui, t, b, sc.as_deref(), mac); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index bf0daeb45..2d691112a 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -364,8 +364,14 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho /// Create Keyword Tag: inside the keyword picked in the list, else the default parent. pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { - let parent = - app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.catalog.default_keyword_parent()); + let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)); + create_dialog_inside(app, picked) +} + +/// Create Keyword Tag for a keyword inside `parent`, else inside the default parent (Put New +/// Keywords Inside This Keyword), else at the top level. +pub(crate) fn create_dialog_inside(app: &LightcraftApp, parent: Option) -> Dialog { + let parent = parent.or_else(|| app.session.catalog.default_keyword_parent()); let d = KeywordInfo::default(); Dialog::KeywordTag { editing: None, @@ -378,6 +384,7 @@ pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { export_synonyms: d.export_synonyms, person: d.person, add_to_selected: false, + reveal: false, } } @@ -396,6 +403,7 @@ pub(crate) fn edit_dialog(app: &LightcraftApp, path: &str) -> Dialog { export_synonyms: info.export_synonyms, person: info.person, add_to_selected: false, + reveal: false, } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 4f1bae44c..a14332c82 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::icons::{Icon, paint}; +use crate::sidebar::SidebarSection; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; @@ -101,7 +102,9 @@ fn row_sensed( ); let font = t.font(13.5); let color = if selected { t.text } else { t.text_label }; - let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim)); + // a count of nothing is there, but fainter: the rows that hold photos stand out + let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; + let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); let label_left = r.left() + 42.0 + indent; let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); // a label's dot sits just before the count @@ -143,38 +146,416 @@ fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, ope let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); let col = if tr.hovered() { t.text } else { t.text_dim }; - let pts = if open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); tr } -/// A collapsible section header (Albums, Local, By Date, Keywords): the bold title with a -/// disclosure chevron after it; a click folds or unfolds the section (kept in the UI state, so it -/// survives restarts). Returns the header's rect and whether the section is now open. -fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> (Rect, bool) { - let t = Tokens::get(ui.ctx()); - let title = crate::i18n::tr(title); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - register(ui.ctx(), format!("sidebarSection:{id}"), r); - if resp.clicked() { - app.ui.toggle_sidebar_section(id); +/// Whether the tree row `key` is open: what the user last chose this session, else `default`. +fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { + ui.data(|d| d.get_temp(key)).unwrap_or(default) +} + +/// Open or close the tree row `key` from now on. +fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { + ui.data_mut(|d| d.insert_temp(key, open)); +} + +/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response +/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). +fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { + let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget); + if tri.clicked() { + *open = !*open; + set_tree_open(ui, key, *open); } - let open = !app.ui.sidebar_section_collapsed(id); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - let text = ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); - let c = pos2(text.right() + 10.0, r.center().y); - let col = if resp.hovered() { t.text } else { t.text_dim }; - let pts = if open { + let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; + tri.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + tri +} + +/// A disclosure triangle around `c`: pointing down when open, right when closed. +fn triangle_points(c: egui::Pos2, open: bool) -> Vec { + if open { vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] } else { vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + } +} + +/// Space above a section's header. +const SECTION_GAP: f32 = 10.0; +/// Height of a section's header. +const SECTION_HEADER: f32 = 34.0; + +/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` +/// draws. Every section goes through here, so they all fold, space and announce themselves alike. +/// `rows`: how many rows the section lists at its top level (a folded header says so). +fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + section: SidebarSection, + rows: usize, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), +) -> PlacedSection { + ui.add_space(SECTION_GAP); + let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); + // a hairline in the gap above tells the sections apart + let t = Tokens::get(ui.ctx()); + let edge = visible_right(ui).min(header.right()); + let y = header.top() - SECTION_GAP / 2.0; + ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); + let show = egui::Id::new("left-show-section"); + if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) + && asked == section + { + ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); + // asked for by the frame before (a request the section was not drawn for is forgotten), + // and up or down only: the sidebar stays where it was scrolled sideways + if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { + let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-view-top"))).unwrap_or(header.top()); + let delta = vec2(0.0, view_top - header.top()); + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + let open = section_header(app, ui, section, header, rows, false).open; + if open { + let rows_from = ui.cursor().top(); + body(app, ui); + if ui.cursor().top() <= rows_from { + empty_hint(ui, section); + } + } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } +} + +/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider +/// than the panel while the sidebar scrolls sideways. +fn visible_right(ui: &egui::Ui) -> f32 { + ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX) +} + +/// The header of the open section whose own header has scrolled out of sight stays at the top of +/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows +/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The +/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. +fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { + let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); + let rect = pinned.map(|p| { + let top = view_top.min(p.bottom - SECTION_HEADER); + Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) + }); + // rows ask where it is (next frame: they are laid out before it) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-pinned-rect"), rect)); + let (Some(p), Some(r)) = (pinned, rect) else { return }; + let t = Tokens::get(ui.ctx()); + let view = ui.clip_rect(); + // over the rows scrolling beneath it + ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); + ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); + if section_header(app, ui, p.section, r, p.rows, true).toggled { + // folded from here: its own header comes into view, not whatever was below the section + // (next frame, when the section is laid out folded: see `section`) + let frame = ui.ctx().cumulative_frame_nr(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), (p.section, frame))); + ui.ctx().request_repaint(); + } +} + +/// What an open section with nothing to list says in place of its rows (widget +/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. +fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r); + let text = crate::i18n::tr(section.empty_hint()); + let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); + note_width(ui, 42.0 + galley.size().x + 18.0); + ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); +} + +/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PlacedSection { + pub section: SidebarSection, + pub top: f32, + pub bottom: f32, + /// Not folded: its rows (or its note that there are none) are drawn. + pub open: bool, + /// How many rows it lists at its top level. + pub rows: usize, +} + +/// What a section's header says happened to it this frame. +struct HeaderOutcome { + open: bool, + /// A click folded or unfolded the section. + toggled: bool, +} + +/// Where a section dragged to `y` would go among the sections drawn: above the first one whose +/// middle is below `y` (`Some`), or after them all (`None`). +pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { + placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) +} + +/// While a section is dragged by its header: a line shows where it would go, releasing over the +/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. +fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { + let Some(dragged) = app.ui.dragging_section else { return }; + let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if esc { + // the drag's Esc, not also a dialog's or a view's + crate::widgets::take_escape(ui.ctx()); + app.ui.dragging_section = None; + return; + } + let view = ui.clip_rect(); + let Some(pos) = pos.filter(|p| view.contains(*p)) else { + if released { + app.ui.dragging_section = None; + } + return; }; - ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); - (r, open) + let before = section_drop_before(placed, pos.y); + if released { + app.ui.dragging_section = None; + let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); + if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { + app.toast(ui.ctx(), e); + } + return; + } + // the line: in the gap above the section it would go before, or below the last one + let y = match before { + Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), + None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), + }; + if let Some(y) = y { + let t = Tokens::get(ui.ctx()); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); + ui.painter().rect_filled(line, 1.0, t.accent); + register(ui.ctx(), "sidebarSectionDrop", line); + } +} + +/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the +/// sidebar, or with the sidebar closed) is over. +pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(section) = app.ui.dragging_section else { return }; + let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + if !down || !app.ui.left_panel { + app.ui.dragging_section = None; + return; + } + let Some(pos) = pos else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::new("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); +} + +/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their +/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at +/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo +/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { + let t = Tokens::get(ui.ctx()); + let id = section.id(); + let title = crate::i18n::tr(section.title()); + let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; + let resp = ui.interact(r, ui.id().with((interact, id)), Sense::click_and_drag()); + register(ui.ctx(), format!("{widget}:{id}"), r); + let toggled = resp.clicked(); + if toggled { + app.ui.sidebar.toggle_collapsed(section); + } + // dragged by its header, a section moves among the others (see `section_drag`); a press on + // the header's button is the button's, also when it turns into a drag + let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); + if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { + // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned)); + app.ui.dragging_section = Some(section); + } + resp.context_menu(|ui| sections_menu(app, ui, Some(section))); + let open = !app.ui.sidebar.is_collapsed(section); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); + // what is visible of the header: its bar and count end at the panel's edge, like a row's + let edge = r.right().min(visible_right(ui)); + let hovered = resp.hovered() && app.ui.dragging_section.is_none(); + if hovered { + let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); + } + let c = pos2(r.left() + 10.0, r.center().y); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + let col = if hovered { t.text } else { t.text_dim }; + ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let button = header_button(section); + // (an album being dragged puts "Top Level" in the Albums header) + let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); + if !open && rows > 0 && app.ui.show_counts && room { + // folded: how much is in there (left of the header's own button) + let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; + let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); + register(ui.ctx(), format!("{widget}Count:{id}"), count); + } + if section == SidebarSection::Albums { + top_level_drop_target(app, ui, r); + } + if let Some(button) = button { + show_header_button(app, ui, r, &button); + } + HeaderOutcome { open, toggled } +} + +/// Side of a header's button. +const HEADER_BUTTON: f32 = 26.0; + +/// A button at the end of a section's header (see [`header_button`]). +struct HeaderButton { + /// Its widget is `icon:`. + id: &'static str, + icon: Icon, + /// What it does, in English: its tooltip and spoken name. + tooltip: &'static str, + action: HeaderAction, +} + +/// What a header's button does when clicked. +enum HeaderAction { + /// Does it at once (usually: opens a dialog). + Run(fn(&mut LightcraftApp, &egui::Context)), + /// Opens a menu with these items. + Menu(fn(&mut LightcraftApp, &mut egui::Ui)), +} + +/// The button a section has at the end of its header, if it has one. This is the one place that +/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps +/// its clicks from folding the section and leaves it room beside a folded section's count. +fn header_button(section: SidebarSection) -> Option { + match section { + SidebarSection::Albums => { + Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) + } + SidebarSection::Keywords => { + Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) + } + SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, + } +} + +/// The end of `header` kept for its button: at the visible edge, also when the sidebar is +/// scrolled sideways. +fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { + let right = visible_right(ui).min(header.right()); + Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) +} + +/// Draw `button` at the end of `header` and act on it. +fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { + let mut hdr = + ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); + let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); + match button.action { + HeaderAction::Run(run) => { + if resp.clicked() { + run(app, ui.ctx()); + } + } + HeaderAction::Menu(items) => { + egui::Popup::menu(&resp).show(|ui| items(app, ui)); + } + } +} + +/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside +/// whatever is picked in the Keyword List, which may not even be on screen). +/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). +fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { + let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); + if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { + *reveal = true; + } + app.ui.dialog = Some(dialog); +} + +/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, +/// show or hide any section, and go back to the usual arrangement. Its items are widgets +/// `sidebarMenu:hide`, `sidebarMenu:section:` and `sidebarMenu:reset`. +fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { + let mut run = None; + if let Some(section) = on { + let hide = ui.button(crate::i18n::tr("Hide This Section")); + register(ui.ctx(), "sidebarMenu:hide", hide.rect); + if hide.clicked() { + run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false}))); + } + for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] { + let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), widget, item.rect); + if item.clicked() { + run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }}))); + } + } + ui.separator(); + } + for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) { + let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title())); + register(ui.ctx(), format!("sidebarMenu:section:{}", s.id.id()), shown.rect); + if shown.clicked() { + run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden}))); + } + } + ui.separator(); + let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections"))); + register(ui.ctx(), "sidebarMenu:reset", reset.rect); + if reset.clicked() { + run = Some(("view.sidebarReset", json!({}))); + } + if let Some((command, params)) = run { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// The menu of Albums' +: Create Album, Smart Album, Folder… +fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { + if ui.button(crate::i18n::tr("Create Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None }); + } + if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: None, + }); + } + if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); + } + if ui.button(crate::i18n::tr("Create Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); + } + // only once the albums were put in an order by hand + if app.session.catalog.album_children_are_ordered(None) { + ui.separator(); + if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { + let _ = app.run("album.sort", json!({})); + } + } } pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { @@ -182,12 +563,34 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + // the title offers the sections' menu too: where hidden sections come back from + let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); + register(ui.ctx(), "sidebarTitle", hr); + title.context_menu(|ui| sections_menu(app, ui, None)); + // the open library's name under the title, so nobody edits in the wrong one (issue #902) + let library = crate::panels::settings::open_library_name(app); + let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y }; + ui.painter().text(pos2(hr.left() + 18.0, title_y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + if let Some(name) = library { + ui.painter().with_clip_rect(hr.shrink2(vec2(18.0, 0.0))).text( + pos2(hr.left() + 18.0, hr.center().y + 10.0), + Align2::LEFT_CENTER, + name, + t.font(11.0), + t.text_dim, + ); + crate::widgets::register(ui.ctx(), "label:libraryName", hr); + if let Some(l) = app.session.library.as_ref() { + title.on_hover_text(l.dir.display().to_string()); + } + } let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { drag_auto_scroll(app, ui); + // where the visible part of the content starts (screen y), for the pinned header + let view_top = ui.cursor().top() + viewport.min.y; + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-view-top"), view_top)); // rows are as wide as the widest one needs (last frame), at least the panel let wide = viewport.width().max(content_width(ui.ctx())); ui.set_min_width(wide); @@ -214,83 +617,64 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { let _ = app.run("library.source", json!({"kind": "missing"})); } - ui.add_space(10.0); - // Albums header - let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums"); - top_level_drop_target(app, ui, ar); - // the + stays at the visible edge when the sidebar is scrolled sideways - let plus_right = (ar.left() + viewport.max.x).min(ar.right()); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(plus_right - 50.0, ar.top()), pos2(plus_right, ar.bottom()))) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); - egui::Popup::menu(&plus).show(|ui| { - if ui.button(crate::i18n::tr("Create Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None }); - } - if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: None, - }); - } - if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); - } - if ui.button(crate::i18n::tr("Create Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); - } - // only once the albums were put in an order by hand - if app.session.catalog.album_children_are_ordered(None) { - ui.separator(); - if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { - let _ = app.run("album.sort", json!({})); - } - } - }); - // the album just made: the folders down to it open, once (also when the section is shut) - let reveal = app.ui.reveal_album.take(); - if albums_open { - let kids: AlbumKids = - app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); - let cat = &app.session.catalog; - let mut open_to = Vec::new(); - let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); - while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { - open_to.push(p); - cur = cat.album(p).and_then(|a| a.parent); - } - albums_tree(app, ui, &kids, None, 0.0, &open_to); - } - ui.add_space(10.0); - local_section(app, ui); - // By date - let (_, dates_open) = sidebar_section_header(app, ui, "byDate", "By Date"); - let groups = if dates_open { app.caches.date_groups(&app.session.catalog) } else { Default::default() }; - for g in groups.iter() { - // year → month → day; a click filters by that prefix, the triangle opens a level - if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { - for (m, n) in &g.months { - let label = crate::i18n::date_group_label(m, true); - if date_row(app, ui, m, &label, *n, 16.0) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = crate::i18n::date_group_label(d, true); - date_row(app, ui, d, &label, *n, 32.0); - } - } - } - } - } - folders_section(app, ui); - keywords_section(app, ui); - ui.add_space(10.0); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } + // the album just made: the folders down to it open, once (also when the section is shut) + let reveal = app.ui.reveal_album.take(); + if reveal.is_some() { + // …and Albums itself is shown and opened, so the new album can be seen + app.ui.sidebar.reveal(SidebarSection::Albums); + } + // the keyword just made from Keywords' header: the section and the keywords above it open + if let Some(path) = app.ui.reveal_keyword.take() { + app.ui.sidebar.reveal(SidebarSection::Keywords); + let mut above = String::new(); + let mut names = path.split('|').peekable(); + while let Some(name) = names.next().filter(|_| names.peek().is_some()) { + if !above.is_empty() { + above.push('|'); + } + above.push_str(name); + set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true); + } + } + // Local keeps track of the folder being browsed whether or not it is drawn + let local = local_now(app, ui); + let shown: Vec = app.ui.sidebar.shown().collect(); + let mut placed: Vec = Vec::with_capacity(shown.len()); + for s in shown { + let at = match s { + SidebarSection::Albums => { + let rows = app.session.catalog.albums().filter(|a| a.parent.is_none()).count(); + section(app, ui, s, rows, |app, ui| albums_rows(app, ui, reveal)) + } + SidebarSection::Local => { + let Some(local) = &local else { continue }; + section(app, ui, s, local.places.places.len(), |app, ui| local_rows(app, ui, local)) + } + SidebarSection::ByDate => { + let rows = app.caches.date_groups(&app.session.catalog).len(); + section(app, ui, s, rows, date_rows) + } + SidebarSection::Folders => { + let tree = app.caches.folder_tree(&app.session.catalog); + section(app, ui, s, tree.len(), |app, ui| { + reveal_chosen(app, ui, &tree); + folder_rows(app, ui, &tree, 0.0); + }) + } + SidebarSection::Keywords => { + let tree = app.caches.keyword_tree(&app.session.catalog); + section(app, ui, s, tree.len(), |app, ui| keyword_rows(app, ui, &tree, 0.0)) + } + }; + placed.push(at); + } + // room below the last section (a section dragged to the end is dropped here) + ui.add_space(SECTION_GAP * 2.0); + pinned_header(app, ui, &placed, view_top); + section_drag(app, ui, &placed); // what the rows asked for becomes next frame's width let next = ui.data(|d| d.get_temp::(egui::Id::new("left-content-width-next"))).unwrap_or(0.0); if (next - content_width(ui.ctx())).abs() > 0.5 { @@ -301,6 +685,38 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, +/// whose folders open down to it. +fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { + let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); + let cat = &app.session.catalog; + let mut open_to = Vec::new(); + let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); + while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { + open_to.push(p); + cur = cat.album(p).and_then(|a| a.parent); + } + albums_tree(app, ui, &kids, None, 0.0, &open_to); +} + +/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. +fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let groups = app.caches.date_groups(&app.session.catalog); + for g in groups.iter() { + if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { + for (m, n) in &g.months { + let label = crate::i18n::date_group_label(m, true); + if date_row(app, ui, m, &label, *n, 16.0) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = crate::i18n::date_group_label(d, true); + date_row(app, ui, d, &label, *n, 32.0); + } + } + } + } + } +} + /// How many library photos have no file (Local browse records are not checked; see /// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes /// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed @@ -341,13 +757,20 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { job.n.load(Relaxed) } +/// Local's folders as they stand this frame, and the folder being browsed among them. +struct LocalNow { + places: LocalPlaces, + current: Option, +} + /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, -/// Downloads, the home folder, the folder being browsed, and Browse Folder…. -fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - if cfg!(target_arch = "wasm32") { - return; +/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse +/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this +/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). +fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { + if !SidebarSection::Local.available() { + return None; } - let (_, open) = sidebar_section_header(app, ui, "local", "Local"); let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); let mut builtin: Vec<(String, String)> = Vec::new(); if !home.is_empty() { @@ -362,18 +785,20 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); - let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); + let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { - app.ui.local_browse_root = local.browse_root.clone(); - } - if !open { - ui.add_space(10.0); - return; + app.ui.local_browse_root = places.browse_root.clone(); } + Some(LocalNow { places, current }) +} + +/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. +fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { + let (local, current) = (&now.places, now.current.as_deref()); for (i, (name, path)) in local.places.iter().enumerate() { let transient = local.browse_root.as_deref() == Some(path.as_str()); let reveal = local.owner == Some(i); - folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient); + folder_tree(app, ui, name, path, 0.0, current, reveal, transient); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() { @@ -399,7 +824,6 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("local.restoreHidden", json!({})); } } - ui.add_space(10.0); } /// Whether two paths name the same folder, however they are spelled (separators, trailing @@ -580,25 +1004,21 @@ fn folder_tree( let open_id = egui::Id::new(("folder-open", path.to_string())); let sel = current.is_some_and(|c| same_folder(c, path)); let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); if reveal && on_the_way { // opened once per browsed folder: collapsing it again afterwards sticks let revealed_id = egui::Id::new(("folder-revealed", path.to_string())); let target = current.map(str::to_string); if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { open = true; - ui.data_mut(|d| { - d.insert_temp(open_id, true); - d.insert_temp(revealed_id, target); - }); + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("folder-tri", path.to_string())), format!("folderToggle:{path}")); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } else if resp.clicked() + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() && let Err(e) = app.run("library.browse", json!({"path": path})) { app.toast(ui.ctx(), e); @@ -658,15 +1078,11 @@ fn folder_tree( /// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { let open_id = egui::Id::new(("date-open", key.to_string())); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); if key.len() < 10 { - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("date-tri", key.to_string())), format!("dateToggle:{key}")); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } if resp.clicked() { let v = if sel { serde_json::Value::Null } else { json!(key) }; @@ -694,10 +1110,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare for a in kids { if a.folder { let open_id = egui::Id::new(("folder-open", a.id.0)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let mut open = tree_open(ui, open_id, true); if open_to.contains(&a.id) && !open { open = true; - ui.data_mut(|d| d.insert_temp(open_id, true)); + set_tree_open(ui, open_id, true); } let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); @@ -707,20 +1123,23 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare app.ui.dragging_album = Some(a.id.0); } if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { - ui.data_mut(|d| d.insert_temp(open_id, true)); + set_tree_open(ui, open_id, true); } - let mut toggled = resp.clicked(); + // what this frame draws; a click shows from the next one + let draw_open = open; + let mut toggled = false; if has_children { - let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); - toggled |= tri.clicked(); + let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); + toggled = tri.clicked(); // the triangle sits on the row and takes its clicks: the menu opens from it too folder_menu(app, &tri, a); } - if toggled { - ui.data_mut(|d| d.insert_temp(open_id, !open)); + // one click, one toggle: the triangle's is not the row's as well + if resp.clicked() && !toggled { + set_tree_open(ui, open_id, !open); } folder_menu(app, &resp, a); - if open { + if draw_open { albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to); } } else { @@ -774,6 +1193,22 @@ fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) } +/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the +/// pointer there is over the header, not over the row. +fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { + pointer_over(ui, rect) && !pointer_on_pinned_header(ui) +} + +/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). +fn pinned_rect(ui: &egui::Ui) -> Option { + ui.data(|d| d.get_temp::>(egui::Id::new("left-pinned-rect"))).flatten() +} + +fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { + let pos = ui.input(|i| i.pointer.latest_pos()); + pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) +} + /// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. const SCROLL_EDGE: f32 = 36.0; /// The fastest the sidebar scrolls for a drag (points per second). @@ -791,11 +1226,20 @@ pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { /// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call /// inside the scroll area. fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { - if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { return; } let view = ui.clip_rect(); let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + // a section picked up by its pinned header starts inside the top edge: it scrolls once it has + // left the header. Any other drag scrolls there as at any edge, to reach what is above + let from_pinned = egui::Id::new("left-section-drag-from-pinned"); + if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { + if pointer_on_pinned_header(ui) { + return; + } + ui.data_mut(|d| d.insert_temp(from_pinned, false)); + } let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); if speed != 0.0 { // a positive delta moves the content down: the view goes up @@ -908,7 +1352,7 @@ fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f3 fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { let hover_id = egui::Id::new(("album-hover", row.id.0)); let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over(ui, resp.rect)); + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); let Some(pos) = pos else { if ui.data(|d| d.get_temp::(hover_id)).is_some() { ui.data_mut(|d| d.remove_temp::(hover_id)); @@ -1006,16 +1450,41 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { /// release there adds them. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { let Some(ids) = app.ui.dragging_photos.clone() else { return }; - let over = pointer_over(ui, resp.rect); + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + let over = pointer_over_row(ui, resp.rect); if !over { return; } + let released = ui.input(|i| i.pointer.any_released()); + // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't + // light up, and a release says why + let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); + if super::grid::local_only(app, &photos) { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), super::grid::albums_hint()); + app.ui.dragging_photos = None; + } + return; + } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - let n = ids.len(); + if released { match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n)), + Ok(r) => { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + )); + } + app.toast(ui.ctx(), msg); + } Err(e) => app.toast(ui.ctx(), e), } app.ui.dragging_photos = None; @@ -1047,8 +1516,17 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } ui.separator(); } - if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { - let _ = app.run("album.addPhotos", json!({"id": a.id.0})); + if !a.folder && !a.is_smart() { + let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let b = ui + .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) + .on_disabled_hover_text(super::grid::albums_hint()); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": a.id.0})) { + Ok(r) => super::grid::toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); @@ -1145,22 +1623,10 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } -/// "Folders": where on disk the library's photos were imported from, with photo counts (see -/// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a -/// Local folder: its photos and those of the folders inside it fill the grid. The triangle opens -/// a level. Only folders holding imported photos are -/// listed; every folder on disk is under Local. -fn folders_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let tree = app.caches.folder_tree(&app.session.catalog); - if tree.is_empty() { - return; - } - ui.add_space(10.0); - if sidebar_section_header(app, ui, "folders", "Folders").1 { - reveal_chosen(app, ui, &tree); - folder_rows(app, ui, &tree, 0.0); - } -} +// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see +// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local +// folder: its photos and those of the folders inside it fill the grid. The triangle opens a +// level. Only folders holding imported photos are listed; every folder on disk is under Local. /// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows /// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. @@ -1193,7 +1659,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], // a disk starts open, and so does a folder that holds nothing itself and leads to one // folder (`Users` → `me`): the first row where the library branches is what you look for let leads_on = n.own == 0 && n.children.len() == 1; - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume || leads_on); + let mut open = tree_open(ui, open_id, n.volume || leads_on); // a row whose path would cover other disks' photos too only opens and closes let selectable = n.selectable; let sel = selectable @@ -1210,11 +1676,8 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); let mut toggled = false; if !n.children.is_empty() { - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), format!("libraryFolderToggle:{}", n.path)); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path)); toggled = tr.clicked(); - tr.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }) - }); // the triangle sits on the row and takes its clicks: the menu opens from it too row_menu(app, &tr, n); } @@ -1225,13 +1688,10 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], // a source like an album or a Local folder: it replaces what the grid showed let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); } else { - toggled = true; + open = !open; + set_tree_open(ui, open_id, open); } } - if toggled { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } row_menu(app, &resp, n); if open && !n.children.is_empty() { folder_rows(app, ui, &n.children, indent + 16.0); @@ -1320,6 +1780,30 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F } ui.close(); } + if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), + hint: "Folder name (created on disk)".into(), + value: String::new(), + command: "folder.create".into(), + params: json!({"parent": path}), + key: "name".into(), + }); + ui.close(); + } + let selected = app.session.selection.ids.len(); + if selected > 0 + && ui + .button(crate::i18n::tr("Move Selected Photos Here")) + .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) + .clicked() + { + match app.run("photo.moveToFolder", json!({"folder": path})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), + Err(e) => app.toast(ui.ctx(), e), + } + ui.close(); + } if app.services.reveal.is_some() && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() && let Some(f) = app.services.reveal.as_mut() @@ -1344,30 +1828,14 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. -fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let tree = app.caches.keyword_tree(&app.session.catalog); - if tree.is_empty() { - return; - } - ui.add_space(10.0); - if sidebar_section_header(app, ui, "keywords", "Keywords").1 { - keyword_rows(app, ui, &tree, 0.0); - } -} - fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { for n in nodes { let open_id = egui::Id::new(("kw-open", n.path.to_lowercase())); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); if !n.children.is_empty() { - let tr = - disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("kw-tri", n.path.to_lowercase())), format!("keywordToggle:{}", n.path)); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path)); } let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1c5a04ad9..501a78c5e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -144,7 +144,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.horizontal(|ui| { ui.label(crate::i18n::tr("Aspect Ratio")); - let cur = aspect_label(d.crop.aspect, original); + let cur = aspect_label(app.crop_tool_aspect(), original); let t = Tokens::get(ui.ctx()); let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); @@ -157,7 +157,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.separator(); custom_aspect(app, ui); }); - let locked = d.crop.aspect.is_some(); + let locked = app.crop_tool_aspect().is_some(); if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); } @@ -343,7 +343,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { header(ui, "Remove"); padded(ui, |ui| { ui.horizontal_wrapped(|ui| { - for (label, tool) in [("Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] { + for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] { if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() { app.ui.tool = tool.into(); } @@ -383,6 +383,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::SpotMode::Heal => "Heal", lightcraft_develop::SpotMode::Clone => "Clone", lightcraft_develop::SpotMode::Remove => "Remove", + lightcraft_develop::SpotMode::Fill => "Content-Aware", }; super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode)); } @@ -669,6 +670,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) + .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) + .clicked() + && let Err(e) = app.run("metadata.reverseGeocode", json!({})) + { + app.toast_error(ui.ctx(), e); + } } if let Some(a) = p.analysis { let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness); @@ -686,12 +694,11 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let lightcraft_catalog::Source::File { path } = &p.source { let avail = &app.session.media.availability; let online = !avail.is_offline(path); - let smart = app - .session - .media - .smart_dir - .as_ref() - .is_some_and(|d| avail.exists(&d.join(lightcraft_engine::smart::file_name(&p)).to_string_lossy()) == Some(true)); + let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| { + let shard = lightcraft_engine::smart::sharded_file_path(d, &p); + let legacy = d.join(lightcraft_engine::smart::file_name(&p)); + avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true) + }); if !online || smart { let text = match (online, smart) { (false, true) => "Original offline · editing the smart preview", diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3f59df629..b271f13cd 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -39,9 +39,8 @@ const LABEL_W: f32 = 150.0; /// The dialog body for `tab` (the tab bar switches `tab`). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { let t = Tokens::get(ui.ctx()); - ui.set_min_width(560.0); - ui.set_min_height(330.0); - ui.horizontal(|ui| { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { @@ -126,6 +125,11 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if r.clicked() { let _ = app.run("app.openLibrary", json!({})); } + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); + register(ui.ctx(), "button:settingsNewLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.newLibrary", json!({})); + } if !can { hint(ui, t, crate::i18n::tr("not available here")); } @@ -142,19 +146,47 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - heading(ui, t, crate::i18n::tr("External Editor")); - row(ui, t, crate::i18n::tr("Application"), |ui| { - let r = ui - .add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0)); - register(ui.ctx(), "field:externalEditor", r.rect); + heading(ui, t, crate::i18n::tr("External Editing")); + row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { + crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) + .hint(crate::i18n::tr("Found automatically")) + .width(220.0) + .show(ui); }); + edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); hint( ui, t, crate::i18n::tr( - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", ), ); + row(ui, t, crate::i18n::tr("Other editor"), |ui| { + crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) + .hint(crate::i18n::tr("System default")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); + hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); +} + +/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). +fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { + use crate::state::{EditFormat, EditSpace}; + row(ui, t, "", |ui| { + choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); + ui.separator(); + choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); + }); + row(ui, t, "", |ui| { + choices( + ui, + &format!("{id}Space"), + &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], + &mut file.color_space, + ); + }); } // -------------------------------------------------------------------------------------- Import @@ -544,12 +576,16 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Monitor profile")); let current = app.renderer.display().cloned(); + let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); row(ui, t, crate::i18n::tr("Profile"), |ui| { let name = match ¤t { Some(d) => d.profile.description.clone(), None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), }; ui.label(RichText::new(name).color(t.text)); + if screen { + hint(ui, t, crate::i18n::tr("this screen's profile")); + } }); row(ui, t, "", |ui| { let can = app.services.pick_display_profile.is_some(); @@ -560,7 +596,9 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { { app.ui.settings.display_profile = path; } - let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(crate::i18n::tr("Use sRGB"))); + // with no profile chosen, previews follow the screen's own profile where the host knows it + let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; + let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); register(ui.ctx(), "button:settingsDisplayProfileNone", r.rect); if r.clicked() { app.ui.settings.display_profile.clear(); @@ -574,9 +612,11 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } if let Some(d) = ¤t { let info = d.describe(); - row(ui, t, crate::i18n::tr("File"), |ui| { - ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim)); - }); + if !screen { + row(ui, t, crate::i18n::tr("File"), |ui| { + ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim)); + }); + } row(ui, t, crate::i18n::tr("Type"), |ui| { let kind = match d.profile.kind { lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), @@ -603,6 +643,8 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); @@ -744,10 +786,238 @@ fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ); } +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + favorites: &mut Vec, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + for (v, kind) in options { + let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let favorite = favorites.iter().any(|id| id == kind.id()); + let r = ui + .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) + .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!( + "{}: {}", + crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), + crate::i18n::tr(kind.label()) + ), + ) + }); + register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); + if favorite || hovered { + crate::icons::paint( + ui.painter(), + r.rect.shrink(4.0), + if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, + if favorite { t.accent } else { t.text_dim }, + ); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != kind.id()); + } else { + favorites.push(kind.id().into()); + } + } + }); + }); + } + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, /// or a folder chosen in a dialog. Remembered as the library to open at launch. +/// +/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` +/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays +/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false, +/// lightroomCatalog: <.lrcat path or null>, path}`. pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { if crate::lightroom_import::is_running(app) { return Err("wait for Lightroom catalog import to finish before switching libraries".into()); @@ -766,21 +1036,187 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result } } }; + // A Lightroom catalog folder (or part of one) is not a library: before closing the library + // open now, offer to import it instead (issue #891). The engine refuses it too. + if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) { + let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned()); + let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) }; + // the library problem window (no library could be opened) shows the error instead + if app.library_problem.is_some() { + return Err(err.to_string()); + } + app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); + return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); + } + // a remembered library that is gone: say so (and forget it) instead of creating an empty one there + if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { + app.ui.settings.recent_libraries.retain(|r| r != &path); + return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); + } + switch_library(app, &path) +} + +/// Close the current library and open the one at `path`. If it can't be opened (locked by +/// another program, unreadable, a file), the error is returned and the current library stays open: +/// the target is checked before anything is closed, and if opening it still fails the previous +/// library is opened again (both errors are reported if that fails too). +fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { + let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + let path = dir.to_string_lossy().into_owned(); + // the library being left goes in the list too (also when it was opened at launch) + let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); app.session.close_library().map_err(|e| e.to_string())?; - app.session.open_library(&path, false).map_err(|e| e.to_string())?; + let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None; app.renderer.forget_all(); app.ui.compare = None; + if let Err(err) = opened { + let Some(prev) = previous_dir else { return Err(err) }; + return match app.session.open_library(&prev, false) { + Ok(_) => Err(err), + Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), + }; + } app.ui.settings.library_path = path.clone(); - Ok(json!({"path": path, "photos": app.session.catalog.len()})) + if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { + remember_library(&mut app.ui.settings.recent_libraries, &prev); + } + remember_library(&mut app.ui.settings.recent_libraries, &path); + Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) +} + +/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. +pub fn remember_library(recent: &mut Vec, path: &str) { + recent.retain(|r| r != path); + recent.insert(0, path.to_string()); + recent.truncate(crate::state::RECENT_LIBRARIES); +} + +/// A library's name as people see it: its folder's name. +pub fn library_name(dir: &std::path::Path) -> String { + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) +} + +/// The open library's name (`None`: in memory, nothing is saved). +pub fn open_library_name(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) +} + +/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library +/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, +/// `path` itself, or a new folder named after the default library. The folder must not hold a +/// library already, nor anything else. +pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(valid_library_name(s)?), + Some(_) => return Err("app.newLibrary: name must be a string".into()), + }; + // from the menu: ask for the name first (default prefilled), then for the location + if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { + crate::panels::dialogs::prompt( + app, + "New Library", + "Library name", + lightcraft_engine::library::DEFAULT_NAME, + "app.newLibrary", + json!({}), + "name", + ); + return Ok(Value::Null); + } + let (location, picked) = match p.get("path").and_then(Value::as_str) { + Some(x) => (std::path::PathBuf::from(x), false), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); + match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => (std::path::PathBuf::from(x), true), + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + let dir = match name { + Some(n) => location.join(n), + // a chosen location gets a new folder in it, named after the default library + None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), + None => location, + }; + if lightcraft_engine::library::is_library(&dir) { + return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); + } + if dir.is_file() { + return Err(format!("{}: is a file; a library is a folder", dir.display())); + } + if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { + return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; + switch_library(app, &dir.to_string_lossy()) +} + +/// Characters a folder name can't hold on Windows (and `/`, which no system allows). +const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +fn valid_library_name(s: &str) -> Result<&str, String> { + let n = s.trim(); + // Windows drops trailing dots and spaces from a folder name: refuse them rather than + // creating a folder whose name differs from the one asked for (spaces around the name + // are trimmed first) + let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); + if bad { + return Err(format!( + "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" + )); + } + Ok(n) +} + +/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. +fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { + let first = location.join(base); + if !first.exists() { + return first; + } + (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as +/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` +/// forgets them all. +pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + if p.get("clear").and_then(Value::as_bool) == Some(true) { + app.ui.settings.recent_libraries.clear(); + } + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let list: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + }) + .collect(); + Ok(Value::Array(list)) } // ---------------------------------------------------------------------------- Display profile /// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile -/// (`""` or `null`: none, the display is treated as sRGB) and keep it as the setting; no `path`: -/// report only. A file that can't be used is an error and changes nothing. Returns the profile in -/// use: `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`. +/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it +/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report +/// only. A file that can't be used is an error and changes nothing. Returns the profile in use: +/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with +/// `path: null, screen: true` for the screen's profile. pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { let path = match p.get("path") { None => None, @@ -794,8 +1230,19 @@ pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { + let mut v = d.describe(); + v["path"] = Value::Null; + v["screen"] = json!(true); + v + } Some(d) => d.describe(), None => json!({"path": null, "description": "sRGB (no display profile)"}), }) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 6462c87d6..a95805f83 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,7 +53,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + // room for the filter icon and the six icons on the right + let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; @@ -187,6 +188,50 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } x -= 40.0; } + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + register(ui.ctx(), "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + resp.context_menu(|ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui.ctx(), "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); + } + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); + ui.close(); + } + } + } + }); let _ = Align2::CENTER_CENTER; }); } + +/// The appearance button's icon and the mode's name for its tooltip. +pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static str) { + use crate::state::AppearanceMode; + match mode { + AppearanceMode::Auto => (Icon::Monitor, "Sync with System"), + AppearanceMode::Light => (Icon::Sun, "Light Mode"), + AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), + } +} diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe47f97b..8fe29e135 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -25,7 +25,7 @@ pub struct RegionView { pub key: u64, pub full: (usize, usize), pub window: PixelWindow, - /// Hash of the develop settings it was rendered with. + /// Hash of the render state it was rendered with (develop settings plus any window-safe overlay). pub settings: u64, } @@ -72,9 +72,9 @@ pub struct ViewSizes { pub texture_side: usize, /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, - /// Whether a window can be drawn over the whole-frame render (not with an overlay, soft - /// proofing, or a read too far for one window): if not, the whole frame is rendered at the - /// size it is drawn. + /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay + /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask + /// overlays are window-safe. pub windows: bool, } @@ -97,9 +97,9 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; if !v.windows { - // the whole frame at the size it is drawn (an overlay or soft proof has no window); while a - // slider drags, a canvas-sized draft as with windows: a drag at 1:1 must not render the - // photo's every pixel each frame + // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no + // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must + // not render the photo's every pixel each frame let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; @@ -281,8 +281,9 @@ mod tests { assert_eq!(drag.window_edge, still.window_edge); } - // Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the - // size it is drawn, but a slider drag drafts at the canvas size like the windowed path + // Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the + // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the + // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; @@ -331,8 +332,8 @@ mod tests { } } - // Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too - // far for one render), the whole-frame render is as big as the view, as it was before windows + // Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a + // spot read too far for one render), the whole-frame render is as big as the view #[test] fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 931b87b32..7ef63b05e 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -39,6 +39,9 @@ pub enum Slot { Preview, Before, Compare(u8), + /// Compare or Reference zoomed past what `Compare(i)` holds: the window of pane `i`'s frame + /// that is on screen, drawn over it (see [`crate::region`] and `panels::compare`). + CompareRegion(u8), /// Background preparation of a neighbouring photo (no texture: its decoded source and view /// render are cached by the engine). 0 = next, 1 = previous. Prefetch(u8), @@ -117,7 +120,7 @@ const HARD_FACTOR: usize = 4; pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { let idle_rank = |s: Slot| match s { Slot::Hover => 0, - Slot::Compare(_) => 2, + Slot::Compare(_) | Slot::CompareRegion(_) => 2, Slot::Second => 3, _ => 5, }; @@ -319,7 +322,18 @@ impl Renderer { /// Views that render straight into the display profile's primaries (wide gamut); everything /// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]). fn renders_for_display(slot: Slot) -> bool { - matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second | Slot::Slide(_)) + matches!( + slot, + Slot::Main + | Slot::Region + | Slot::Before + | Slot::RegionBefore + | Slot::Hover + | Slot::Compare(_) + | Slot::CompareRegion(_) + | Slot::Second + | Slot::Slide(_) + ) } /// `job` as [`Self::request`] would run it for `slot` (its key included): views render for diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ed60ef56b..311a840d5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -304,7 +304,13 @@ fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut: /// Dock chrome owns activation, traversal, resizing and cancellation; its other keys reach commands. fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { if !ctx.egui_wants_keyboard_input() { - return false; + // Escape drops egui focus before shortcuts run; keep the numeric edit's whole input batch. + return ctx.input(|input| input.key_pressed(Key::Escape)) + && app.widgets.iter().any(|(widget, _)| widget.starts_with("sliderField:") && crate::text_field::was_editing(ctx, widget)); + } + // a selected slider keeps its arrow keys; everything else is a shortcut + if crate::widgets::slider_has_focus(ctx) { + return ctx.input(|input| [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|key| input.key_pressed(*key))); } let chrome = app .ui diff --git a/crates/ui-egui/src/sidebar.rs b/crates/ui-egui/src/sidebar.rs new file mode 100644 index 000000000..37b539595 --- /dev/null +++ b/crates/ui-egui/src/sidebar.rs @@ -0,0 +1,455 @@ +//! The left sidebar's sections and how the user arranged them: their order, which are hidden and +//! which are folded shut. No drawing here (that is `panels/left.rs`); this is the arrangement +//! itself, saved with the UI state and changed by the `view.sidebar*` commands. +//! +//! My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a +//! section: it stays at the top and can't be hidden, moved or folded. + +use serde::{Deserialize, Serialize}; + +/// A section of the left sidebar below My Photos. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SidebarSection { + Albums, + Local, + ByDate, + Folders, + Keywords, +} + +impl SidebarSection { + /// Every section, in the order a new library shows them. + pub const ALL: [SidebarSection; 5] = + [SidebarSection::Albums, SidebarSection::Local, SidebarSection::ByDate, SidebarSection::Folders, SidebarSection::Keywords]; + + /// The name commands, the saved UI state and widget ids (`sidebarSection:`) use. + pub const fn id(self) -> &'static str { + match self { + SidebarSection::Albums => "albums", + SidebarSection::Local => "local", + SidebarSection::ByDate => "byDate", + SidebarSection::Folders => "folders", + SidebarSection::Keywords => "keywords", + } + } + + /// The heading, in English (translated where it is shown). + pub const fn title(self) -> &'static str { + match self { + SidebarSection::Albums => "Albums", + SidebarSection::Local => "Local", + SidebarSection::ByDate => "By Date", + SidebarSection::Folders => "Folders", + SidebarSection::Keywords => "Keywords", + } + } + + /// What the section says while it has nothing to list, in English. + pub const fn empty_hint(self) -> &'static str { + match self { + SidebarSection::Albums => "No albums yet", + SidebarSection::Local => "No folders to browse", + SidebarSection::ByDate => "No photos yet", + SidebarSection::Folders => "No imported folders yet", + SidebarSection::Keywords => "No keywords yet", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|s| s.id() == id) + } + + /// Whether this build has the section at all (Local browses folders on disk: not on the web). + pub const fn available(self) -> bool { + !(cfg!(target_arch = "wasm32") && matches!(self, SidebarSection::Local)) + } + + /// The ids of every section, for error messages (`albums|local|…`). + pub fn ids() -> String { + Self::ALL.map(SidebarSection::id).join("|") + } +} + +/// Where one section stands: shown or hidden, open or folded. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct SectionState { + pub id: SidebarSection, + pub hidden: bool, + pub collapsed: bool, +} + +impl SectionState { + const fn new(id: SidebarSection) -> Self { + Self { id, hidden: false, collapsed: false } + } +} + +/// The arrangement of the sidebar's sections, top to bottom. Every section is in it exactly once, +/// whatever was read from disk or sent by an agent: unknown and repeated entries are dropped and +/// sections not named come last, in their usual order. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "serde_json::Value", into = "Vec")] +pub struct SidebarLayout(Vec); + +impl Default for SidebarLayout { + fn default() -> Self { + Self(SidebarSection::ALL.map(SectionState::new).to_vec()) + } +} + +impl From for Vec { + fn from(layout: SidebarLayout) -> Self { + layout.0 + } +} + +/// Lenient on purpose: a settings file edited by hand, written by a newer version, or damaged +/// must still open, so whatever can't be understood is left out instead of failing the load. +impl From for SidebarLayout { + fn from(saved: serde_json::Value) -> Self { + let entries = saved.as_array().map(Vec::as_slice).unwrap_or_default(); + let flag = |entry: &serde_json::Value, key: &str| entry.get(key).and_then(serde_json::Value::as_bool).unwrap_or(false); + let read = entries.iter().filter_map(|entry| { + // an entry is `{id, hidden?, collapsed?}`, or just the id + let id = entry.as_str().or_else(|| entry.get("id").and_then(serde_json::Value::as_str))?; + Some(SectionState { id: SidebarSection::from_id(id)?, hidden: flag(entry, "hidden"), collapsed: flag(entry, "collapsed") }) + }); + Self::from_states(read) + } +} + +impl SidebarLayout { + /// The arrangement an agent or script asks for (`ui.set` `sidebar`), read strictly, unlike a + /// settings file: a list whose entries are a section id or `{id, hidden?, collapsed?}`, each + /// section at most once. Sections left out follow in the usual order, shown and open. What is + /// wrong is said, so a typo doesn't quietly rearrange the sidebar. + pub fn parse(asked: &serde_json::Value) -> Result { + let entries = asked.as_array().ok_or_else(|| format!("`sidebar` must be a list of sections ({}), not {asked}", SidebarSection::ids()))?; + let mut states: Vec = Vec::new(); + for entry in entries { + let name = entry + .as_str() + .or_else(|| entry.get("id").and_then(serde_json::Value::as_str)) + .ok_or_else(|| format!("`sidebar`: {entry} names no section (an id, or {{id, hidden, collapsed}})"))?; + let id = SidebarSection::from_id(name).ok_or_else(|| format!("`sidebar`: unknown section `{name}` ({})", SidebarSection::ids()))?; + if states.iter().any(|s| s.id == id) { + return Err(format!("`sidebar`: `{name}` is listed twice")); + } + let flag = |key: &str| match entry.get(key) { + None | Some(serde_json::Value::Null) => Ok(false), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`sidebar`: `{key}` of `{name}` must be true or false, not {other}")), + }; + states.push(SectionState { id, hidden: flag("hidden")?, collapsed: flag("collapsed")? }); + } + Ok(Self::from_states(states)) + } + + /// The layout these states describe, made whole (see the type). + fn from_states(states: impl IntoIterator) -> Self { + let mut all: Vec = Vec::with_capacity(SidebarSection::ALL.len()); + for state in states.into_iter().chain(SidebarSection::ALL.map(SectionState::new)) { + if !all.iter().any(|s| s.id == state.id) { + all.push(state); + } + } + Self(all) + } + + /// Every section top to bottom, hidden ones included. + pub fn sections(&self) -> &[SectionState] { + &self.0 + } + + /// The sections that are shown, top to bottom. + pub fn shown(&self) -> impl Iterator + '_ { + self.0.iter().filter(|s| !s.hidden && s.id.available()).map(|s| s.id) + } + + fn state(&self, section: SidebarSection) -> SectionState { + self.0.iter().copied().find(|s| s.id == section).unwrap_or(SectionState::new(section)) + } + + fn state_mut(&mut self, section: SidebarSection) -> Option<&mut SectionState> { + self.0.iter_mut().find(|s| s.id == section) + } + + pub fn is_hidden(&self, section: SidebarSection) -> bool { + self.state(section).hidden + } + + pub fn is_collapsed(&self, section: SidebarSection) -> bool { + self.state(section).collapsed + } + + /// Show or hide a section. It keeps its place and whether it was folded. + pub fn set_hidden(&mut self, section: SidebarSection, hidden: bool) { + if let Some(s) = self.state_mut(section) { + s.hidden = hidden; + } + } + + /// Fold a section shut or open it. + pub fn set_collapsed(&mut self, section: SidebarSection, collapsed: bool) { + if let Some(s) = self.state_mut(section) { + s.collapsed = collapsed; + } + } + + pub fn toggle_collapsed(&mut self, section: SidebarSection) { + self.set_collapsed(section, !self.is_collapsed(section)); + } + + /// Make sure a section can be seen: shown and open (something in it is being pointed out). + pub fn reveal(&mut self, section: SidebarSection) { + self.set_hidden(section, false); + self.set_collapsed(section, false); + } + + /// Put `section` just above `before`, or last when `before` is `None`. Returns whether it + /// moved. Moving a section above itself or to where it already is changes nothing, and neither + /// does a move of a shown section that would only take it past hidden ones: the sidebar would + /// look the same, so the arrangement stays the same too. + pub fn move_before(&mut self, section: SidebarSection, before: Option) -> bool { + if before == Some(section) { + return false; + } + let was = self.clone(); + let moved = self.state(section); + self.0.retain(|s| s.id != section); + let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len()); + self.0.insert(at, moved); + let seen = !moved.hidden && section.available(); + if seen && self.shown().eq(was.shown()) { + *self = was; + return false; + } + *self != was + } + + /// Move `section` one place up or down among the sections that are shown (hidden ones are + /// stepped over, so every move is one the user can see). Returns whether it moved: the first + /// can't go up, the last can't go down, and a hidden section stays where it is. + pub fn move_by(&mut self, section: SidebarSection, up: bool) -> bool { + let shown: Vec = self.shown().collect(); + let Some(at) = shown.iter().position(|s| *s == section) else { return false }; + if up { + let Some(above) = at.checked_sub(1).and_then(|i| shown.get(i)) else { return false }; + self.move_before(section, Some(*above)) + } else { + if at + 1 >= shown.len() { + return false; + } + // below the next one: above the one after it, or last + self.move_before(section, shown.get(at + 2).copied()) + } + } + + /// Whether `section` can move up (or down) among the shown sections. + pub fn can_move(&self, section: SidebarSection, up: bool) -> bool { + let shown: Vec = self.shown().collect(); + match shown.iter().position(|s| *s == section) { + Some(at) if up => at > 0, + Some(at) => at + 1 < shown.len(), + None => false, + } + } + + /// Whether this is the arrangement a new library starts with. + pub fn is_default(&self) -> bool { + *self == Self::default() + } + + /// Fold shut the sections an older settings file listed as collapsed (`collapsedSidebar`). + pub fn adopt_collapsed(&mut self, ids: &[String]) { + for section in ids.iter().filter_map(|id| SidebarSection::from_id(id)) { + self.set_collapsed(section, true); + } + } +} + +#[cfg(test)] +mod tests { + use super::SidebarSection::{Albums, ByDate, Folders, Keywords, Local}; + use super::*; + use serde_json::json; + + fn order(layout: &SidebarLayout) -> Vec { + layout.sections().iter().map(|s| s.id).collect() + } + + fn read(saved: serde_json::Value) -> SidebarLayout { + serde_json::from_value(saved).unwrap() + } + + /// Given a new library, the sidebar lists every section, shown and open, in the usual order. + #[test] + fn a_new_sidebar_shows_every_section_open_in_the_usual_order() { + let layout = SidebarLayout::default(); + assert_eq!(order(&layout), [Albums, Local, ByDate, Folders, Keywords]); + assert!(layout.sections().iter().all(|s| !s.hidden && !s.collapsed)); + assert!(layout.is_default()); + } + + /// Given an arrangement the user made, when it is saved and read back, then it is the same. + #[test] + fn an_arrangement_survives_saving() { + let mut layout = SidebarLayout::default(); + layout.move_before(Keywords, Some(Albums)); + layout.set_hidden(Local, true); + layout.set_collapsed(ByDate, true); + let saved = serde_json::to_value(&layout).unwrap(); + assert_eq!(saved[0], json!({"id": "keywords", "hidden": false, "collapsed": false})); + assert_eq!(read(saved), layout); + } + + /// Given a saved arrangement that is incomplete, repeats itself or names sections this version + /// doesn't know, when it is read, then every section is there exactly once: the readable + /// entries first, in their order, and the rest after them. + #[test] + fn a_damaged_arrangement_is_made_whole() { + let layout = read(json!([ + {"id": "keywords", "collapsed": true}, + {"id": "map"}, + "folders", + {"id": "keywords", "hidden": true}, + {"id": 7}, + null, + {"id": "albums", "hidden": "yes", "collapsed": 1} + ])); + assert_eq!(order(&layout), [Keywords, Folders, Albums, Local, ByDate]); + assert!(layout.is_collapsed(Keywords) && !layout.is_hidden(Keywords), "the first entry for a section counts"); + assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums), "a flag that isn't true or false is off"); + } + + /// Given a saved arrangement that is not a list at all, when it is read, then the sidebar is + /// the usual one (and the rest of the settings still load). + #[test] + fn an_unreadable_arrangement_is_the_usual_one() { + for saved in [json!(null), json!("albums"), json!(3), json!({"albums": true}), json!([])] { + assert!(read(saved.clone()).is_default(), "{saved}"); + } + } + + /// Hiding a section takes it off the sidebar and nothing else: it keeps its place and stays + /// folded or open, so showing it again puts it back as it was. + #[test] + fn a_hidden_section_comes_back_where_and_how_it_was() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(ByDate, true); + layout.set_hidden(ByDate, true); + assert_eq!(layout.shown().collect::>(), [Albums, Local, Folders, Keywords]); + assert!(!layout.is_default()); + layout.set_hidden(ByDate, false); + assert_eq!(layout.shown().collect::>(), [Albums, Local, ByDate, Folders, Keywords]); + assert!(layout.is_collapsed(ByDate)); + } + + /// Revealing a section shows it and opens it. + #[test] + fn revealing_a_section_shows_and_opens_it() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(Albums, true); + layout.set_hidden(Albums, true); + layout.reveal(Albums); + assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums)); + } + + /// A section can be put above any other, or last; the others keep their order. + #[test] + fn a_section_moves_above_another_or_to_the_end() { + let mut layout = SidebarLayout::default(); + assert!(layout.move_before(Keywords, Some(Local))); + assert_eq!(order(&layout), [Albums, Keywords, Local, ByDate, Folders]); + assert!(layout.move_before(Albums, None)); + assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); + // nothing to do: above itself, where it already is + assert!(!layout.move_before(Local, Some(Local))); + assert!(!layout.move_before(Local, Some(ByDate))); + assert!(!layout.move_before(Albums, None)); + assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); + } + + /// Given a hidden section between two shown ones, when the upper one is dropped right where + /// it already appears (above the lower one), then nothing changes: no move that can't be seen. + #[test] + fn a_move_that_cannot_be_seen_is_no_move() { + let mut layout = SidebarLayout::default(); + layout.set_hidden(Local, true); + let was = layout.clone(); + assert!(!layout.move_before(Albums, Some(ByDate)), "Albums is already right above By Date, as far as anyone can see"); + assert_eq!(layout, was); + // a hidden section can still be put elsewhere (it shows there when it is shown again) + assert!(layout.move_before(Local, None)); + assert_eq!(order(&layout), [Albums, ByDate, Folders, Keywords, Local]); + } + + /// Moving a section keeps whether it is hidden or folded. + #[test] + fn a_moved_section_stays_folded() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(Folders, true); + layout.move_before(Folders, Some(Albums)); + assert!(layout.is_collapsed(Folders)); + } + + /// Move Up and Move Down go one shown section at a time and stop at the ends. + #[test] + fn a_section_moves_one_place_among_the_shown_ones() { + let mut layout = SidebarLayout::default(); + layout.set_hidden(ByDate, true); + // down past the hidden By Date in one step + assert!(layout.can_move(Local, false)); + assert!(layout.move_by(Local, false)); + assert_eq!(layout.shown().collect::>(), [Albums, Folders, Local, Keywords]); + assert!(layout.move_by(Local, false)); + assert_eq!(layout.shown().collect::>(), [Albums, Folders, Keywords, Local]); + assert!(!layout.can_move(Local, false) && !layout.move_by(Local, false), "the last can't go down"); + assert!(layout.move_by(Folders, true)); + assert_eq!(layout.shown().collect::>(), [Folders, Albums, Keywords, Local]); + assert!(!layout.can_move(Folders, true) && !layout.move_by(Folders, true), "the first can't go up"); + assert!(!layout.can_move(ByDate, true) && !layout.move_by(ByDate, true), "a hidden section doesn't move"); + } + + /// Sections an older settings file listed as collapsed are folded; names it got wrong are ignored. + #[test] + fn collapsed_sections_of_an_older_settings_file_stay_folded() { + let mut layout = SidebarLayout::default(); + layout.adopt_collapsed(&["byDate".into(), "nonsense".into(), "keywords".into()]); + assert!(layout.is_collapsed(ByDate) && layout.is_collapsed(Keywords) && !layout.is_collapsed(Albums)); + } + + /// Given an arrangement asked for by an agent, when it is right, then it is taken (sections it + /// leaves out follow, shown and open); when anything in it is wrong, then it is refused with + /// what is wrong, instead of being made whole as a settings file is. + #[test] + fn an_arrangement_that_is_asked_for_is_read_strictly() { + let layout = SidebarLayout::parse(&json!(["keywords", {"id": "albums", "hidden": true, "collapsed": null}])).unwrap(); + assert_eq!(order(&layout), [Keywords, Albums, Local, ByDate, Folders]); + assert!(layout.is_hidden(Albums) && !layout.is_collapsed(Albums)); + assert!(SidebarLayout::parse(&json!([])).unwrap().is_default()); + for (asked, says) in [ + (json!(null), "must be a list"), + (json!({"albums": 1}), "must be a list"), + (json!([{"id": "keywrods", "hidden": true}]), "unknown section `keywrods`"), + (json!([7]), "names no section"), + (json!(["albums", {"id": "albums"}]), "`albums` is listed twice"), + (json!([{"id": "albums", "hidden": "yes"}]), "`hidden` of `albums` must be true or false"), + (json!([{"id": "albums", "collapsed": 1}]), "`collapsed` of `albums` must be true or false"), + ] { + let e = SidebarLayout::parse(&asked).unwrap_err(); + assert!(e.contains(says), "{asked}: {e}"); + } + } + + #[test] + fn sections_are_named_by_their_ids() { + for s in SidebarSection::ALL { + assert_eq!(SidebarSection::from_id(s.id()), Some(s)); + assert_eq!(serde_json::to_value(s).unwrap(), json!(s.id())); + } + assert_eq!(SidebarSection::from_id("Albums"), None); + assert_eq!(SidebarSection::ids(), "albums|local|byDate|folders|keywords"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2eefe08a3..0419b628d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,101 @@ impl InfoOverlay { } } +/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back +/// on): everything but the photo is veiled in black, the panels stay where they are. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightsOut { + #[default] + On, + /// The interface at 20 % brightness. + Dim, + /// The interface black. + Off, +} + +impl LightsOut { + pub fn next(self) -> LightsOut { + match self { + LightsOut::On => LightsOut::Dim, + LightsOut::Dim => LightsOut::Off, + LightsOut::Off => LightsOut::On, + } + } + + /// Opacity of the black veil over everything but the photo. + pub fn veil(self) -> f32 { + match self { + LightsOut::On => 0.0, + LightsOut::Dim => 0.8, + LightsOut::Off => 1.0, + } + } +} +/// How many libraries File ▸ Open Recent Library remembers. +pub const RECENT_LIBRARIES: usize = 8; + +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -135,6 +230,9 @@ impl InfoOverlay { pub struct AppSettings { /// Library opened at launch when no `--library` is given (empty = the default location). pub library_path: String, + /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most + /// [`RECENT_LIBRARIES`]). + pub recent_libraries: Vec, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted (keyboard and menu). pub confirm_delete: bool, @@ -144,8 +242,15 @@ pub struct AppSettings { /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). pub preview_limit: u32, - /// Edit in External Editor: the application ("" = the system's default for TIFF files). + /// Edit in Other Editor: the application ("" = the system's default for the file type). pub external_editor: String, + /// The file Edit in Other Editor hands over. + pub external_file: EditFile, + /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable + /// copy needs its path). + pub photocraft_path: String, + /// The file Edit in PhotoCraft hands over. + pub photocraft_file: EditFile, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). pub memory_mb: u32, /// Filmstrip: file names above the thumbnails. @@ -156,6 +261,15 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, + /// Theme shortcuts in the appearance context menu. + #[serde(deserialize_with = "deserialize_favorite_themes")] + pub favorite_themes: Vec, /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, @@ -165,21 +279,87 @@ impl Default for AppSettings { fn default() -> Self { AppSettings { library_path: String::new(), + recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: false, gpu: true, preview_limit: 0, external_editor: String::new(), + external_file: EditFile::default(), + photocraft_path: String::new(), + photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, + favorite_themes: Vec::new(), display_profile: String::new(), } } } +/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds +/// where it is installed. +pub const PHOTOCRAFT: &str = "PhotoCraft"; + +/// The file format Edit In renders the copy as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditFormat { + #[default] + Tiff, + /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). + Psd, +} + +/// The colour space of the Edit In copy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditSpace { + #[default] + AdobeRgb, + ProPhoto, + DisplayP3, + Srgb, +} + +/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EditFile { + pub format: EditFormat, + pub color_space: EditSpace, + /// 16 or 8. + pub bit_depth: u8, +} + +impl Default for EditFile { + fn default() -> Self { + EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } + } +} + +impl EditFile { + /// The `photo.editExternal` params for this file. + pub fn params(&self) -> serde_json::Value { + let format = match self.format { + EditFormat::Tiff => "tiff", + EditFormat::Psd => "psd", + }; + let space = match self.color_space { + EditSpace::AdobeRgb => "adobeRgb", + EditSpace::ProPhoto => "proPhoto", + EditSpace::DisplayP3 => "displayP3", + EditSpace::Srgb => "srgb", + }; + serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) + } +} + /// Preview size limits offered in Settings → Performance (0 = Automatic). pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; @@ -200,6 +380,29 @@ pub const LOUPE_EDGE_STEP: usize = 64; pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling @@ -325,6 +528,13 @@ pub struct UiState { /// Soft proofing (S in the loupe): render as `proof` would hold the photo. pub soft_proof: bool, pub proof: lightcraft_engine::pipeline::Proof, + /// Soft proofing against a printer / paper ICC profile: (file, name), the intent and Simulate + /// Paper & Ink. With a profile, `proof.profile` names its table. + #[serde(skip)] + pub proof_profile: Option<(String, String)>, + #[serde(skip)] + pub proof_intent: lightcraft_engine::proof::ProofIntent, + pub proof_paper_ink: bool, /// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles), /// in which colour and opacity (0..100, colour views), and whether pins are drawn. pub mask_overlay: bool, @@ -338,6 +548,8 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, + /// Last explicitly chosen aspect, retained across photos and app launches. + pub crop_default_aspect: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · /// focal length) or `date`. @@ -346,8 +558,11 @@ pub struct UiState { pub show_counts: bool, /// Face / pet boxes (read from XMP) over the photo in the loupe. pub face_boxes: bool, - /// Left-sidebar sections folded shut by their header (`albums`, `local`, `byDate`, - /// `keywords`); the rest are open. + /// The left sidebar's sections: their order, which are hidden and which are folded shut. + pub sidebar: crate::sidebar::SidebarLayout, + /// What settings files said before [`UiState::sidebar`]: the ids of the folded sections. Read + /// so they stay folded ([`UiState::sanitized`] moves them over), never written. + #[serde(skip_serializing)] pub collapsed_sidebar: Vec, /// A face's name being typed in the loupe. #[serde(skip)] @@ -417,15 +632,25 @@ pub struct UiState { /// An album just made: the Albums tree opens the folders down to it, once. #[serde(skip)] pub reveal_album: Option, + /// A keyword just made from the sidebar: Keywords opens down to it, once. + #[serde(skip)] + pub reveal_keyword: Option, /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). #[serde(skip)] pub dragging_album: Option, + /// A sidebar section being dragged by its header (dropped between sections to move it there). + #[serde(skip)] + pub dragging_section: Option, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". pub mixer_mode: String, - /// Selected colour grading wheel: "3way" | "shadows" | "midtones" | "highlights" | "global". + /// Selected colour grading tab: "3way" | "shadows" | "midtones" | "highlights". pub grading_mode: String, + /// Colour grading wheels (shadows, midtones, highlights) hidden for viewing by their eye, and + /// the photo that applies to: not an edit, and forgotten on moving to another photo or quitting. + #[serde(skip)] + pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>, /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove". pub tool: String, pub brush_size: f32, @@ -463,6 +688,9 @@ pub struct UiState { pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. pub fullscreen: bool, + /// Lights Out (Shift+L): not saved, every launch starts with the lights on. + #[serde(skip)] + pub lights_out: LightsOut, /// Window ▸ Second Window. pub second_window: bool, /// The keyword painter: clicking a photo in the grid toggles this keyword on it. @@ -605,6 +833,9 @@ pub enum Dialog { person: bool, /// A new keyword is given to the selected photos. add_to_selected: bool, + /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. + #[serde(default)] + reveal: bool, }, /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent @@ -724,6 +955,13 @@ pub enum Dialog { #[serde(default)] error: Option, }, + /// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import + /// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead. + /// `folder`: what was chosen. + LightroomCatalog { + folder: String, + catalog: Option, + }, /// Confirm moving photos to Recently Deleted. ConfirmDelete { count: usize, @@ -807,17 +1045,22 @@ impl Default for UiState { histogram: true, soft_proof: false, proof: lightcraft_engine::pipeline::Proof { dest_warning: false, ..Default::default() }, + proof_profile: None, + proof_intent: lightcraft_engine::proof::ProofIntent::Perceptual, + proof_paper_ink: false, mask_overlay: true, mask_overlay_mode: "color".into(), mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, crop_overlay: CropOverlay::Thirds, + crop_default_aspect: None, crop_overlay_orient: 0, show_filenames: true, grid_info: "filename".into(), show_counts: true, face_boxes: true, + sidebar: Default::default(), collapsed_sidebar: Vec::new(), name_edit: None, person_page: None, @@ -843,10 +1086,13 @@ impl Default for UiState { quit: false, dragging_photos: None, reveal_album: None, + reveal_keyword: None, dragging_album: None, + dragging_section: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), + grading_hidden: None, tool: String::new(), brush_size: 0.04, brush_feather: 50.0, @@ -865,6 +1111,7 @@ impl Default for UiState { spots_threshold: 50.0, auto_advance: false, fullscreen: false, + lights_out: LightsOut::On, slideshow: None, second_window: false, keyword_painter: None, @@ -890,6 +1137,23 @@ impl Default for UiState { } impl UiState { + /// Which colour grading wheels (shadows, midtones, highlights) are hidden for viewing `photo`. + pub fn grading_hidden_for(&self, photo: lightcraft_catalog::PhotoId) -> [bool; 3] { + match self.grading_hidden { + Some((p, h)) if p == photo => h, + _ => [false; 3], + } + } + + /// Show or hide colour grading wheel `i` (0 shadows, 1 midtones, 2 highlights) on `photo`. + pub fn set_grading_hidden(&mut self, photo: lightcraft_catalog::PhotoId, i: usize, hidden: bool) { + let mut h = self.grading_hidden_for(photo); + if let Some(slot) = h.get_mut(i) { + *slot = hidden; + } + self.grading_hidden = h.iter().any(|x| *x).then_some((photo, h)); + } + pub fn section_open(&self, id: &str) -> bool { self.open_sections.iter().any(|s| s == id) } @@ -903,15 +1167,9 @@ impl UiState { self.open_sections.push(id.to_string()); } } + /// Whether the sidebar section with this id is folded shut (an unknown id: no). pub fn sidebar_section_collapsed(&self, id: &str) -> bool { - self.collapsed_sidebar.iter().any(|s| s == id) - } - pub fn toggle_sidebar_section(&mut self, id: &str) { - if self.sidebar_section_collapsed(id) { - self.collapsed_sidebar.retain(|s| s != id); - } else { - self.collapsed_sidebar.push(id.to_string()); - } + crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s)) } pub fn flyout_open(&self, id: &str) -> bool { self.open_flyouts.iter().any(|s| s == id) @@ -929,6 +1187,7 @@ impl UiState { self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); + self.sidebar.adopt_collapsed(&std::mem::take(&mut self.collapsed_sidebar)); self.dialog = None; self.fullscreen = false; if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) { @@ -942,3 +1201,14 @@ impl UiState { self } } + +fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let values = Vec::::deserialize(d)?; + let mut seen = std::collections::BTreeSet::new(); + for id in &values { + if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { + return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); + } + } + Ok(values) +} diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs new file mode 100644 index 000000000..947e6cf9f --- /dev/null +++ b/crates/ui-egui/src/tests_appearance.rs @@ -0,0 +1,247 @@ +//! Appearance Mode: Auto (follow the system), Dark or Light, each with its own saved theme. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{AppSettings, AppearanceMode, DarkTheme, LightTheme, UiState}; +use crate::theme::ThemeKind; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }) +} + +/// One frame of logic on `ctx`: where the theme is applied. +fn frame(app: &mut LightcraftApp, ctx: &egui::Context) { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + output.textures_delta.clear(); +} + +#[test] +fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { + let s = AppSettings::default(); + assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); + assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); + // a ui.json saved before appearance modes existed + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); + assert_eq!(old.settings.light_theme, LightTheme::Silver); + assert!(old.settings.confirm_delete); + // and the Charcoal tokens are the ones LightCraft always had + assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); +} + +#[test] +fn choices_validate_and_survive_a_restart() { + let mut a = app(); + let ctx = egui::Context::default(); + let set = |a: &mut LightcraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + match crate::control::handle(a, &ctx, &req) { + crate::control::Outcome::Done(reply) => reply, + _ => panic!("expected a reply"), + } + }; + let ok = set(&mut a, json!({"settings": {"appearanceMode": "auto", "darkTheme": "midnight", "lightTheme": "paper"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); + for bad in [json!({"appearanceMode": "sepia"}), json!({"darkTheme": "paper"}), json!({"lightTheme": "midnight"}), json!({"appearanceMode": 3})] { + let reply = set(&mut a, json!({ "settings": bad })); + assert_eq!(reply["ok"], false, "{bad} accepted"); + } + assert_eq!((a.ui.settings.dark_theme, a.ui.settings.light_theme), (DarkTheme::Midnight, LightTheme::Paper), "a rejected value changes nothing"); + let saved = serde_json::to_string(&a.ui).unwrap(); + assert!(saved.contains(r#""appearanceMode":"auto""#) && saved.contains(r#""darkTheme":"midnight""#) && saved.contains(r#""lightTheme":"paper""#)); + let restarted: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restarted.settings, a.ui.settings); +} + +#[test] +fn the_button_cycles_auto_light_dark_without_losing_theme_choices() { + let mut a = app(); + let ctx = egui::Context::default(); + a.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + a.run("view.theme", json!({"theme": "midnight"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Dark, "a dark theme fixes the mode to Dark"); + for (mode, shown) in + [(AppearanceMode::Auto, ThemeKind::Midnight), (AppearanceMode::Light, ThemeKind::Paper), (AppearanceMode::Dark, ThemeKind::Midnight)] + { + let r = a.run("view.appearance", json!({})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, mode); + assert_eq!(r["darkTheme"], "midnight"); + assert_eq!(r["lightTheme"], "paper"); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(shown)); + assert_eq!(crate::theme::Tokens::get(&ctx).chrome, crate::theme::Tokens::for_kind(shown).chrome); + let icon = match mode { + AppearanceMode::Auto => crate::icons::Icon::Monitor, + AppearanceMode::Light => crate::icons::Icon::Sun, + AppearanceMode::Dark => crate::icons::Icon::Moon, + }; + assert_eq!(crate::panels::topbar::appearance_icon(mode).0, icon); + } + // a light theme from the menu fixes Light + a.run("view.theme", json!({"theme": "silver"})).unwrap(); + assert_eq!( + (a.ui.settings.appearance_mode, a.ui.settings.light_theme, a.ui.settings.dark_theme), + (AppearanceMode::Light, LightTheme::Silver, DarkTheme::Midnight) + ); + // bad parameters are errors, never a panic, and change nothing + for p in [json!({}), json!({"theme": 4}), json!({"theme": "neon"})] { + assert!(a.run("view.theme", p.clone()).is_err(), "{p}"); + } + for p in [json!({"mode": "sepia"}), json!({"mode": 1}), json!({"mode": []})] { + assert!(a.run("view.appearance", p.clone()).is_err(), "{p}"); + } + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Light); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); +} + +#[test] +fn auto_follows_the_system_and_falls_back_to_dark() { + use std::sync::{Arc, Mutex}; + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let mut a = app(); + let reported = Arc::clone(&system); + a.services.system_theme = Some(Box::new(move |_| *reported.lock().unwrap())); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); + assert_eq!(ctx.theme(), egui::Theme::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + assert_eq!(ctx.theme(), egui::Theme::Dark); + // no answer from the host or from egui: dark + *system.lock().unwrap() = None; + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + // without a host service, egui's report is used + let mut plain = app(); + plain.ui.settings.appearance_mode = AppearanceMode::Auto; + let ctx = egui::Context::default(); + let input = egui::RawInput { system_theme: Some(egui::Theme::Light), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + assert_eq!(plain.system_theme(ui.ctx()), Some(egui::Theme::Light)); + plain.logic(ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); +} + +#[test] +fn view_menu_lists_modes_and_themes_with_checks() { + let mut a = app(); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + let menus = crate::menubar::menu_bar(&a); + let text = serde_json::to_string(&menus).unwrap(); + for label in ["Appearance", "Sync with System", "Light Mode", "Dark Mode", "Next Appearance Mode", "Charcoal", "Midnight", "Silver", "Paper"] { + assert!(text.contains(&format!("\"{label}\"")), "{label} missing from the menus"); + } + assert!(text.contains(r#""params":{"theme":"paper"}"#)); +} + +#[test] +fn the_top_bar_button_and_settings_cards_drive_the_mode() { + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.settle(Duration::from_secs(60)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Auto); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Silver)); + // Settings ▸ Interface: the mode buttons and the theme radios + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Paper)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAppearance-1"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Charcoal)); +} + +#[test] +fn interface_settings_fit_small_windows_and_stack_theme_choices() { + // Desktop windows have a 900×560 minimum; also stress a smaller headless viewport. + for size in [[900.0, 560.0], [640.0, 480.0]] { + let mut h = Headless::new(app(), size, 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").unwrap().1; + assert!(window.width() <= size[0], "{size:?}: {window:?}"); + assert!(window.height() <= size[1], "{size:?}: {window:?}"); + let silver = h.app.widgets.iter().find(|(id, _)| id == "radio:theme-silver").unwrap().1; + let mode = h.app.widgets.iter().find(|(id, _)| id == "button:settingsAppearance-0").unwrap().1; + assert!(silver.top() > mode.bottom() + 80.0, "theme choices must sit below their preview"); + assert!(silver.right() <= window.right(), "theme choices stay inside the modal"); + } +} + +#[test] +fn favorites_validate_roundtrip_and_stars_toggle_without_selecting() { + for bad in [json!(["paper", "paper"]), json!(["unknown"]), json!([3]), json!("paper")] { + assert!(serde_json::from_value::(json!({"favoriteThemes": bad})).is_err()); + } + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + assert_eq!(h.request("ui.hoverWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.favorite_themes, vec!["paper"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + h.request("ui.scroll", json!({"dy": -90.0}), T); + for _ in 0..4 { + h.step(); + } + let pixels = h.paint(); + let image = + lightcraft_raster::Rgba8 { width: pixels.size[0], height: pixels.size[1], data: pixels.pixels.iter().map(|c| c.to_array()).collect() }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(std::path::Path::new(&path).join("lightcraft-favorites.png"), png).unwrap(); + } + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Silver, "starring does not select"); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap(), h.app.ui.settings); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert!(h.app.ui.settings.favorite_themes.is_empty()); + h.app.ui.dialog = None; + h.app.ui.settings.favorite_themes = vec!["midnight".into(), "paper".into()]; + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance", "button": "right"}), T)["ok"], true); + h.step(); + h.step(); + let light = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-paper").unwrap().1; + let dark = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-midnight").unwrap().1; + assert!(light.top() < dark.top(), "light favorites precede dark favorites"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "menu:themeFavorite-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(h.app.ui.settings.dark_theme, DarkTheme::Charcoal); +} diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index cfbfbde63..8e27411eb 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -31,6 +31,17 @@ fn angle(h: &Headless) -> f64 { h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.angle } +/// The crop box lies inside the rotated photo (issue #742: a typed or slid angle shrinks it to fit, +/// as the rotate handle does). +fn crop_fits(h: &Headless) -> bool { + let id = h.app.session.active().expect("a photo"); + let p = h.app.session.catalog.photo(id).expect("the photo"); + let (w, h) = (p.width as f64, p.height as f64); + let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let g = p.develop.crop.geometry; + g.rect != lightcraft_geom::Rect::UNIT && g.is_within_image(w, h) +} + fn hover(h: &mut Headless, p: egui::Pos2) { let r = h.request("ui.move", json!({"x": p.x, "y": p.y}), T); assert_eq!(r["ok"], true, "{r}"); @@ -88,6 +99,7 @@ fn the_straighten_value_takes_an_exact_angle() { assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); assert!((angle(&h) - 2.5).abs() < 1e-9, "typed 2.5°, got {}", angle(&h)); + assert!(crop_fits(&h), "the Straighten value shrinks the crop to fit the photo"); } /// The angle next to the pointer reads like the Straighten value, in degrees. @@ -145,6 +157,7 @@ fn the_crop_panel_has_an_angle_field() { assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); assert!((angle(&h) + 3.25).abs() < 1e-9, "typed -3.25°, got {}", angle(&h)); + assert!(crop_fits(&h), "the Angle field shrinks the crop to fit the photo"); } /// Rotating with the pointer at the canvas's corner keeps the angle readout on the canvas (and so @@ -364,3 +377,41 @@ fn the_angle_field_writes_the_angle_like_the_readout() { let field = texts.iter().position(|t| t == "Angle").and_then(|i| texts.get(i + 1)); assert_eq!(field.map(String::as_str), Some("+2.50"), "{texts:?}"); } + +/// Issue #731: the dimming outside a rotated crop box stays outside it. The photo is shown +/// unrotated under the box, so a pixel inside the box at -45° looks exactly as it does at 0°, +/// where the box is the whole photo and nothing is dimmed; the photo's corner, outside the +/// box, is darker. (The dimming was four quads joining photo and box corners, which crossed the +/// box once rotated: dimmed wedges inside it, doubly dark ones outside, growing with the angle.) +#[test] +fn rotating_the_crop_dims_nothing_inside_the_box() { + let mut h = crop_tool(); + let plain = h.paint(); + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "crop.angle", "value": -45.0}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(angle(&h), -45.0); + let rotated = h.paint(); + let img = h.app.image_rect.expect("the photo on screen"); + let corner = |i: usize| h.app.widgets.iter().find(|(w, _)| *w == format!("cropHandle:{i}")).map(|(_, r)| r.center()).expect("a corner handle"); + let c = [corner(0), corner(1), corner(2), corner(3)]; + let at = |u: f32, v: f32| c[0] + (c[1] - c[0]) * u + (c[3] - c[0]) * v; + let pixel = |im: &egui::ColorImage, p: egui::Pos2| im.pixels[p.y.round() as usize * im.size[0] + p.x.round() as usize]; + let mut checked = 0; + for u in [0.15, 0.25, 0.5, 0.75, 0.85] { + for v in [0.15, 0.25, 0.5, 0.75, 0.85] { + let p = at(u, v); + // clear of the 0° guide lines (thirds of the photo) and of the photo's edge + let thirds = |t: f32, lo: f32, len: f32| [1.0 / 3.0, 2.0 / 3.0].iter().any(|f| (t - (lo + f * len)).abs() < 3.0); + if !img.shrink(4.0).contains(p) || thirds(p.x, img.left(), img.width()) || thirds(p.y, img.top(), img.height()) { + continue; + } + assert_eq!(pixel(&rotated, p), pixel(&plain, p), "inside the box at ({u}, {v}) = {p:?}"); + checked += 1; + } + } + assert!(checked >= 12, "{checked} points inside the box compared"); + let corner_px = img.left_top() + egui::vec2(6.0, 6.0); + 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"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs new file mode 100644 index 000000000..5c4f1a83f --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -0,0 +1,134 @@ +//! Issue #781: a dialog taller than the window keeps its title and buttons on screen; its options +//! scroll. On a 1366×768 screen the Export dialog ran off both ends and couldn't be confirmed. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo(size: [f32; 2]) -> Headless { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, size, 1.0); + // let the first renders finish (a render still running at exit can crash in GPU teardown) + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn open(h: &mut Headless, command: &str) { + let r = h.request("engine.execute", json!({"command": command, "params": {}}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + assert!(h.app.ui.dialog.is_some(), "{command} opens a dialog"); + h.settle(T); +} + +/// The dialog lies inside the window, and so do its buttons (`button:dialogCancel` when it has one). +fn assert_fits(h: &Headless, what: &str) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{what}: dialog {dialog:?} runs off the {screen:?} window"); + let mut buttons = vec!["button:dialogOk"]; + if h.app.widgets.iter().any(|(w, _)| w == "button:dialogCancel") { + buttons.push("button:dialogCancel"); + } + for b in buttons { + let r = rect(h, b); + assert!(dialog.contains_rect(r), "{what}: {b} {r:?} is outside the dialog {dialog:?}"); + } +} + +/// The Export dialog in a window of `size`, set to write small JPEGs to a made-up folder. +fn export_dialog(size: [f32; 2]) -> Headless { + let mut h = demo(size); + open(&mut h, "dialog.export"); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + h.settle(T); + h +} + +// Scenario: on a 1366×768 screen (and a shorter window) the Export dialog's buttons are on screen and work +#[test] +fn export_dialog_fits_a_short_window_and_its_button_exports() { + for size in [[1366.0, 690.0], [1366.0, 520.0]] { + let mut h = export_dialog(size); + assert_fits(&h, &format!("Export at {size:?}")); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Export closed the dialog at {size:?}"); + assert!(h.app.export.is_some() || h.app.last_export_result.is_some(), "Export started at {size:?}"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); + } +} + +// Scenario: Cancel is on screen too, and closes the dialog +#[test] +fn export_dialog_cancel_is_on_screen_in_a_short_window() { + let mut h = export_dialog([1366.0, 690.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + assert!(screen.contains_rect(rect(&h, "button:dialogCancel"))); + h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.export.is_none() && h.app.last_export_result.is_none(), "nothing exported"); +} + +// Scenario: a window with room for it shows the Export dialog as before (no scrolling, same size) +#[test] +fn export_dialog_is_unchanged_in_a_tall_window() { + let h = export_dialog([1600.0, 1000.0]); + assert_fits(&h, "Export at 1600×1000"); + let dialog = rect(&h, "dialog:window"); + // (846 tall as before #781: the whole dialog, nothing scrolled away; 546 wide since the + // watermark's nine positions, #833) + assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}"); + assert!((dialog.width() - 546.0).abs() <= 1.0, "{dialog:?}"); +} + +// Scenario: other long dialogs fit a short window too, with their buttons on screen +#[test] +fn long_dialogs_fit_a_short_window() { + let mut h = demo([1000.0, 320.0]); + for command in [ + "dialog.copySettings", + "dialog.pasteSettings", + "dialog.createPreset", + "dialog.contactSheet", + "app.settings", + "app.shortcuts", + "app.whatsNew", + "app.about", + "app.systemInfo", + "dialog.smartAlbum", + ] { + open(&mut h, command); + assert_fits(&h, command); + h.app.ui.dialog = None; + h.settle(T); + } +} + +// Scenario: a list that scrolls on its own (the shortcuts) gets shorter instead of scrolling inside a scrolling dialog +#[test] +fn a_dialog_list_shrinks_to_fit_a_short_window() { + let mut h = demo([1000.0, 320.0]); + open(&mut h, "app.shortcuts"); + let shrink = h.view.ctx.data(|d| d.get_temp::(egui::Id::new("dialog-list-shrink"))).unwrap_or(0.0); + assert!(shrink > 0.0, "the list gave up room"); + let dialog = rect(&h, "dialog:window"); + assert!(dialog.height() <= 320.0, "{dialog:?}"); +} diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs new file mode 100644 index 000000000..8b6ec7c7b --- /dev/null +++ b/crates/ui-egui/src/tests_grading.rs @@ -0,0 +1,121 @@ +//! Headless tests of the Color Grading flyout (issue #737): the 3-Way / Shadows / Midtones / +//! Highlights tabs, the equal-size wheels, and each wheel's eye that hides its effect for viewing. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 2400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + if !h.app.ui.section_open("color") { + h.app.ui.toggle_section("color"); + } + if !h.app.ui.flyout_open("grading") { + h.app.ui.toggle_flyout("grading"); + } + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn three_way_is_the_default_with_three_equal_wheels() { + let h = demo(); + assert_eq!(h.app.ui.grading_mode, "3way"); + let sizes: Vec = ["shadows", "midtones", "highlights"].iter().map(|k| widget(&h, &format!("wheel:{k}")).unwrap().width()).collect(); + assert!(sizes.iter().all(|w| (w - sizes[0]).abs() < 0.5), "{sizes:?}"); + for tab in ["3way", "shadows", "midtones", "highlights"] { + assert!(widget(&h, &format!("icon:grading-{tab}")).is_some(), "tab {tab}"); + } +} + +#[test] +fn a_range_tab_shows_one_bigger_wheel_and_its_hue_and_saturation() { + let mut h = demo(); + let small = widget(&h, "wheel:shadows").unwrap().width(); + click(&mut h, "icon:grading-shadows"); + assert_eq!(h.app.ui.grading_mode, "shadows"); + assert!(widget(&h, "wheel:shadows").unwrap().width() > small); + assert!(widget(&h, "wheel:midtones").is_none() && widget(&h, "wheel:highlights").is_none()); + for c in ["grading.shadows.hue", "grading.shadows.sat", "grading.shadows.lum", "grading.blending", "grading.balance"] { + assert!(h.app.widgets.iter().any(|(w, _)| w.contains(c)), "slider {c}"); + } + assert!(!h.app.widgets.iter().any(|(w, _)| w.contains("grading.midtones.lum"))); +} + +#[test] +fn the_eye_hides_a_wheel_for_viewing_only_and_resets_on_another_photo() { + let mut h = demo(); + let id = h.app.session.active().unwrap(); + h.app.session.execute("develop.set", &json!({"values": {"grading.midtones.sat": 50}})).unwrap(); + h.step(); + let r = h.request("ui.hoverWidget", json!({"id": "wheel:midtones"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + click(&mut h, "wheelEye:midtones"); + assert_eq!(h.app.ui.grading_hidden_for(id), [false, true, false]); + // not an edit + assert_eq!(h.app.session.catalog.photo(id).unwrap().develop.grading.midtones.sat, 50.0); + // the eye stays on screen while the wheel is hidden, and shows it again + h.request("ui.move", json!({"x": 5.0, "y": 5.0}), T); + h.step(); + assert!(widget(&h, "wheelEye:midtones").is_some()); + click(&mut h, "wheelEye:midtones"); + assert_eq!(h.app.ui.grading_hidden_for(id), [false; 3]); + // hidden state belongs to one photo + h.app.ui.set_grading_hidden(id, 0, true); + let other = lightcraft_catalog::PhotoId(id.0 + 1); + assert_eq!(h.app.ui.grading_hidden_for(other), [false; 3]); +} + +#[test] +fn hovering_a_three_way_wheel_shows_its_hue_and_saturation() { + let mut h = demo(); + h.app.session.execute("develop.set", &json!({"values": {"grading.shadows.hue": 220, "grading.shadows.sat": 45}})).unwrap(); + h.step(); + let readout = "H 220° S 45"; + assert!(!h.painted_text().iter().any(|t| t == readout), "hidden until hovered"); + let r = h.request("ui.hoverWidget", json!({"id": "wheel:shadows"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.painted_text().iter().any(|t| t == readout), "{:?}", h.painted_text()); +} + +#[test] +fn shift_drag_keeps_the_hue_and_changes_only_saturation() { + use crate::panels::edit::wheel_pick; + let close = |a: f32, b: f32| (a - b).abs() < 0.01; + // free: hue from the angle (screen y points down), saturation from the distance + let (h, s) = wheel_pick(egui::vec2(0.0, -20.0), 40.0, None); + assert!(close(h, 90.0) && close(s, 50.0), "{h} {s}"); + // locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts + let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(0.0)); + assert!(close(h, 0.0) && close(s, 75.0), "{h} {s}"); + // past the centre or the rim: clamped to 0..100 + assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(0.0)).1, 0.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(0.0)).1, 100.0); + // a degenerate radius doesn't divide by zero + assert!(wheel_pick(egui::vec2(1.0, 1.0), 0.0, None).1.is_finite()); +} diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 0bd55f413..1b04a46bd 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -176,3 +176,64 @@ fn shift_insert_pastes_plainly() { assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted"); } +/// The opening release can carry both physical shortcut keys and the replacement text. +#[test] +fn opening_numeric_release_keeps_keys_in_the_editor() { + use egui::{Event, Key, Modifiers, PointerButton}; + + for terminal in [Key::Enter, Key::Escape] { + let mut h = demo(); + h.app.run("photo.rate", json!({"rating": 0})).unwrap(); + h.app.run("develop.set", json!({"values": {"light.exposure": 0.7}})).unwrap(); + h.app.run("app.setShortcut", json!({"id": "photo.increaseRating", "shortcut": "Enter"})).unwrap(); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit", "openSections": ["light"], "histogram": false}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + assert!(h.app.fonts_ready); + assert!(h.app.recording_shortcut.is_none() && h.app.ui.name_edit.is_none()); + assert!(h.app.native_shortcuts.is_empty() && h.app.synthetic.is_empty() && h.events.is_empty()); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), Some("Enter")); + let active = h.app.session.active().unwrap(); + let selection = h.app.session.selection.ids.clone(); + let (view, right, tool) = (h.app.ui.view, h.app.ui.right, h.app.ui.tool.clone()); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0); + assert!((h.app.session.develop_of(active).unwrap().light.exposure - 0.7).abs() < 1e-9); + let (undo, redo) = (h.app.session.undo.len(), h.app.session.redo.len()); + let pos = h.app.widgets.iter().rev().find(|(id, _)| id == "sliderValue:light.exposure").unwrap().1.center(); + h.events.push(Event::PointerMoved(pos)); + h.step(); + h.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the press focuses the editor before app shortcuts run"); + h.events.extend([ + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Num2, physical_key: Some(Key::Num2), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Text("2".into()), + Event::Key { key: terminal, physical_key: Some(terminal), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.step(); + // Observe the consuming frame directly, then exactly one empty frame: no request drains. + for next_frame in [false, true] { + if next_frame { + assert!(h.events.is_empty() && h.app.synthetic.is_empty()); + h.step(); + } + let expected = if terminal == Key::Enter { 2.0 } else { 0.7 }; + assert!((h.app.session.develop_of(active).unwrap().light.exposure - expected).abs() < 1e-9, "{terminal:?}, next={next_frame}"); + assert_eq!(h.app.session.undo.len(), undo + usize::from(terminal == Key::Enter), "{terminal:?}, next={next_frame}"); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0, "neither Num2 nor the saved Enter shortcut leaked"); + assert_eq!(h.app.session.active(), Some(active)); + assert_eq!(h.app.session.selection.ids, selection); + assert_eq!((h.app.ui.view, h.app.ui.right, &h.app.ui.tool), (view, right, &tool)); + assert!(h.app.ui.dialog.is_none()); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + if next_frame { + assert!(h.app.widgets.iter().all(|(id, _)| id != "sliderField:light.exposure"), "the editor is gone"); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderValue:light.exposure")); + } + } + } +} diff --git a/crates/ui-egui/src/tests_lights_out.rs b/crates/ui-egui/src/tests_lights_out.rs new file mode 100644 index 000000000..1720c7ad5 --- /dev/null +++ b/crates/ui-egui/src/tests_lights_out.rs @@ -0,0 +1,76 @@ +//! Headless tests of View ▸ Lights Out (Shift+L): On → Dim → Off → On, Esc turns the lights back +//! on, and everything but the photo is veiled while the photo keeps its pixels. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::LightsOut; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn loupe() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn key(h: &mut Headless, k: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn luma(img: &egui::ColorImage, p: egui::Pos2) -> u32 { + let [w, _] = img.size; + let c = img.pixels[(p.y as usize) * w + p.x as usize]; + c.r() as u32 + c.g() as u32 + c.b() as u32 +} + +#[test] +fn shift_l_cycles_lights_out_and_esc_turns_the_lights_back_on() { + let mut h = loupe(); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::Off); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + // plain L stays the Linear Gradient tool + key(&mut h, "L", false); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + key(&mut h, "Escape", false); + key(&mut h, "L", true); + key(&mut h, "Escape", false); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "dim"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim, "{r}"); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "bogus"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim, "{r}"); +} + +#[test] +fn lights_off_blacks_out_the_interface_but_not_the_photo() { + let mut h = loupe(); + let before = h.snapshot(SETTLE); + let img = h.app.image_rect.expect("the loupe shows the photo"); + let inside = img.center(); + let outside = egui::pos2(5.0, 5.0); + assert!(!img.contains(outside)); + assert!(luma(&before, outside) > 0, "the title/top bar is not black to begin with"); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "off"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Off, "{r}"); + let after = h.snapshot(SETTLE); + assert_eq!(luma(&after, outside), 0, "the interface goes black"); + assert_eq!(luma(&after, inside), luma(&before, inside), "the photo keeps its pixels"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 4dd7211e3..e071a7389 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -478,6 +478,54 @@ fn command_drag_straightens_in_crop() { h.settle(SETTLE); } +#[test] +fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = Some(json!([1, 1])); + h.settle(SETTLE); + let before = develop(&h); + let undo = h.app.session.undo.len(); + assert!(before.crop.aspect.is_none()); + let r = h.request( + "ui.pointer", + json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some((100, 100))); + assert_eq!(h.app.session.undo.len(), undo + 1); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); +} + +/// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the +/// drag still crops: it must not block every crop of an untouched photo. +#[test] +fn a_bad_remembered_ratio_is_dropped_and_the_drag_still_crops() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = Some(json!([0, -3])); + h.settle(SETTLE); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.crop_default_aspect, None, "the bad value is forgotten"); + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None); +} + #[test] fn double_click_in_crop_box_applies_the_crop() { let mut h = detail("panel.crop"); @@ -890,6 +938,60 @@ fn edit_in_external_editor_opens_the_copy() { h.settle(SETTLE); } +/// Edit in PhotoCraft: a PSD copy (what PhotoCraft saves back to), handed to "PhotoCraft" for +/// the host to find; when the host can't find it, no copy is made. +#[test] +fn edit_in_photocraft_opens_a_psd_copy() { + let dir = std::env::temp_dir().join(format!("lc-ui-pcraft-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let installed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (o, i) = (opened.clone(), installed.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + check_editor: Some(Box::new(move |app: &str| { + if app == crate::state::PHOTOCRAFT && !i.load(std::sync::atomic::Ordering::SeqCst) { + Err("PhotoCraft isn't installed".into()) + } else { + Ok(()) + } + })), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let n = h.app.session.catalog.len(); + let params = json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}); + let r = h.request("engine.execute", params.clone(), T); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!((h.app.session.catalog.len(), opened.lock().unwrap().len()), (n, 0), "no copy without PhotoCraft"); + installed.store(true, std::sync::atomic::Ordering::SeqCst); + let r = h.request("engine.execute", params, T); + assert_eq!(r["ok"], true, "{r}"); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.ends_with("-Edit.psd") && std::path::Path::new(&calls[0].0).exists(), "{calls:?}"); + assert_eq!(calls[0].1, "PhotoCraft"); + let d = lightcraft_codecs::decode(&std::fs::read(&calls[0].0).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16, "16-bit by default"); + // a portable copy: its path is what the host is given; the file follows the settings + h.app.ui.settings.photocraft_path = "/opt/pc/photocraft".into(); + h.app.ui.settings.photocraft_file.format = crate::state::EditFormat::Tiff; + h.app.ui.settings.photocraft_file.bit_depth = 8; + let r = h.request("engine.execute", json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + let last = opened.lock().unwrap().last().cloned().unwrap(); + assert!(last.0.ends_with(".tif") && last.1 == "/opt/pc/photocraft", "{last:?}"); + assert!(crate::menus::ui_enabled(&h.app, "photo.editInPhotoCraft")); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); @@ -1030,11 +1132,30 @@ fn soft_proofing_flags_out_of_gamut_colours_and_makes_proof_copies() { assert_eq!(r["ok"], true, "{r}"); assert!(h.app.ui.soft_proof); let st = exec(&mut h, "view.softProof", json!({"space": "srgb", "destWarning": true})); - assert_eq!(st, json!({"on": true, "space": "srgb", "destWarning": true, "displayWarning": false})); + assert_eq!( + st, + json!({"on": true, "space": "srgb", "profile": null, "intent": "perceptual", "paperInk": false, "destWarning": true, "displayWarning": false}) + ); h.settle(SETTLE); assert!(red(&h) > before, "out-of-gamut colours are painted red"); let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"space": "cmyk"}}), T); assert_ne!(r["ok"], true); + // a device (printer) profile, its intent and Simulate Paper & Ink + let dir = std::env::temp_dir().join(format!("proof-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("printer.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::Srgb)).unwrap(); + let st = exec(&mut h, "view.softProof", json!({"profile": icc.to_string_lossy(), "intent": "relative", "paperInk": true})); + assert_eq!((st["intent"].as_str(), st["paperInk"].as_bool()), (Some("relative"), Some(true)), "{st}"); + assert!(st["profile"]["name"].as_str().is_some_and(|n| !n.is_empty()), "{st}"); + assert_ne!(h.app.ui.proof.profile, 0); + h.settle(SETTLE); + assert!(red(&h) > before, "the device gamut is flagged too"); + let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"profile": "/nonexistent.icc"}}), T); + assert_ne!(r["ok"], true); + let st = exec(&mut h, "view.softProof", json!({"profile": null})); + assert_eq!((st["profile"].clone(), h.app.ui.proof.profile), (json!(null), 0)); + let _ = std::fs::remove_dir_all(&dir); // Create Proof Copy: a virtual copy named after the proof let n = h.app.session.catalog.photos().count(); let r = h.request("ui.clickWidget", json!({"id": "button:createProofCopy"}), T); @@ -1180,3 +1301,86 @@ fn color_range_pick_button_samples_the_photo() { assert!(h.app.ui.tool.is_empty()); h.settle(SETTLE); } + +#[test] +fn a_selected_develop_slider_accepts_wheel_nudges() { + let mut h = detail("panel.edit"); + // the demo may open on the Edit panel already, which `panel.edit` then toggles shut + let r = h.request("ui.set", json!({"right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let name = "slider:light.exposure"; + let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let track = h.app.widgets.iter().find(|(id, _)| id == name).unwrap().1; + assert!(panel.contains(track.center()), "slider must be visible before interaction"); + + let result = h.request("ui.clickWidget", json!({"id": name, "fx": 0.5}), T); + assert_eq!(result["ok"], true, "{result}"); + let before = lightcraft_develop::controls::get(&develop(&h), "light.exposure").expect("exposure"); + + // A wheel event over the selected control adjusts exposure instead of + // scrolling the right sidebar (an unselected slider leaves scrolling alone). + let result = h.request("ui.scroll", json!({"dy": 120.0}), T); + assert_eq!(result["ok"], true, "{result}"); + h.step(); + let after = lightcraft_develop::controls::get(&develop(&h), "light.exposure").expect("exposure"); + assert!(after > before, "selected exposure did not increase: {before} -> {after}"); +} + +/// Issue #517: a fast brush stroke (pointer samples several brush widths apart) shows on the photo +/// while the button is held as the continuous, feathered band it will be once committed, not as +/// separate dabs at the samples with gaps between them. +#[test] +fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { + let mut h = detail("panel.masking"); + h.settle(SETTLE); + exec(&mut h, "tool.brush", json!({"new": true})); + let r = h.request("ui.set", json!({"brushSize": 0.03, "brushFeather": 50.0, "brushFlow": 100.0}), T); + assert_eq!(r["ok"], true, "{r}"); + // the photo before painting, and where a normalized image point is on screen + pointer(&mut h, json!([{"kind": "move", "x": 0.02, "y": 0.02}])); + h.settle(SETTLE); + let photo = h.paint(); + let img = h.app.image_rect.expect("the photo is on screen"); + let ppp = h.pixels_per_point; + let px = |im: &egui::ColorImage, x: f32, y: f32| { + let (sx, sy) = (((img.left() + x * img.width()) * ppp) as usize, ((img.top() + y * img.height()) * ppp) as usize); + let c = im.pixels[sy * im.size[0] + sx]; + [c.r() as f32, c.g() as f32, c.b() as f32] + }; + // a drag that starts (past egui's drag threshold, at `a`) and then moves fast: samples far + // apart, button held + let (a, b, c) = ((0.17_f32, 0.4_f32), (0.5_f32, 0.4_f32), (0.85_f32, 0.4_f32)); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.15, "y": a.1}, {"kind": "drag", "x": a.0, "y": a.1}, {"kind": "drag", "x": b.0, "y": b.1}, {"kind": "drag", "x": c.0, "y": c.1}]), + ); + assert!(matches!(h.app.gesture, Some(crate::panels::detail::Gesture::Brush { .. })), "still painting"); + let held = h.paint(); + let [r0, g0, b0] = h.app.ui.mask_overlay_color.map(|v| v as f32); + let op = h.app.ui.mask_overlay_opacity / 100.0; + // along the centreline between the samples (away from the cursor ring at the last one), the + // overlay colour at full coverage over the photo + let mut gaps = vec![]; + for i in 1..=74 { + let x = a.0 + (c.0 - a.0) * i as f32 / 80.0; + let p = px(&photo, x, a.1); + let want = [p[0] + (r0 - p[0]) * op, p[1] + (g0 - p[1]) * op, p[2] + (b0 - p[2]) * op]; + let got = px(&held, x, a.1); + if (0..3).any(|k| (got[k] - want[k]).abs() > 12.0) { + gaps.push((x, got, want)); + } + } + assert!(gaps.is_empty(), "the live stroke has gaps: {} of 74 points, first {:?}", gaps.len(), gaps.first()); + pointer(&mut h, json!([{"kind": "up", "x": c.0, "y": c.1}])); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[0].points.len() >= 3, "{strokes:?}"); + // committed: the rendered overlay covers what the live stroke did, feather included + h.settle(SETTLE); + let done = h.paint(); + for (x, y) in [(0.3, a.1), (0.6, a.1), (0.3, a.1 + 0.02), (0.6, a.1 - 0.025), (0.45, a.1 + 0.06)] { + let (l, k) = (px(&held, x, y), px(&done, x, y)); + assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})"); + } +} diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests_menubar.rs index d0e905a50..8fbc13608 100644 --- a/crates/ui-egui/src/tests_menubar.rs +++ b/crates/ui-egui/src/tests_menubar.rs @@ -145,3 +145,39 @@ fn click_outside_and_escape_close_the_open_menu() { h.settle(SETTLE); assert_eq!(open_menu(&h), None, "Escape"); } + +// ---- a scrolled menu draws no scrollbar over its rows (issue #691) + +/// egui's floating scrollbar sat on the rows' right edge, where the shortcut text ends, and in +/// its wide (hovered) state its background hid the last character. With the pointer resting on +/// the menu, no tall narrow rectangle (a bar's handle or track) is painted along the level's +/// right edge, in a menu that scrolls as in one that fits, and the level is as wide in both. +#[test] +fn a_scrolled_menu_draws_no_scrollbar_over_its_shortcuts() { + let mut widths = Vec::new(); + for (size, scrolls) in [([1600.0, 700.0], true), ([1600.0, 1400.0], false)] { + let what = format!("{} pt window", size[1]); + let mut h = app(size); + go(&mut h, "ui.clickWidget", "menu:View"); + go(&mut h, "ui.hoverWidget", "menu-level:1"); + let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: no View menu")); + let rows = widgets_with(&h, "menusub:"); + assert!(!rows.is_empty(), "{what}: no submenu rows registered"); + assert_eq!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), scrolls, "{what}: scrolls = {scrolls}, level {level:?}"); + widths.push(level.width()); + // a row's hover highlight spans the level; a separator is one point tall; the ▸ of a + // submenu row and the text are not rectangles: only a scrollbar is tall and narrow there + let bars: Vec = h + .view + .painted_shapes() + .iter() + .filter_map(|s| match s { + egui::epaint::Shape::Rect(r) => Some(r.rect), + _ => None, + }) + .filter(|r| r.width() <= 14.0 && r.height() > 30.0 && r.left() >= level.right() - 16.0 && r.right() <= level.right() + 0.5) + .collect(); + assert!(bars.is_empty(), "{what}: a scrollbar is drawn along the menu's right edge: {bars:?}"); + } + assert!((widths[0] - widths[1]).abs() < 0.5, "a scrolled menu is as wide as one that fits: {widths:?}"); +} diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests_model_setup.rs index 85a7ca7fa..3068d8221 100644 --- a/crates/ui-egui/src/tests_model_setup.rs +++ b/crates/ui-egui/src/tests_model_setup.rs @@ -200,3 +200,24 @@ fn model_installation_waits_for_an_active_slider_gesture_to_finish() { drop(app); let _ = std::fs::remove_dir_all(dir); } + +/// Issue #922: closing the setup without installing a model turns the AI Denoise switch back off +/// and drops the "Cancel pending action" offer; while the setup stays open the request waits. +#[test] +fn dismissing_the_setup_without_a_model_leaves_ai_denoise_off() { + let (mut app, dir, raw) = setup("dismissed"); + let r = app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap(); + assert_eq!(r["setupRequired"], true); + resume(&mut app); + assert!(model_setup::denoise_requested(&app, raw), "the open setup keeps the request"); + app.ui.dialog = None; + resume(&mut app); + assert!(!model_setup::denoise_requested(&app, raw), "a dismissed setup leaves nothing pending"); + assert!(app.ui.status.contains("cancelled"), "{}", app.ui.status); + // installing later changes nothing; asking again offers the setup again + install_denoise(&mut app, &dir); + resume(&mut app); + assert_eq!(amount(&app, raw), 0.0); + drop(app); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs new file mode 100644 index 000000000..028801b56 --- /dev/null +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -0,0 +1,99 @@ +//! Issue #891: File ▸ Open Library… given a Lightroom Classic catalog folder doesn't make a +//! LightCraft library inside it. The library open now stays, and a dialog offers Import Lightroom +//! Catalog… with the catalog found, which starts the import of that `.lrcat`. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn names(dir: &std::path::Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + v.sort(); + v +} + +#[test] +fn open_library_on_a_lightroom_catalog_offers_its_import() { + let dir = std::env::temp_dir().join(format!("lc-ui-openlrcat-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let lib = dir.join("LightCraft Library"); + let lr = dir.join("Lightroom"); + std::fs::create_dir_all(lr.join("Lightroom Catalog Previews.lrdata")).unwrap(); + std::fs::create_dir_all(lr.join("Lightroom Catalog.lrcat-data")).unwrap(); + std::fs::create_dir_all(lr.join("Backups")).unwrap(); + let lrcat = lr.join("Lightroom Catalog.lrcat"); + std::fs::write(&lrcat, b"not really SQLite").unwrap(); + let before = names(&lr); + + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&lib, true).unwrap(); + let photos = session.catalog.len(); + assert!(photos > 0); + let picked = lr.to_string_lossy().to_string(); + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let services = Services { + pick_folder: Some(Box::new(move || Some(picked.clone()))), + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); + h.step(); + + // File ▸ Open Library…, the Lightroom folder chosen in the folder dialog + let r = h.app.run("app.openLibrary", json!({})).unwrap(); + assert_eq!(r["opened"], false, "{r}"); + assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); + h.step(); + h.step(); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::LightroomCatalog { catalog: Some(c), .. }) if *c == lrcat.to_string_lossy()), + "{:?}", + h.app.ui.dialog + ); + assert!(has(&h, "button:dialogOk") && has(&h, "button:dialogCancel")); + // the library open before is still open, and nothing was made in Lightroom's folder + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); + assert_eq!(h.app.session.catalog.len(), photos); + assert_eq!(names(&lr), before); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("prompt-891.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + + // Cancel: nothing happens + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T)["ok"], true); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + + // a previews folder given by path (control channel, agents): the same offer + let r = h.app.run("app.openLibrary", json!({"path": lr.join("Lightroom Catalog Previews.lrdata").to_string_lossy()})).unwrap(); + assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); + h.step(); + h.step(); + // Import Lightroom Catalog…: the import of that catalog starts (no file dialog), into the + // library open now. (This fake catalog then fails to read: only its path matters here.) + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); + h.step(); + assert!(h.app.ui.dialog.is_none()); + h.step_until(T, |h| h.app.lightroom.is_none()); + let last = h.app.lightroom_last.clone().unwrap_or_default(); + assert_eq!(last["path"], json!(lrcat), "{last}"); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); + assert_eq!(names(&lr), before, "the catalog is only read"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 1f5cd4d46..25edf3bfa 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -220,6 +220,781 @@ fn sidebar_sections_collapse_and_remember_it() { assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums"); } +/// Given a settings file from before sections could be arranged (it lists the folded ones as +/// `collapsedSidebar`), when it is loaded, then those sections are still folded, and what is +/// saved from then on is the arrangement alone. +#[test] +fn folded_sections_of_an_older_settings_file_stay_folded() { + let old = json!({"leftPanel": true, "collapsedSidebar": ["byDate", "keywords", "no such section"]}); + let ui = serde_json::from_value::(old).unwrap().sanitized(); + assert!(ui.sidebar_section_collapsed("byDate") && ui.sidebar_section_collapsed("keywords") && !ui.sidebar_section_collapsed("albums")); + let saved = serde_json::to_value(&ui).unwrap(); + assert!(saved.get("collapsedSidebar").is_none(), "{saved}"); + assert_eq!(saved["sidebar"][2], json!({"id": "byDate", "hidden": false, "collapsed": true})); +} + +/// Given a settings file whose sidebar arrangement is damaged, when it is loaded, then the rest of +/// the settings load and the sidebar draws every section. +#[test] +fn a_damaged_sidebar_arrangement_does_not_lose_the_settings() { + for sidebar in [json!("oops"), json!([{"id": "folders"}, {"id": "folders"}, 7, {"id": "ghost"}]), json!({"albums": 1})] { + let saved = json!({"leftPanel": true, "leftWidth": 300.0, "sidebar": sidebar}); + let ui = serde_json::from_value::(saved.clone()).unwrap().sanitized(); + assert_eq!(ui.left_width, 300.0, "{saved}"); + assert_eq!(ui.sidebar.sections().len(), crate::sidebar::SidebarSection::ALL.len(), "{saved}"); + } +} + +/// The sections drawn in the sidebar, top to bottom. +fn sections_drawn(h: &Headless) -> Vec { + let mut drawn: Vec<(f32, String)> = + h.app.widgets.iter().filter_map(|(w, r)| w.strip_prefix("sidebarSection:").map(|id| (r.top(), id.to_string()))).collect(); + drawn.sort_by(|a, b| a.0.total_cmp(&b.0)); + drawn.into_iter().map(|(_, id)| id).collect() +} + +/// Given an arrangement of the sidebar (an order, a hidden section), when the sidebar is drawn, +/// then its sections are in that order, the hidden one is not there, and every header is the same +/// distance below what is above it. +#[test] +fn the_sidebar_draws_its_sections_as_arranged() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + // (Local is not on every platform) + let listed = |drawn: &[String]| drawn.iter().filter(|s| s.as_str() != "local").cloned().collect::>(); + let usual = sections_drawn(&h); + assert_eq!(listed(&usual), ["albums", "byDate", "folders", "keywords"], "{usual:?}"); + // the demo library was imported from no folder: Folders says so in place of rows + assert!(has(&h, "sidebarEmpty:folders") && !has(&h, "sidebarEmpty:keywords")); + let r = h.request("ui.set", json!({"sidebar": [{"id": "keywords"}, {"id": "byDate", "hidden": true}, {"id": "albums"}]}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let drawn = sections_drawn(&h); + assert_eq!(listed(&drawn), ["keywords", "albums", "folders"], "{drawn:?}"); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "a hidden section draws no rows"); + // every header sits the same gap below the last row above it + let mut rows: Vec = + h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:") || w.starts_with("sidebarEmpty:")).map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + for id in &drawn { + let header = widget(&h, &format!("sidebarSection:{id}")); + let above = rows.iter().rev().find(|r| r.top() < header.top()).map(|r| r.bottom()).unwrap(); + assert_eq!(header.top() - above, 10.0, "{id}"); + } +} + +/// Run a command that must succeed; its result. +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + h.step(); + h.step(); + r["result"].clone() +} + +/// The View ▸ Sidebar Sections items: (section id or command, checked). +fn sidebar_menu_items(h: &Headless) -> Vec<(String, Option)> { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(&h.app); + let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; + let children = view + .iter() + .find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Sidebar Sections" => Some(children.clone()), + _ => None, + }) + .expect("View ▸ Sidebar Sections"); + children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, checked, .. } => Some((params["section"].as_str().unwrap_or(id).to_string(), *checked)), + _ => None, + }) + .collect() +} + +/// Given the sidebar, when a section is unchecked in View ▸ Sidebar Sections, then it leaves the +/// sidebar (header and rows) and the menu shows it unchecked; checking it again brings it back +/// as it was. The choice is part of the saved UI state. +#[test] +fn a_section_is_hidden_and_shown_from_the_view_menu() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let checked = |h: &Headless, id: &str| sidebar_menu_items(h).into_iter().find(|(s, _)| s == id).unwrap_or_else(|| panic!("no {id} item")).1; + assert_eq!(checked(&h, "byDate"), Some(true)); + assert!(has(&h, "sidebarSection:byDate")); + // folded first: it must come back folded + click(&mut h, "sidebarSection:byDate"); + let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate"})); + assert_eq!(r, json!({"section": "byDate", "show": false, "collapsed": true})); + assert!(!has(&h, "sidebarSection:byDate"), "the header is gone"); + assert_eq!(checked(&h, "byDate"), Some(false)); + assert_eq!(checked(&h, "albums"), Some(true), "the others stay"); + // saved and read back + let back = serde_json::from_value::(serde_json::to_value(&h.app.ui).unwrap()).unwrap().sanitized(); + assert!(back.sidebar.is_hidden(crate::sidebar::SidebarSection::ByDate)); + let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate"})); + assert_eq!(r["show"], true); + assert!(has(&h, "sidebarSection:byDate") && h.app.ui.sidebar_section_collapsed("byDate"), "back, still folded"); + // saying what is wanted, instead of flipping it: showing a shown section changes nothing + let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": true, "collapsed": false})); + assert_eq!(r, json!({"section": "byDate", "show": true, "collapsed": false})); + assert!(h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "open again: its rows are drawn"); +} + +/// Given sections moved out of sight, when Reset Sidebar Sections is chosen, then every section +/// is shown and open again; the item is only offered when there is something to reset. +#[test] +fn resetting_the_sidebar_brings_every_section_back() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let reset_enabled = |h: &Headless| { + let bar = crate::menubar::menu_bar(&h.app); + let json = serde_json::to_string(&bar).unwrap(); + // the item as the menu model describes it + let at = json.find("view.sidebarReset").expect("the reset item"); + json[at..].split("enabled\":").nth(1).is_some_and(|rest| rest.starts_with("true")) + }; + assert!(!reset_enabled(&h), "nothing to reset yet"); + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "keywords", "collapsed": true})); + assert!(reset_enabled(&h)); + assert!(!has(&h, "sidebarSection:albums")); + exec(&mut h, "view.sidebarReset", json!({})); + assert!(h.app.ui.sidebar.is_default()); + assert!(has(&h, "sidebarSection:albums") && !h.app.ui.sidebar_section_collapsed("keywords")); +} + +/// Given a section's header, when it is right-clicked and Hide This Section is chosen, then the +/// section is hidden; and the menu of the My Photos title, which is always there, shows it again. +#[test] +fn a_section_is_hidden_from_its_header_and_brought_back_from_the_title() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + right_click(&mut h, "sidebarSection:keywords"); + assert!(popup_open(&h), "the header has a menu"); + click(&mut h, "sidebarMenu:hide"); + assert!(!has(&h, "sidebarSection:keywords") && !popup_open(&h)); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:keyword:")), "its rows went with it"); + right_click(&mut h, "sidebarTitle"); + assert!(popup_open(&h), "the title has the menu too"); + assert!(!has(&h, "sidebarMenu:hide"), "the title is no section: nothing to hide"); + click(&mut h, "sidebarMenu:section:keywords"); + assert!(has(&h, "sidebarSection:keywords")); + // …and from there every section can go back to the usual arrangement + exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": false})); + right_click(&mut h, "sidebarTitle"); + click(&mut h, "sidebarMenu:reset"); + assert!(h.app.ui.sidebar.is_default()); +} + +/// Given an album is being shown, when Albums is hidden, then the grid goes on showing the album. +#[test] +fn hiding_the_section_of_what_is_shown_keeps_showing_it() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let album = h.app.session.execute("album.create", &json!({"name": "Kept"})).unwrap()["id"].as_u64().unwrap(); + exec(&mut h, "library.source", json!({"kind": "album", "id": album})); + let shown = h.app.session.source; + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false})); + assert!(!has(&h, &format!("source:album:{album}"))); + assert_eq!(h.app.session.source, shown); +} + +/// Given Albums is hidden (and folded), when an album is made from the menu, then Albums is back +/// and open, so the new album can be seen. +#[test] +fn a_new_album_brings_a_hidden_albums_section_back() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false, "collapsed": true})); + exec(&mut h, "dialog.newAlbum", json!({"name": "Fresh"})); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let id = h.app.session.catalog.albums().find(|a| a.name == "Fresh").expect("the album was made").id.0; + assert!(has(&h, "sidebarSection:albums") && has(&h, &format!("source:album:{id}"))); +} + +/// A command that names no section, an unknown one, or a flag that is no true / false is refused +/// with what it takes, and changes nothing. +#[test] +fn sidebar_commands_refuse_what_they_cannot_read() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + for (params, says) in [ + (json!({}), "`section` is needed (albums|local|byDate|folders|keywords)"), + (json!({"section": 3}), "`section` is needed"), + (json!({"section": "map"}), "unknown section `map`"), + (json!({"section": "albums", "show": "no"}), "`show` must be true or false"), + (json!({"section": "albums", "collapsed": 1}), "`collapsed` must be true or false"), + ] { + let r = h.request("engine.execute", json!({"command": "view.sidebarSection", "params": params}), T); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert!(r.to_string().contains(says), "{params}: {r}"); + } + assert!(h.app.ui.sidebar.is_default()); +} + +/// Press on `from`, move to `to` over a few frames, and release there (after `before_release`). +fn drag_between(h: &mut Headless, from: egui::Pos2, to: egui::Pos2, before_release: Vec) { + let m = egui::Modifiers::NONE; + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + h.step(); + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.step(); + for e in before_release { + h.app.synthetic.push(e); + h.step(); + } + h.app.synthetic.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + h.step(); + h.step(); + h.step(); +} + +fn header(h: &Headless, id: &str) -> egui::Rect { + widget(h, &format!("sidebarSection:{id}")) +} + +/// Given the sidebar, when a section's header is dragged above another section and released, +/// then the section is listed there, the others keeping their order; while it is dragged a line +/// shows where it would go; and the new order is part of the saved UI state. +#[test] +fn dragging_a_header_moves_its_section() { + // folders too: photos imported from a folder + let mut h = folders_app_sized(&["/pics/trip/a.jpg"], [1400.0, 1400.0]); + let usual = sections_drawn(&h); + let with_local = usual.contains(&"local".to_string()); + let listed = |ids: &[&str]| ids.iter().filter(|s| with_local || **s != "local").map(|s| s.to_string()).collect::>(); + assert_eq!(usual, listed(&["albums", "local", "byDate", "folders", "keywords"])); + assert!(has(&h, "sidebarEmpty:keywords"), "no keywords in this library: the section says so and stays"); + // Folders, dragged to just above Albums + let (from, to) = (header(&h, "folders").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0)); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.step(); + assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Folders)); + let line = widget(&h, "sidebarSectionDrop"); + assert!( + line.center().y < header(&h, "albums").top() && line.center().y > header(&h, "albums").top() - 10.0, + "the line is in the gap above Albums: {line:?}" + ); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + h.step(); + assert_eq!(h.app.ui.dragging_section, None); + assert!(!has(&h, "sidebarSectionDrop")); + assert_eq!(sections_drawn(&h), listed(&["folders", "albums", "local", "byDate", "keywords"])); + // dragged below everything: last + let end = egui::pos2(header(&h, "keywords").center().x, widget(&h, "panel:left_panel").bottom() - 20.0); + let from = header(&h, "albums").center(); + drag_between(&mut h, from, end, Vec::new()); + assert_eq!(sections_drawn(&h), listed(&["folders", "local", "byDate", "keywords", "albums"])); + // kept with the UI state + let back = serde_json::from_value::(serde_json::to_value(&h.app.ui).unwrap()).unwrap().sanitized(); + assert_eq!(back.sidebar, h.app.ui.sidebar); + // the header still folds its section with a click + click(&mut h, "sidebarSection:folders"); + assert!(h.app.ui.sidebar_section_collapsed("folders")); +} + +/// A drag that ends with Esc, or is released away from the sidebar, moves nothing. +#[test] +fn a_section_drag_that_is_given_up_moves_nothing() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let usual = sections_drawn(&h); + let (from, to) = (header(&h, "keywords").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0)); + let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + drag_between(&mut h, from, to, vec![esc]); + assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "Esc"); + // released over the photos + drag_between(&mut h, from, egui::pos2(800.0, to.y), Vec::new()); + assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "released outside"); + assert!(h.app.ui.sidebar.is_default()); +} + +/// An album dragged to the Albums header is not a section drag, and the other way round. +#[test] +fn dragging_a_section_is_not_dragging_an_album() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let (from, to) = (header(&h, "albums").center(), header(&h, "keywords").center() + egui::vec2(0.0, 4.0)); + let before = h.app.session.catalog.revision; + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * 0.5)); + h.step(); + h.step(); + assert!(h.app.ui.dragging_section.is_some() && h.app.ui.dragging_album.is_none()); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.revision, before, "no album moved"); + assert!(!h.app.ui.sidebar.is_default(), "the section did"); + // …and an album dragged onto another section's header moves no section + let album = h.app.session.catalog.albums().find(|a| !a.folder).expect("the demo library has albums").id.0; + let arranged = h.app.ui.sidebar.clone(); + let (from, to) = (widget(&h, &format!("source:album:{album}")).center(), header(&h, "keywords").center()); + drag_between(&mut h, from, to, Vec::new()); + assert_eq!((h.app.ui.sidebar.clone(), h.app.ui.dragging_section), (arranged, None)); +} + +/// Given a section's header menu, Move Up and Move Down move it one shown section at a time +/// (stepping over hidden ones), and the command says when there is nowhere to go. +#[test] +fn a_section_moves_up_and_down_from_its_menu() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + // (Local is not on every platform: taken out, so the same sections are there everywhere) + exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false})); + assert_eq!(sections_drawn(&h), ["albums", "byDate", "keywords"]); + right_click(&mut h, "sidebarSection:keywords"); + click(&mut h, "sidebarMenu:moveUp"); + assert_eq!(sections_drawn(&h), ["albums", "keywords", "byDate"]); + right_click(&mut h, "sidebarSection:albums"); + click(&mut h, "sidebarMenu:moveDown"); + assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"], "one step, over the hidden sections"); + // the ends: nowhere to go + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "keywords", "direction": "up"})); + assert_eq!(r["moved"], false, "{r}"); + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "direction": "down"})); + assert_eq!(r["moved"], false, "{r}"); + // above a named section, or last + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": "keywords"})); + assert_eq!(r["moved"], true, "{r}"); + assert_eq!(sections_drawn(&h), ["byDate", "keywords", "albums"]); + exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": null})); + assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"]); + assert_eq!(h.app.ui.sidebar.sections().last().map(|s| s.id.id()), Some("byDate")); + for (params, says) in [ + (json!({"section": "albums"}), "give either `direction`"), + (json!({"section": "albums", "direction": "up", "before": null}), "give either `direction`"), + (json!({"section": "albums", "direction": "left"}), "`direction` must be"), + (json!({"section": "albums", "before": "map"}), "`before` must be a section"), + (json!({"direction": "up"}), "`section` is needed"), + ] { + let r = h.request("engine.execute", json!({"command": "view.sidebarMoveSection", "params": params}), T); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert!(r.to_string().contains(says), "{params}: {r}"); + } +} + +/// Recently Deleted is one of My Photos' rows: right below the others, above every section. +#[test] +fn recently_deleted_is_listed_with_my_photos() { + let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let (picks, deleted) = (widget(&h, "source:picks"), widget(&h, "source:recentlyDeleted")); + assert_eq!(deleted.top(), picks.bottom(), "no gap: the same group"); + let first = sections_drawn(&h).remove(0); + assert_eq!(header(&h, &first).top() - deleted.bottom(), 10.0, "the sections start below it"); +} + +/// An arrangement set through the control channel is checked: a wrong one is refused and leaves +/// the sidebar as it was; the older `collapsedSidebar` list still folds and unfolds sections. +#[test] +fn an_arrangement_set_by_an_agent_is_checked() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + exec(&mut h, "view.sidebarSection", json!({"section": "keywords", "show": false})); + let arranged = h.app.ui.sidebar.clone(); + for bad in [json!(null), json!({"albums": 1}), json!([{"id": "keywrods", "hidden": true}]), json!(["albums", "albums"])] { + let r = h.request("ui.set", json!({"sidebar": bad}), T); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.ui.sidebar, arranged, "{bad}"); + } + let r = h.request("ui.set", json!({"collapsedSidebar": ["byDate"]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.sidebar_section_collapsed("byDate") && !h.app.ui.sidebar_section_collapsed("albums")); + assert!(h.app.ui.sidebar.is_hidden(crate::sidebar::SidebarSection::Keywords), "the rest of the arrangement stays"); + let r = h.request("ui.set", json!({"collapsedSidebar": []}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.ui.sidebar_section_collapsed("byDate")); + // a name it doesn't know, or both ways of saying it at once, is refused and changes nothing + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "collapsed": true})); + let arranged = h.app.ui.sidebar.clone(); + for bad in [json!({"collapsedSidebar": ["albms"]}), json!({"collapsedSidebar": [], "sidebar": [{"id": "albums", "collapsed": true}]})] { + let r = h.request("ui.set", bad.clone(), T); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.ui.sidebar, arranged, "{bad}"); + } +} + +/// Where a dragged section goes: above the first section whose middle is below the pointer. +#[test] +fn a_dragged_section_goes_above_the_section_it_is_over_the_top_half_of() { + use crate::panels::left::{PlacedSection, section_drop_before}; + use crate::sidebar::SidebarSection::{Albums, ByDate, Keywords}; + let placed = [ + PlacedSection { section: Albums, top: 100.0, bottom: 300.0, open: true, rows: 1 }, + PlacedSection { section: ByDate, top: 310.0, bottom: 344.0, open: true, rows: 1 }, + PlacedSection { section: Keywords, top: 354.0, bottom: 500.0, open: true, rows: 1 }, + ]; + for (y, before) in [ + (0.0, Some(Albums)), + (199.0, Some(Albums)), + (201.0, Some(ByDate)), + (326.0, Some(ByDate)), + (328.0, Some(Keywords)), + (430.0, None), + (9e9, None), + ] { + assert_eq!(section_drop_before(&placed, y), before, "{y}"); + } + assert_eq!(section_drop_before(&[], 10.0), None); + assert_eq!(section_drop_before(&placed, f32::NAN), None); +} + +/// By Date opens a year into its months with the triangle, and closes it again. +#[test] +fn a_year_opens_into_its_months() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:date:")).count(); + let years = rows(&h); + let toggle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(w, _)| w.clone()).expect("a year has a triangle"); + click(&mut h, &toggle); + assert!(rows(&h) > years, "months are listed"); + click(&mut h, &toggle); + assert_eq!(rows(&h), years, "closed again"); +} + +/// Given a section longer than the sidebar, when its header scrolls out of sight, then a copy of +/// the header stays at the top of the sidebar for as long as the section's rows are there; a +/// click on it folds the section and brings the section's own header into view. +#[test] +fn the_header_of_a_long_section_stays_in_sight() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + for i in 0..40 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + h.step(); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("sidebarSectionPinned:")), "nothing is pinned while every header is in sight"); + let own = header(&h, "albums"); + let c = egui::pos2(own.center().x, 400.0); + let scroll = |h: &mut Headless, dy: f32| { + // the wheel scrolls what the pointer is over + h.request("ui.move", json!({"x": c.x, "y": c.y}), T); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..60 { + h.step(); + } + }; + scroll(&mut h, -500.0); + assert!(header(&h, "albums").top() < own.top() - 300.0, "the sidebar scrolled"); + let pinned = widget(&h, "sidebarSectionPinned:albums"); + let first_row = h + .app + .widgets + .iter() + .filter(|(w, r)| w.starts_with("source:album:") && r.bottom() > pinned.bottom()) + .map(|(_, r)| r.top()) + .fold(f32::MAX, f32::min); + assert!( + pinned.top() > header(&h, "albums").bottom() && first_row < pinned.bottom() + 29.0, + "at the top of the rows in view: {pinned:?} {first_row}" + ); + assert!(h.app.widgets.iter().any(|(w, r)| w == "icon:albumNew" && pinned.contains_rect(*r)), "with its buttons"); + // folded from the pinned header: Albums' own header is what the sidebar shows now + click(&mut h, "sidebarSectionPinned:albums"); + h.settle(SETTLE); + assert!(h.app.ui.sidebar_section_collapsed("albums")); + assert!(!has(&h, "sidebarSectionPinned:albums"), "a folded section has no rows to pin its header over"); + let own = header(&h, "albums"); + assert!((own.top() - pinned.top()).abs() < 1.0, "its own header is where the pinned one was, at the top of the list: {own:?} {pinned:?}"); +} + +/// Scroll the sidebar (the pointer over it) by `dy`. +fn scroll_sidebar(h: &mut Headless, dy: f32) { + h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..60 { + h.step(); + } +} + +/// Given a pinned Albums header with album folders scrolled beneath it, when an album is dragged +/// onto the pinned header and released, then it goes to the top level — what the header shows — +/// and not into the folder hidden under the header. +#[test] +fn a_drop_on_the_pinned_header_is_not_a_drop_on_the_row_beneath_it() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + for i in 0..30 { + make(&mut h, json!({"name": format!("Folder {i:02}"), "folder": true})); + } + let last = make(&mut h, json!({"name": "Zz last", "folder": true})); + let best = make(&mut h, json!({"name": "Best", "parent": last})); + h.step(); + h.step(); + let parent = |h: &Headless| h.app.session.catalog.album(lightcraft_catalog::AlbumId(best)).unwrap().parent.map(|p| p.0); + let row = |h: &Headless| widget(h, &format!("source:album:{best}")); + // far enough that Best is in view and Albums' own header is not + for _ in 0..8 { + if row(&h).bottom() < 600.0 { + break; + } + scroll_sidebar(&mut h, -200.0); + } + let pinned = widget(&h, "sidebarSectionPinned:albums"); + assert!(row(&h).bottom() < 600.0 && row(&h).top() > pinned.bottom(), "Best is in view below the pinned header: {:?}", row(&h)); + let beneath = h.app.widgets.iter().filter(|(w, r)| w.starts_with("source:folder:") && r.contains(pinned.center())).count(); + assert_eq!(beneath, 1, "a folder row lies under the middle of the pinned header"); + let from = row(&h).center(); + drag_between(&mut h, from, pinned.center(), Vec::new()); + assert_eq!(parent(&h), None, "to the top level, as the header said"); +} + +/// Given a long section scrolled so that its header is pinned, when another section is dragged up +/// to the top edge, then the sidebar scrolls up to reach the sections above, although the pointer +/// is over the pinned header there. A section picked up by the pinned header itself does not +/// start scrolling until the pointer has left it. +#[test] +fn a_section_dragged_to_the_top_edge_scrolls_past_the_pinned_header() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + for i in 0..40 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + let press = |h: &mut Headless, at: egui::Pos2| { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + }; + let hold = |h: &mut Headless, at: egui::Pos2, frames: usize| { + for _ in 0..frames { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + } + }; + let give_up = |h: &mut Headless| { + h.app.synthetic.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { + pos: egui::pos2(800.0, 400.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + }; + // Local's header is in view below the albums, Albums' is pinned + for _ in 0..12 { + if has(&h, "sidebarSectionPinned:albums") && header(&h, "byDate").top() < 600.0 { + break; + } + scroll_sidebar(&mut h, -200.0); + } + let pinned = widget(&h, "sidebarSectionPinned:albums"); + let own = |h: &Headless| header(h, "albums").top(); + // picked up by the pinned header: held inside it, nothing scrolls + let start = own(&h); + press(&mut h, pinned.center()); + hold(&mut h, pinned.center() + egui::vec2(12.0, 3.0), 12); + assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Albums)); + assert_eq!(own(&h), start, "still on the header it was picked up by"); + give_up(&mut h); + // By Date, picked up by its own header and taken to the top edge: the sidebar scrolls up + let start = own(&h); + let from = header(&h, "byDate").center(); + press(&mut h, from); + hold(&mut h, from - egui::vec2(0.0, 60.0), 3); + hold(&mut h, pinned.center(), 30); + assert!(own(&h) > start + 20.0, "scrolled towards the sections above: {start} -> {}", own(&h)); + give_up(&mut h); + assert!(h.app.ui.sidebar.is_default()); +} + +/// The pinned header belongs to the section whose rows are at the top of the sidebar: scrolling on +/// into the next section pushes it out and pins that section's header instead. +#[test] +fn the_next_section_takes_over_the_pinned_header() { + let mut h = demo([1400.0, 500.0], json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["albums", "keywords"]})); + exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false})); + for i in 0..12 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + let pinned = |h: &Headless| -> Vec { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("sidebarSectionPinned:").map(str::to_string)).collect() + }; + let mut seen = Vec::new(); + for _ in 0..30 { + scroll_sidebar(&mut h, -60.0); + let now = pinned(&h); + assert!(now.len() <= 1, "one header at a time: {now:?}"); + if let Some(id) = now.first().filter(|id| seen.last() != Some(*id)) { + seen.push(id.clone()); + } + } + assert_eq!(seen, ["albums", "keywords"]); +} + +/// Given the Keywords header, when its + is clicked, then Create Keyword Tag opens (for a keyword +/// at the usual place, whatever is picked in the Keyword List) and the section does not fold; +/// and the keyword made there is listed in the section. +#[test] +fn the_keywords_header_creates_a_keyword() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + // something picked in the Keyword List: not where a keyword made from the sidebar goes + h.app.ui.keyword_list_selected = Some("aurora".into()); + let (button, head) = (widget(&h, "icon:keywordNew"), header(&h, "keywords")); + assert!(head.contains_rect(button), "the + is in the header: {button:?} in {head:?}"); + click(&mut h, "icon:keywordNew"); + assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "the + does not fold Keywords"); + let Some(crate::state::Dialog::KeywordTag { editing, name, parent, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((editing.clone(), parent.clone()), (None, None)); + *name = "zebra crossing".into(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed"); +} + +/// A folded section's row count sits to the left of its header button; a section without a button +/// has its count at the edge. +#[test] +fn a_header_button_stays_with_a_folded_header() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + for (section, button) in [("keywords", "icon:keywordNew"), ("albums", "icon:albumNew")] { + click(&mut h, &format!("sidebarSection:{section}")); + assert!(h.app.ui.sidebar_section_collapsed(section)); + let (count, plus) = (widget(&h, &format!("sidebarSectionCount:{section}")), widget(&h, button)); + assert!(count.right() <= plus.left(), "{section}: the count {count:?} is left of the button {plus:?}"); + } + // By Date has no button: its count goes where the others have theirs + click(&mut h, "sidebarSection:byDate"); + let (plain, beside_button) = (widget(&h, "sidebarSectionCount:byDate"), widget(&h, "sidebarSectionCount:keywords")); + assert!(plain.right() >= widget(&h, "icon:keywordNew").left() && plain.right() > beside_button.right() + 20.0, "{plain:?} {beside_button:?}"); +} + +/// Given Keywords is folded and new keywords go inside a parent, when a keyword is made with the +/// header's +, then Keywords opens, and so does the parent, so the new keyword is there to see. +/// A keyword made elsewhere (the Keyword List) leaves the sidebar as it is. +#[test] +fn a_keyword_made_from_the_header_is_shown() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + exec(&mut h, "keyword.create", json!({"name": "places", "parent": null})); + exec(&mut h, "keyword.setDefaultParent", json!({"keyword": "places"})); + click(&mut h, "sidebarSection:keywords"); + assert!(h.app.ui.sidebar_section_collapsed("keywords")); + let confirm = |h: &mut Headless, name: &str| { + let Some(crate::state::Dialog::KeywordTag { name: n, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) }; + *n = name.into(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + // from the Keyword List: nothing is revealed + h.app.ui.dialog = Some(crate::panels::keyword_list::create_dialog(&h.app)); + confirm(&mut h, "harbour"); + assert!(h.app.ui.sidebar_section_collapsed("keywords") && !has(&h, "source:keyword:places|harbour")); + // from the header's + + click(&mut h, "icon:keywordNew"); + confirm(&mut h, "lighthouse"); + assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "Keywords opened"); + assert!(has(&h, "source:keyword:places|lighthouse"), "and its parent, down to the new keyword"); +} + +/// A press on a header's button that turns into a drag is not a drag of the section. +#[test] +fn dragging_from_a_header_button_does_not_drag_the_section() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + for button in ["icon:keywordNew", "icon:albumNew"] { + let from = widget(&h, button).center(); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from - egui::vec2(8.0 * i as f32, 30.0 * i as f32))); + h.step(); + assert_eq!(h.app.ui.dragging_section, None, "{button}"); + } + let to = from - egui::vec2(48.0, 180.0); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + } + assert!(h.app.ui.sidebar.is_default()); +} + +/// A folded section says how many rows it holds (unless photo counts are off); an open one +/// doesn't need to. +#[test] +fn a_folded_section_says_how_much_it_holds() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + click(&mut h, "sidebarSection:byDate"); + assert!(has(&h, "sidebarSectionCount:byDate")); + exec(&mut h, "view.photoCounts", json!({"show": false})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + // an empty section has nothing to count + exec(&mut h, "view.photoCounts", json!({"show": true})); + click(&mut h, "sidebarSection:folders"); + assert!(h.app.ui.sidebar_section_collapsed("folders") && !has(&h, "sidebarSectionCount:folders")); +} + +/// A section's chevron is in the same column as the triangles of the rows below it. +#[test] +fn section_chevrons_line_up_with_row_triangles() { + let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let triangle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(_, r)| *r).expect("a year has a triangle"); + for id in sections_drawn(&h) { + assert_eq!(widget(&h, &format!("sidebarSectionChevron:{id}")).center().x, triangle.center().x, "{id}"); + } +} + /// Albums nest in folders like the other sidebar trees: a folder row has a disclosure triangle /// (`albumToggle:`), plain albums have none, and folding a folder hides what is inside it. #[test] @@ -964,7 +1739,8 @@ fn removing_a_disk_from_the_library_asks_first() { #[test] fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; - let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]); + // (tall enough for the whole chain: Recently Deleted is above it now) + let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); let mut xs: Vec = Vec::new(); let mut path = String::new(); for name in deep.split('/').filter(|n| !n.is_empty()) { @@ -1059,7 +1835,8 @@ fn a_long_name_alone_does_not_make_the_sidebar_scroll() { #[test] fn the_selection_bar_stays_inside_the_panel() { let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; - let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]); + // (tall enough for the whole chain: Recently Deleted is above it now) + let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); let panel = widget(&h, "panel:left_panel"); let bar = widget(&h, "highlight:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); @@ -1182,3 +1959,185 @@ fn the_tracklog_toast_shows_after_a_while() { assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Tagged")), "{:?}", h.app.ui.toast); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #678: Settings ▸ Faces cannot switch recognition on until a recognition model is chosen: +/// the box is off and clicking it changes nothing. Installing a model switches recognition on by +/// itself, and from then on the box turns it off and on. +#[test] +fn recognise_faces_stays_off_until_a_model_is_chosen() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid"})); + let dir = std::env::temp_dir().join(format!("lc-ui-faces-toggle-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // (the first request switches the widget list on, the next frame fills it) + h.request("ui.widgets", json!({}), T); + h.step(); + let enabled = |h: &mut Headless| h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"] == true; + let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; + assert!(!enabled(&mut h)); + let click = |h: &mut Headless| { + let r = h.request("ui.clickWidget", json!({"id": "check:settings.facesEnabled"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + click(&mut h); + assert!(!enabled(&mut h), "nothing to run: the box stays off"); + assert!(h.app.session.execute("faces.enable", &json!({"enabled": true})).is_err(), "the engine refuses too"); + if !runtime { + let _ = std::fs::remove_dir_all(&dir); + return; + } + // a model: installing it chooses it and switches recognition on; the box then works both ways + let f = dir.join("m.onnx"); + std::fs::write(&f, lightcraft_faces::synthetic::tiny_embedder_model(64)).unwrap(); + h.app.session.execute("faces.models.install", &json!({"path": f.to_string_lossy(), "acknowledged": true})).unwrap(); + h.app.caches.faces_epoch += 1; + h.step(); + h.step(); + assert!(enabled(&mut h)); + click(&mut h); + assert!(!enabled(&mut h), "the box switches recognition off"); + click(&mut h); + assert!(enabled(&mut h), "and on again, with a model chosen"); +} + +/// Issue #684: albums hold library photos. For a browsed (Local) photo the grid's right-click +/// Add to Album is off, Photo ▸ Add to Album says to Add to My Photos first with its entries off, +/// and a drop onto an album row is refused with that hint; once the photo is in My Photos all +/// three work. +#[test] +fn local_photos_cannot_be_added_to_albums_until_they_join_my_photos() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let album = h.app.session.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let album_id = lightcraft_catalog::AlbumId(album); + let dir = std::env::temp_dir().join(format!("lc-ui-albums-local-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let vis = h.app.session.visible_cloned(); + assert_eq!(vis.len(), 1, "{vis:?}"); + let local = vis[0]; + assert!(h.app.session.catalog.photo(local).unwrap().local); + let thumb = widget(&h, &format!("thumb:{}", local.0)); + + // right-click: Add to Album is there but off, so hovering opens no album entries + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.session.selection.contains(local)); + let r = h.request("ui.hoverWidget", json!({"id": "button:contextAddToAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("button:contextAlbum:")), "the submenu must not open for a Local photo"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + + // Photo ▸ Add to Album: the hint item first (off), the album and New Album… off + let add_to_album = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let photo = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1; + photo + .iter() + .find_map(|n| match n { + crate::menubar::MenuNode::Submenu { label, children } if label == "Add to Album" => Some(children.clone()), + _ => None, + }) + .expect("Add to Album submenu") + }; + let enabled_of = |nodes: &[crate::menubar::MenuNode], id: &str| -> Option { + nodes.iter().find_map(|n| match n { + crate::menubar::MenuNode::Item { id: i, enabled, .. } if i == id => Some(*enabled), + _ => None, + }) + }; + let nodes = add_to_album(&h.app); + assert!(matches!(nodes.first(), Some(crate::menubar::MenuNode::Item { id, enabled: false, .. }) if id == "photo.addToLibrary"), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "album.addPhotos"), Some(false), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "dialog.newAlbum"), Some(false), "{nodes:?}"); + + // a drop onto the album row is refused and says why + let row = widget(&h, &format!("source:album:{album}")); + let r = h.request("ui.drag", json!({"x": thumb.center().x, "y": thumb.center().y, "toX": row.center().x, "toY": row.center().y, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.album(album_id).unwrap().photos.is_empty(), "nothing invisible was added"); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("Add to My Photos first"), "{toast:?}"); + + // in My Photos: the menu entries come on, the submenu opens, and the album takes the photo + h.app.run("photo.addToLibrary", json!({"ids": [local.0]})).unwrap(); + h.step(); + let nodes = add_to_album(&h.app); + assert!(!matches!(nodes.first(), Some(crate::menubar::MenuNode::Item { id, .. }) if id == "photo.addToLibrary"), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "album.addPhotos"), Some(true), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "dialog.newAlbum"), Some(true), "{nodes:?}"); + let thumb = widget(&h, &format!("thumb:{}", local.0)); + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.hoverWidget", json!({"id": "button:contextAddToAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:contextAlbum:{album}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.album(album_id).unwrap().photos, vec![local]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #676: right-click ▸ Add to Album offers New Album… like the Photo menu does, also when +/// the library has no album yet (the submenu used to come up empty); the new album holds the photo. +#[test] +fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let plain: Vec = h.app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| a.id.0).collect(); + for id in plain { + h.app.session.execute("album.delete", &json!({"id": id})).unwrap(); + } + assert!(h.app.session.catalog.albums().all(|a| a.folder || a.is_smart()), "no plain album is left"); + let photo = lightcraft_catalog::PhotoId(h.app.session.visible_cloned()[1].0); + h.step(); + let thumb = widget(&h, &format!("thumb:{}", photo.0)); + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.session.selection.contains(photo), "right-click selects the photo"); + let r = h.request("ui.hoverWidget", json!({"id": "button:contextAddToAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:contextNewAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + match &mut h.app.ui.dialog { + Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None }) => *name = "Trip".into(), + other => panic!("New Album dialog expected, got {other:?}"), + } + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let trip = h.app.session.catalog.albums().find(|a| a.name == "Trip").expect("the album was created"); + assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); +} diff --git a/crates/ui-egui/src/tests_preset_import.rs b/crates/ui-egui/src/tests_preset_import.rs new file mode 100644 index 000000000..7a1abf6ee --- /dev/null +++ b/crates/ui-egui/src/tests_preset_import.rs @@ -0,0 +1,88 @@ +//! File ▸ Import Profiles & Presets…: `.cube` LUTs become profiles, everything else presets, and +//! one toast says what happened to both (issue #942). + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn app() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(SETTLE); + h +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-ui-preset-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn import(h: &mut Headless, paths: &[&std::path::Path]) -> serde_json::Value { + let paths: Vec = paths.iter().map(|p| p.to_string_lossy().to_string()).collect(); + let r = h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T); + h.settle(SETTLE); + r +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default() +} + +/// A 2×2×2 identity LUT. +fn identity_cube() -> String { + let mut s = String::from("TITLE \"Identity\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + s += &format!("{r} {g} {b}\n"); + } + } + } + s +} + +/// Importing only `.cube` files reports the profiles, not "No new presets". +#[test] +fn a_cube_only_import_reports_the_profiles() { + let dir = temp_dir("ok"); + let cube = dir.join("Warm.cube"); + std::fs::write(&cube, identity_cube()).unwrap(); + let mut h = app(); + let r = import(&mut h, &[&cube]); + assert_eq!(r["ok"], true, "{r}"); + let t = toast(&h); + assert!(t.contains("1 profile"), "{t}"); + assert!(!t.contains("preset"), "{t}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A `.cube` that can't be imported says which file and why. +#[test] +fn a_broken_cube_says_why() { + let dir = temp_dir("bad"); + let bad = dir.join("Broken.cube"); + std::fs::write(&bad, "LUT_3D_SIZE 2\n0 0 0\n").unwrap(); + let mut h = app(); + let r = import(&mut h, &[&bad]); + assert_eq!(r["ok"], true, "{r}"); + let t = toast(&h); + assert!(t.contains("Broken"), "{t}"); + assert!(!t.contains("No new presets"), "{t}"); + let reason = r["result"]["profiles"]["failed"][0][1].as_str().unwrap_or_default().to_string(); + assert!(!reason.is_empty() && t.contains(&reason), "{t} / {r}"); + // with a good one alongside, both outcomes are in the one toast + let good = dir.join("Good.cube"); + std::fs::write(&good, identity_cube()).unwrap(); + import(&mut h, &[&good, &bad]); + let t = toast(&h); + assert!(t.contains("1 profile") && t.contains("Broken"), "{t}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f48a2e1c3..3b712e876 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -189,6 +189,44 @@ mod in_the_loupe { assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); } + // Given a mask selected with its overlay shown at 1:1 (issue #547), the zoomed view keeps its + // zoom and pan and stays a canvas-sized frame plus a native window carrying the overlay, as + // without it, instead of a whole-frame render at the zoomed size; hiding or showing the + // overlay swaps the window for one with the other look + #[test] + fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() { + let (mut h, native) = detail(); + for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + } + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.app.ui.pan = (0.3, 0.4); + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + let (zoom, pan) = (h.app.ui.zoom, h.app.ui.pan); + assert_eq!(zoom, crate::state::Zoom::Percent(100.0)); + let plain = h.app.region_view.expect("a window render without the overlay"); + + h.app.ui.mask_overlay = true; + h.settle(SETTLE); + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.develop_of(id).expect("settings"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let shown = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(shown.full.0.max(shown.full.1), native, "cut from the photo's own pixels"); + assert_eq!(shown.window, plain.window, "the same part of the photo"); + assert_ne!(shown.settings, plain.settings, "the overlay is part of the window's look"); + + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "hiding it keeps them too"); + assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { @@ -724,4 +762,131 @@ mod in_the_loupe { assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0); assert!(held(&h) > before / 3, "and they are still there"); } + + /// Compare view of the demo library at `zoom`, settled. + fn compare_at(zoom: crate::state::Zoom) -> Headless { + let (mut h, _) = detail(); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + h.app.ui.zoom = zoom; + h.settle(SETTLE); + h + } + + fn native_of(h: &Headless, id: lightcraft_catalog::PhotoId) -> usize { + let p = h.app.session.catalog.photo(id).expect("in the catalog"); + p.width.max(p.height) as usize + } + + // Given two photos in Compare, when I zoom to 1:1 to check focus, then each pane shows a window + // cut from its own photo's pixels, not the 2560 px whole-frame render magnified + #[test] + fn compare_at_one_to_one_gets_a_sharp_window_per_pane() { + let h = compare_at(crate::state::Zoom::Percent(100.0)); + let (sel, cand) = h.app.ui.compare.map(|(a, b)| (lightcraft_catalog::PhotoId(a), lightcraft_catalog::PhotoId(b))).expect("a pair"); + for (i, id) in [(0u8, sel), (1u8, cand)] { + let whole = h.app.renderer.textures.get(&Slot::Compare(i)).expect("the whole-frame render"); + assert!(whole.size[0].max(whole.size[1]) <= 2560, "pane {i}: the whole frame stays small"); + let w = h.app.compare_windows[i as usize].unwrap_or_else(|| panic!("pane {i} asked for a window")); + assert_eq!(w.photo, id); + assert_eq!(w.full.0.max(w.full.1), native_of(&h, id), "pane {i}: 1:1 is the photo's own pixels"); + let tex = h.app.renderer.textures.get(&Slot::CompareRegion(i)).unwrap_or_else(|| panic!("pane {i}: its texture")); + assert_eq!(tex.photo, id); + assert!(tex.size[0] >= 600 && tex.size[1] >= 600, "pane {i}: it covers the pane: {:?}", tex.size); + assert!(h.app.session.media.has_source(id, lightcraft_engine::SourceLevel::Full), "pane {i}: both originals stay decoded"); + } + } + + // Given Compare at fit, nothing more is rendered than before windows + #[test] + fn compare_at_fit_makes_no_window() { + let h = compare_at(crate::state::Zoom::Fit); + assert_eq!(h.app.compare_windows, [None, None]); + assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1))); + } + + // Given Compare at 1:1, when I zoom back to fit or go back to Detail, then the windows' textures + // are freed, and back in Detail only one original is kept decoded + #[test] + fn leaving_a_zoomed_compare_frees_its_windows() { + let mut h = compare_at(crate::state::Zoom::Percent(100.0)); + assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(1))); + h.app.ui.zoom = crate::state::Zoom::Fit; + h.settle(SETTLE); + assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)), "fit: no window"); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(0))); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.compare_windows, [None, None]); + assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1))); + assert!(h.app.session.media.usage().2.count <= 1, "one original in Detail"); + } + + // A pane's window is drawn where it belongs: its pixels agree with the pane's whole-frame render + // at the same place of the frame, and it is painted over that render, inside the pane + #[test] + fn a_compare_window_lines_up_with_its_pane() { + let (mut h, _) = detail(); + h.app.renderer.keep_pixels = true; + h.request("engine.execute", json!({"command": "view.compare"}), T); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + h.step(); + for i in 0..2u8 { + let view = h.app.compare_windows[i as usize].expect("a window"); + let tile = h.app.renderer.textures.get(&Slot::CompareRegion(i)).and_then(|t| t.pixels.clone()).expect("tile pixels"); + let main = h.app.renderer.textures.get(&Slot::Compare(i)).and_then(|t| t.pixels.clone()).expect("pane pixels"); + let (mut sum, mut n) = (0.0f32, 0.0f32); + for j in (8..tile.size[1] - 8).step_by(37) { + for x in (8..tile.size[0] - 8).step_by(37) { + let u = (view.window.x + x) as f32 / view.full.0 as f32; + let v = (view.window.y + j) as f32 / view.full.1 as f32; + let (mx, my) = + (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); + let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]); + sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; + n += 1.0; + } + } + assert!(sum / n < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", sum / n); + let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i))); + assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted"); + assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render"); + assert!(window[0].1.width() < h.size.x * 0.6, "pane {i}: clipped to its pane, {:?}", window[0].1); + } + // the two panes' clips don't overlap + let (a, b) = (drawn(&h, Slot::CompareRegion(0))[0].1, drawn(&h, Slot::CompareRegion(1))[0].1); + assert!(a.right() <= b.left() + 0.5, "{a:?} / {b:?}"); + } + + // Given Reference view at 1:1, the reference and the active photo both get windows; a slider + // drag on the active photo asks for no new window until it ends + #[test] + fn reference_view_at_one_to_one_gets_windows_and_a_drag_keeps_them() { + let (mut h, _) = detail(); + let r = h.request("engine.execute", json!({"command": "photo.setReference"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.key", json!({"key": "r", "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Reference); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + let before = h.app.compare_windows[1].expect("the active photo's window"); + assert!(h.app.compare_windows[0].is_some(), "the reference's window"); + h.app.session.begin_interaction("Exposure").unwrap(); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.8}}), T); + for _ in 0..5 { + h.step(); + } + assert_eq!(h.app.compare_windows[1], Some(before), "no window per drag tick"); + assert!(!h.app.renderer.is_pending(Slot::CompareRegion(1))); + h.app.session.end_interaction().unwrap(); + h.settle(SETTLE); + let after = h.app.compare_windows[1].expect("a window for the new look"); + assert_ne!(after.settings, before.settings); + } } diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index d7bffc0d8..8c5208e3d 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -41,3 +41,162 @@ fn opening_another_library_drops_the_old_librarys_cached_panels() { let _ = std::fs::remove_dir_all(&dir); } + +fn temp(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn app_in(dir: &std::path::Path) -> LightcraftApp { + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + LightcraftApp::new(session, Services::default()) +} + +fn recent_submenu(app: &LightcraftApp) -> Vec { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// Issue #902: File ▸ New Library… creates a named library and opens it; it shows in the window +/// title and in Open Recent Library, newest first. +#[test] +fn new_library_creates_opens_and_is_remembered_and_named() { + let dir = temp("newlib"); + let mut app = app_in(&dir); + app.library_changed(&egui::Context::default()); + assert_eq!(app.window_title(), "LightCraft — first"); + + let r = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap(); + assert_eq!(r["name"], "Client A"); + let a = dir.join("Client A"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + assert!(lightcraft_engine::library::is_library(&a), "the catalog is written"); + assert_eq!(app.window_title(), "LightCraft — Client A"); + assert_eq!(app.ui.settings.library_path, a.to_string_lossy()); + + // a second New Library at the same place is refused and the current one stays open + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap_err(); + assert!(e.contains("library here already"), "{e}"); + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "../x"})).unwrap_err(); + assert!(e.contains("library name"), "{e}"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + + let list = app.run("app.recentLibraries", json!({})).unwrap(); + let names: Vec<_> = list.as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["Client A", "first"]); + assert_eq!(list[0]["open"], true); + assert_eq!(list[1]["exists"], true); + + // Open Recent Library reopens "first" + let items = recent_submenu(&app); + let first = items + .iter() + .find_map(|n| match n { + crate::menubar::MenuNode::Item { id, params, enabled, .. } + if id == "app.openLibrary" && params["path"].as_str().is_some_and(|p| p.ends_with("first")) => + { + assert!(*enabled); + Some(params.clone()) + } + _ => None, + }) + .expect("first in Open Recent Library"); + app.run("app.openLibrary", first).unwrap(); + assert_eq!(app.window_title(), "LightCraft — first"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// From the menu, New Library… asks for a name (default prefilled), then where to put it. +#[test] +fn new_library_from_the_menu_asks_for_a_name_then_a_location() { + let dir = temp("newlibprompt"); + let mut app = app_in(&dir); + let loc = dir.to_string_lossy().into_owned(); + app.services.pick_folder = Some(Box::new(move || Some(loc.clone()))); + app.run("app.newLibrary", json!({})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, command, key, .. }) = app.ui.dialog.as_mut() else { + panic!("no name prompt: {:?}", app.ui.dialog) + }; + assert_eq!(value, lightcraft_engine::library::DEFAULT_NAME); + assert_eq!((command.as_str(), key.as_str()), ("app.newLibrary", "name")); + *value = "Wedding".into(); + // what the dialog's OK runs + app.ui.dialog = None; + app.run("app.newLibrary", json!({"name": "Wedding"})).unwrap(); + assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Wedding")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A remembered library that's gone is greyed out in the menu; opening it anyway is an error that +/// keeps the current library open and drops it from the list. The list is capped. +#[test] +fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { + let dir = temp("recentlib"); + let mut app = app_in(&dir); + let gone = dir.join("gone").to_string_lossy().into_owned(); + app.ui.settings.recent_libraries = vec![gone.clone()]; + let disabled = recent_submenu(&app) + .iter() + .any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str())); + assert!(disabled, "missing library greyed out"); + let e = app.run("app.openLibrary", json!({"path": gone})).unwrap_err(); + assert!(e.contains("no longer there"), "{e}"); + assert!(!std::path::Path::new(&gone).exists(), "no empty library created in its place"); + assert!(app.ui.settings.recent_libraries.is_empty()); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first")); + + for i in 0..20 { + crate::panels::settings::remember_library(&mut app.ui.settings.recent_libraries, &format!("/x/{i}")); + } + assert_eq!(app.ui.settings.recent_libraries.len(), crate::state::RECENT_LIBRARIES); + assert_eq!(app.ui.settings.recent_libraries[0], "/x/19"); + app.run("app.clearRecentLibraries", json!({})).unwrap(); + assert!(app.ui.settings.recent_libraries.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library another program has open is refused before the current one is closed: the current +/// library stays open with its photos, and the window title keeps its name. +#[test] +fn switching_to_a_locked_library_keeps_the_current_one_open() { + let dir = temp("lockedlib"); + let mut app = app_in(&dir); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Other"})).unwrap(); + app.run("app.openLibrary", json!({"path": dir.join("first").to_string_lossy()})).unwrap(); + // "another program" holds Other's lock + let other = dir.join("Other"); + let held = lightcraft_catalog::LibraryLock::acquire(&other, "someone else").unwrap(); + let e = app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap_err(); + assert!(e.contains("already open"), "{e}"); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first"), "the current library stays"); + assert!(app.session.library.as_ref().unwrap().on_disk); + assert!(app.ui.settings.library_path.ends_with("first")); + drop(held); + app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("Other")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Library names that can't be folder names everywhere (Windows' reserved characters, a +/// trailing dot) are refused; spaces around a name are trimmed. +#[test] +fn library_names_must_work_as_folder_names_on_every_system() { + let dir = temp("libnames"); + let mut app = app_in(&dir); + for bad in ["ab", "a:b", "a\"b", "a/b", "a\\b", "a|b", "a?b", "a*b", "name.", "..", " ", "a\u{7}"] { + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": bad})).unwrap_err(); + assert!(e.contains("library name"), "{bad:?}: {e}"); + } + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first")); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": " Trip 2026 "})).unwrap(); + assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Trip 2026")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index d9e5dfcf3..d22b3b939 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -53,6 +53,7 @@ pub struct TextField<'a> { hint: Option, width: Option, select_on_focus: bool, + begin_edit: bool, frame: bool, font: Option, color: Option, @@ -66,6 +67,12 @@ pub fn id(widget: &str) -> egui::Id { egui::Id::new("lc-text-field").with(widget) } +/// Whether the field had an unfinished edit on the immediately preceding egui pass. +pub(crate) fn was_editing(ctx: &egui::Context, widget: &str) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(id(widget).with("memo")).is_some_and(|memo| memo.before.is_some() && memo.pass.checked_add(1) == Some(pass))) +} + impl<'a> TextField<'a> { /// A one-line field (Return ends the edit) registered as `widget`. pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { @@ -76,6 +83,7 @@ impl<'a> TextField<'a> { hint: None, width: None, select_on_focus: false, + begin_edit: false, frame: true, font: None, color: None, @@ -139,6 +147,12 @@ impl<'a> TextField<'a> { self } + /// Initialize a newly revealed value editor before its first input is processed. + pub(crate) fn begin_edit(mut self, on: bool) -> Self { + self.begin_edit = on; + self + } + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { let id = id(self.widget); let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); @@ -149,6 +163,12 @@ impl<'a> TextField<'a> { memo = Memo::default(); } memo.pass = pass; + if self.begin_edit { + memo.before = Some(self.text.clone()); + memo.select_on_release = false; + ui.memory_mut(|m| m.request_focus(id)); + select_all(ui.ctx(), id, self.text); + } // a choice from the menu last frame: the field takes the focus back and does it, with // egui's own handling of the clipboard events (a password is never copied) if let Some(action) = memo.action.take() { @@ -205,6 +225,10 @@ impl<'a> TextField<'a> { // egui's own focus: `Response::has_focus` is also false while the window is in the // background (another app in front), which neither ends nor restarts an edit let focused = ui.memory(|m| m.has_focus(id)); + // The opening press may place a cursor; keep the original selected, never replacement text. + if self.begin_edit && focused && !response.changed() { + select_all(ui.ctx(), id, self.text); + } // an edit not under way (none, or one cut short when the field went away) is forgotten let in_menu = memo.away || memo.menu_open || memo.action.is_some(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 620871c36..a87a196ae 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,5 +1,9 @@ //! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the //! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code. +//! +//! Themes come in two families, picked by the Appearance Mode setting (Settings ▸ Interface): +//! - dark: **Charcoal** (the default, the observed dark look) and **Midnight** (near black); +//! - light: **Silver** (light grey chrome) and **Paper** (near white). use std::sync::Arc; @@ -75,6 +79,44 @@ pub struct Tokens { pub film_h: f32, } +/// An interface theme. Each belongs to the dark or the light family. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ThemeKind { + #[default] + Charcoal, + Midnight, + Silver, + Paper, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper]; + + pub fn is_dark(self) -> bool { + matches!(self, ThemeKind::Charcoal | ThemeKind::Midnight) + } + + /// The theme's id (settings value and command suffix). + pub fn id(self) -> &'static str { + match self { + ThemeKind::Charcoal => "charcoal", + ThemeKind::Midnight => "midnight", + ThemeKind::Silver => "silver", + ThemeKind::Paper => "paper", + } + } + + /// The English display name. + pub fn label(self) -> &'static str { + match self { + ThemeKind::Charcoal => "Charcoal", + ThemeKind::Midnight => "Midnight", + ThemeKind::Silver => "Silver", + ThemeKind::Paper => "Paper", + } + } +} + impl Default for Tokens { fn default() -> Self { Tokens { @@ -118,6 +160,92 @@ impl Default for Tokens { } impl Tokens { + /// The tokens of `kind`. Metrics are shared; only colours change. + pub fn for_kind(kind: ThemeKind) -> Tokens { + let rgb = |v: u32| Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + let base = Tokens::default(); + match kind { + ThemeKind::Charcoal => base, + ThemeKind::Midnight => Tokens { + chrome: rgb(0x1e1e1e), + canvas: rgb(0x121212), + grid_bg: rgb(0x080808), + cell: rgb(0x121212), + cell_selected: rgb(0x262626), + divider: rgb(0x0e0e0e), + inset: rgb(0x181818), + field: rgb(0x161616), + field_border: rgb(0x333333), + button: rgb(0x191919), + button_border: rgb(0x404040), + hover: rgb(0x2e2e2e), + pressed: rgb(0x343434), + tool_active: rgb(0x343434), + ..base + }, + ThemeKind::Silver => Tokens { + chrome: rgb(0xe6e6e6), + canvas: rgb(0xcfcfcf), + grid_bg: rgb(0xd9d9d9), + cell: rgb(0xcbcbcb), + cell_selected: rgb(0xb4b4b4), + divider: rgb(0xc4c4c4), + inset: rgb(0xdcdcdc), + field: rgb(0xf6f6f6), + field_border: rgb(0xb8b8b8), + button: rgb(0xf2f2f2), + button_border: rgb(0xb0b0b0), + hover: rgb(0xd2d2d2), + pressed: rgb(0xc4c4c4), + tool_active: rgb(0xcacaca), + text: rgb(0x1c1c1c), + text_label: rgb(0x383838), + text_dim: rgb(0x6a6a6a), + text_disabled: rgb(0xa2a2a2), + icon: rgb(0x555555), + track: rgb(0xa8a8a8), + thumb: rgb(0x5e5e5e), + thumb_hover: rgb(0x262626), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + ThemeKind::Paper => Tokens { + chrome: rgb(0xfafafa), + canvas: rgb(0xececec), + grid_bg: rgb(0xf2f2f2), + cell: rgb(0xe8e8e8), + cell_selected: rgb(0xd2d2d2), + divider: rgb(0xe0e0e0), + inset: rgb(0xf0f0f0), + field: rgb(0xffffff), + field_border: rgb(0xcccccc), + button: rgb(0xffffff), + button_border: rgb(0xc6c6c6), + hover: rgb(0xebebeb), + pressed: rgb(0xdedede), + tool_active: rgb(0xe2e2e2), + text: rgb(0x161616), + text_label: rgb(0x343434), + text_dim: rgb(0x707070), + text_disabled: rgb(0xababab), + icon: rgb(0x585858), + track: rgb(0xbdbdbd), + thumb: rgb(0x606060), + thumb_hover: rgb(0x202020), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + } + } + pub fn get(ctx: &egui::Context) -> Tokens { ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_default() } @@ -153,7 +281,7 @@ pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFo fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); // Craft-fonts faces in preference order for a family drawn in `style`. let fallback = |style: &str| { - lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style) + lightcraft_engine::fonts::ui_fallback(craft, crate::i18n::language().script(), style) .into_iter() .map(craft_font_name) .collect::>() @@ -215,15 +343,32 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +fn theme_id() -> egui::Id { + egui::Id::new("lightcraft-theme") +} + +/// The theme `ctx` is styled with (none before the first [`apply_kind`]). +pub fn current(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(theme_id())) +} + +/// Style `ctx` with the default theme. pub fn apply(ctx: &egui::Context) { - // Cmd+= / Cmd+- / Cmd+0 (Ctrl on Windows and Linux) are View ▸ Zoom In / Zoom Out / Zoom to Fit - // for the photo. egui would take the same keys at the end of every frame for its browser-style - // interface zoom (issue #566); on macOS the native menu bar got them first, elsewhere the whole - // UI scaled. + apply_kind(ctx, ThemeKind::default()); +} + +/// Style `ctx` with `kind`. The visuals go to both of egui's styles, so egui following the +/// system's dark/light setting on its own never swaps in its stock look. +pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { + // Keep photo zoom shortcuts from resizing egui itself. ctx.options_mut(|o| o.zoom_with_keyboard = false); - let t = Tokens::default(); - ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); - let mut v = Visuals::dark(); + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::NULL, t); + d.insert_temp(theme_id(), kind); + }); + let dark = kind.is_dark(); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome; v.window_fill = t.chrome; v.extreme_bg_color = t.field; @@ -234,8 +379,9 @@ pub fn apply(ctx: &egui::Context) { v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; - v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; + let (popup, window) = if dark { (140, 160) } else { (50, 60) }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(popup) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(window) }; let w = &mut v.widgets; craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to( w, @@ -244,8 +390,10 @@ pub fn apply(ctx: &egui::Context) { ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + ctx.set_visuals_of(egui::Theme::Dark, v.clone()); + ctx.set_visuals_of(egui::Theme::Light, v); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; @@ -262,6 +410,23 @@ mod tests { use super::*; use crate::i18n::{Locale, set_language}; + /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a + /// craft-fonts build that carries a Thai face, an English UI offers it in every family. + #[test] + fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { + let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { + eprintln!("no Thai face in craft-fonts: nothing to check"); + return; + }; + set_language(Locale::En); + let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); + let name = craft_font_name(thai); + assert!(definitions.font_data.contains_key(&name)); + for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { + assert!(definitions.families.get(&family).is_some_and(|names| names.contains(&name)), "{family:?} falls back on {name}"); + } + } + #[test] fn native_chinese_face_falls_back_after_default_faces() { static FACES: &[lightcraft_engine::CraftFont] = &[ diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4281c28ec..0872b2ec4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); let id = ui.id().with(spec.id); let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); + // A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust + // opt-in so hovering an unfocused slider still scrolls the containing panel. + if enabled && (resp.clicked() || resp.drag_started()) { + resp.request_focus(); + ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); + } // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); @@ -195,18 +201,31 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; let typing_id = id.with("typing"); - // the text being typed and how many frames the field has been up: it takes the keyboard on its - // first frames (not on the click's own, whose release would take the focus straight back) - let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id)); - if enabled && typing.is_none() && value_resp.clicked() { - typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0)); + // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. + let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Focus on the owned primary press before fast typing; retain the readout's click activation. + if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { + typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); + } + let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // The opening press must complete the readout's original click before the edit continues. + if value_released && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + typing = None; + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); + ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let mut out = SliderOut::default(); let span = (spec.max - spec.min).max(1e-9); let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width(); let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span; let mut v = value; - if let Some((mut text, frames)) = typing.take() { + if let Some((mut text, frames, pressed)) = typing.take() { + // Keep the owned opening release focused without restarting its draft or selection. + if pressed && value_released { + ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + } + let pressed = pressed && !value_released; // a child over the value, so the field never moves the rows around it let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); // the old value is selected when the field takes the focus, so typing replaces it @@ -216,11 +235,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ .width(value_rect.width()) .margin(egui::Margin::ZERO) .select_on_focus(true) + .begin_edit(frames == 0) .show(&mut child); - if frames < 2 { + if frames < 2 && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { field.response.request_focus(); - typing = Some((text.clone(), frames + 1)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1))); + typing = Some((text.clone(), frames + 1, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1, pressed))); } else if field.ending.is_some() || !field.editing || !enabled { // Esc (or the slider turning off) keeps the old value; Return or clicking away applies let cancelled = !enabled || field.cancelled(); @@ -230,10 +250,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out.drag_stopped = true; v = nv; } - ui.data_mut(|m| m.remove::<(String, u8)>(typing_id)); + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); } else { - typing = Some((text.clone(), frames)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames))); + typing = Some((text.clone(), frames, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); } } else if resp.double_clicked() || label_resp.double_clicked() { out.reset = true; @@ -272,8 +292,8 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ if resp.drag_stopped() { out.drag_stopped = true; } - // ↑ / ↓ while the pointer rests on the row nudge the value (⇧: five times as much) - if enabled && out.value.is_none() && typing.is_none() && ui.rect_contains_pointer(row) { + // Arrow keys work with either a hovered or a focused slider. + if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus()) { let (up, down, shift) = ui.input_mut(|i| { let shift = i.modifiers.shift; let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; @@ -289,6 +309,34 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } } + // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. + // Consume only the vertical component so a trackpad's horizontal scroll still works. + let wheel_id = id.with("wheel-rem"); + if !resp.has_focus() { + ui.data_mut(|m| m.remove::(wheel_id)); + } else if enabled && typing.is_none() && out.value.is_none() && ui.rect_contains_pointer(row) { + let delta = ui.input_mut(|i| { + let dy = i.smooth_scroll_delta.y; + if dy != 0.0 { + i.smooth_scroll_delta.y = 0.0; + } + dy + }); + if delta != 0.0 { + let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, delta); + ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); + if steps != 0.0 { + let nv = nudged(spec, value, steps); + if (nv - value).abs() > 1e-12 { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + v = nv; + } + } + } + } } // paint let hovered = resp.hovered() || resp.dragged(); @@ -320,6 +368,16 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out } +/// Convert vertical wheel movement to bounded slider steps, keeping sub-notch +/// trackpad movement between frames without amplifying a single large event. +fn wheel_nudges(remainder: &mut f32, delta: f32) -> f64 { + const POINTS_PER_STEP: f32 = 40.0; + let total = (*remainder + delta).clamp(-400.0, 400.0); + let steps = (total / POINTS_PER_STEP).trunc(); + *remainder = total - steps * POINTS_PER_STEP; + f64::from(steps) +} + /// `value` moved by `steps` keyboard nudges: one nudge is about 1/200 of the range, a whole /// number of the control's steps (exposure 0.05, most sliders 1, temperature 50 K). pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 { @@ -488,27 +546,36 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; - let hover_n = { - let mut h = None; - for i in 0..5u8 { - let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - register(ui.ctx(), format!("star:{id}:{}", i + 1), r); - if resp.hovered() { - h = Some(i + 1); - } - if resp.clicked() { - out = Some(if rating == i + 1 { 0 } else { i + 1 }); - } - ui.painter().text(r.center(), Align2::CENTER_CENTER, "", t.font(1.0), t.star); - let filled = i < rating; - paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); + let mut hover_n = None; + let mut rects = [Rect::NOTHING; 5]; + for (i, slot) in (0..5u8).zip(rects.iter_mut()) { + let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + register(ui.ctx(), format!("star:{id}:{}", i + 1), r); + if resp.hovered() { + hover_n = Some(i + 1); } - h - }; - let _ = hover_n; + if resp.clicked() { + out = Some(if rating == i + 1 { 0 } else { i + 1 }); + } + *slot = r; + } + // Hover previews the rating a click would set: stars 1..=hovered light up. The hovered star also gets the + // icon_button hover square, so hovering the current rating (where the preview looks the same) still shows feedback. + for (i, r) in (0..5u8).zip(rects) { + if hover_n == Some(i + 1) { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + let filled = star_lit(i, rating, hover_n); + paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); + } out } +/// Whether the star at zero-based index `i` is drawn filled: the hovered star's rating previews over the current one. +fn star_lit(i: u8, rating: u8, hover: Option) -> bool { + i < hover.unwrap_or(rating) +} + /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { let t = Tokens::get(ui.ctx()); @@ -527,6 +594,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co mod tests { use super::*; + /// Issue #647: hovering a star previews the rating a click would set. + #[test] + fn hovered_star_previews_its_rating() { + let lit = |rating, hover| (0..5u8).filter(|&i| star_lit(i, rating, hover)).count(); + assert_eq!(lit(2, None), 2, "no hover shows the rating"); + assert_eq!(lit(2, Some(4)), 4); + assert_eq!(lit(5, Some(1)), 1); + assert_eq!(lit(0, Some(3)), 3); + } + #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { @@ -546,6 +623,17 @@ mod tests { assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped"); } + #[test] + fn wheel_steps_accumulate_small_movements_and_preserve_direction() { + let mut remainder = 0.0; + assert_eq!(wheel_nudges(&mut remainder, 15.0), 0.0); + assert_eq!(wheel_nudges(&mut remainder, 25.0), 1.0); + assert_eq!(remainder, 0.0); + assert_eq!(wheel_nudges(&mut remainder, -100.0), -2.0); + assert_eq!(wheel_nudges(&mut remainder, -20.0), -1.0); + assert_eq!(remainder, 0.0); + } + /// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range. #[test] fn typed_values_are_stepped_and_clamped() { @@ -635,3 +723,18 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S } "…".to_string() } + +#[cfg(test)] +#[path = "widgets/tests_numeric_entry.rs"] +mod numeric_entry_tests; + +fn focused_slider_key() -> egui::Id { + egui::Id::new("lightcraft-focused-slider") +} + +/// Whether the widget with keyboard focus is a develop slider a click selected. It owns only the +/// arrow keys (and the wheel): every other shortcut still works, as in Lightroom after a drag. +pub fn slider_has_focus(ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused.is_some() && focused == ctx.data(|d| d.get_temp::(focused_slider_key())) +} diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs new file mode 100644 index 000000000..1935cde95 --- /dev/null +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -0,0 +1,77 @@ +//! Real egui pointer and keyboard frames on the production slider readout. +use super::{SliderOut, slider, take_registry}; +use crate::headless::HeadlessView; +use egui::{Event, Key, Modifiers, PointerButton, Rect, vec2}; +use lightcraft_develop::{ControlSpec, controls}; + +struct Harness { + view: HeadlessView, + spec: ControlSpec, + value: f64, + enabled: bool, + time: f64, + rect: Rect, +} +impl Harness { + fn new() -> Self { + let mut h = Self { + view: HeadlessView::new(), + spec: *controls::find("light.exposure").unwrap(), + value: 0.71, + enabled: true, + time: 0.0, + rect: Rect::NOTHING, + }; + for _ in 0..3 { + h.frame(vec![]); + } + h + } + fn frame(&mut self, events: Vec) -> SliderOut { + self.time += 0.02; + let mut out = SliderOut::default(); + self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| { + out = slider(ui, &self.spec, self.value, self.enabled, None); + }); + self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1; + if let Some(v) = out.value { + self.value = v; + } + out + } + fn key(&mut self, key: Key) -> SliderOut { + self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]) + } +} + +#[test] +fn opening_release_honors_return_and_escape_immediately() { + for key in [Key::Enter, Key::Escape] { + let mut h = Harness::new(); + let pos = h.rect.center(); + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]); + let out = h.frame(vec![ + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Text("2".into()), + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + ]); + assert_eq!(out.value, (key == Key::Enter).then_some(2.0)); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + assert!(h.key(Key::Enter).value.is_none(), "no retired draft can commit later"); + } +} + +#[test] +fn dragging_away_from_readout_does_not_capture_keyboard() { + let mut h = Harness::new(); + let inside = h.rect.center(); + let outside = egui::pos2(5.0, 80.0); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] { + let out = h.frame(events); + assert_eq!(out.value, None, "a cancelled readout gesture must not emit a value"); + assert_eq!(h.value, 0.71, "a cancelled readout gesture must preserve the stored value"); + } + assert!(!h.view.ctx.egui_wants_keyboard_input(), "a cancelled readout gesture must not retain keyboard focus"); +} diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md new file mode 100644 index 000000000..3b65c1d37 --- /dev/null +++ b/docs/arw6-compression.md @@ -0,0 +1,174 @@ +# Sony ARW6 (Compressed RAW 2) + +LightCraft decodes the raw sensor data of Sony's *Compressed* and *Compressed (HQ)* ARW files (TIFF Compression 32766, SonyRawFileType 6, "ARW 6.0") written by the A7R VI (ILCE-7RM6) in pure Rust. The shared RAW decoder serves import, desktop previews, editing, CLI rendering, web and export. Before this decoder these files failed the full decode and silently rendered from their embedded JPEG. The ILCE-7M5 (A7 V) writes the same codec; no sample of it was available, so it is unverified. + +Decode timings: see the end of this document (measured in the performance pass). + +## Format and implementation + +The codec is Sony's use of SMPTE RDD 34 (LLVC, Low Latency Video Codec). `crates/raw/src/llvc.rs` is the codec core (bit reader, variable-length decoder, dequantiser, inverse 5/3 wavelet); `crates/raw/src/vendor/arw6.rs` is the Sony container, geometry and colour reconstruction; `crates/raw/src/vendor/arw6_curve.rs` is the companding curve; `arw.rs` dispatches Compression 32766 to it. A test-only encoder (`arw6_testenc.rs`) builds synthetic tiles for round-trip tests. + +### Container + +- Raw SubIFD: Compression 32766, 14 bits per sample, BlackLevel 512 x4 (`0x7310`), WB `0x7313`, crop `0x74c7/0x74c8` (full frame: origin 12,8, size 9984x6656; APS-C: origin 16,10, size 6528x4352), CFA RGGB. The Sony tag-based white level (15360) is not read; the DNG `WhiteLevel` is (16383). +- The single strip starts with a tile table: `u32` count, `u32` 0, then per tile a `u64` offset (relative to the strip) and `u32` x, y, width, height. Seen: full frame 4 tiles of 5008x3336 (HQ) or 5024 / 4992 wide (Compressed); APS-C (6592x4372) one tile. Bytes up to offset 512 follow the table (480 on APS-C, 408 on full frame; not interpreted, not needed). +- A tile is a sequence of 16-byte words. Word 0 is RDD 34 `Picture_info` (HS = tile width, VS = tile height / 2, BBD = 16, NC = 3, NW = 3, NP = `1000000000b`; other values are refused as unsupported). Word 1 holds five `u24` group byte-sums, word 2 is zero, words 3-7 describe the groups (a count byte, then `u24` stream sizes in words). Groups: g0 = LL3 (3 streams, one per component), g1 / g2 / g3 = level-3 / 2 / 1 bands (3 streams each), g4 = green residual (1 stream). Streams follow in that order from word 8. +- A stream is a header word (a `u16` count of index words, a `u24` count of data words, then 11 bytes that are not interpreted), an index table and data, each padded to a word. The index has one entry per transform unit (TU): `A(16) X B(16) X`, where A and B are the byte lengths of the TU's two halves and X are quantiser-index (QI) nibbles (one for g0 and g4, three for g1-g3: HL, LH, HH). Halves per tile = ceil((VS - 2 - s) / 8) + 1 with s the first-half shift (below), TUs = ceil(halves / 2): 105 and 137 for the sample sizes. Halves are numbered n = 2 * TU + h; each is byte-aligned and consecutive halves are contiguous. +- QI values seen, identical in Compressed and HQ files: level 1 component 0 (1,1,2), components 1-2 (2,2,3); level 2 (0,0,1); level 3 (0,0,0); LL3 0; residual 2. Compressed and HQ differ only in the coefficient data. + +### Entropy coding and dequantisation + +- Each line of a band is an RDD 34 section 6.3 coefficient-set stream: ceil(width / 4) sets of four coefficients driven by the DPT / ABS / SGN state machine, with zero runs and the "0^lim" end code. Values past the width are discarded. Lines of a half follow one another bit-contiguously; the state resets per line. +- Two deviations from the RDD 34 text were established by black-box comparison: in state DPTs = 0 with DPT = 0, a "0" code is a zero set that also enters DPTs = 1; and a run code of value V means V - 1 further zero sets before the post-run "0^n 1" depth code. +- Dequantisation is `recon(q) = sign(q) * (((2|q| + 1) << (QI - 1)) - (|q| & 1))`, `recon(0) = 0`, and QI = 0 leaves q unchanged. This replaces RDD 34's plain shift. +- LL3 lines are DPCM: a running sum along the line. The 2048 base (codes are unsigned 12-bit) applies to component 0 (the green mean) only; the chroma planes start at 0. +- Check: every half's lines end inside its last byte. The Phase 0 measurement found budget - 8 < consumed <= budget on every half of all 8 files; the decoder rejects a half that does not. + +### Frame geometry + +- Each tile holds three W/2 x H/2 planes (W, H = tile size): component 0 = M, the green mean; components 1-2 = chroma c1, c2; plus a W/2 x H/2 residual plane from g4. A three-level 5/3 wavelet is inverted (vertical, then horizontal) with whole-sample symmetric extension at every edge and integer arithmetic. Tiles are independent. +- The TU grid and the lifting phases are anchored to the full sensor, not to the tile. Let s = (lines in g4's first half) - 2, read from the stream (residual lines are decoded while at least 8 bits of the half remain, at most 8): 0 on full frame, 3 on the APS-C crop (the crop's sensor-row offset 573 is 5 mod 8). Half n then covers plane rows 8n - 6 + s .. 8n + 1 + s, clipped to the frame. The lifting phases follow from s: full frame (0,1,1), APS-C (1,0,0). Each half holds, in clipped natural order, its rows of HL1, LH1, HH1, HL2, LH2, HH2, LL3, HL3, LH3, HH3 and, in g4, 8 residual rows; edge halves are shorter. With the right s every line of every file is used. + +### Colour reconstruction + +With plane row j, column x (x +- 1 clamped at the edges), in 12-bit code units: + +- `G1[j][x] = M[j][x] - ((res[j-1][x] + res[j-1][x+1] + res[j][x] + res[j][x+1] + 4) >> 3)`, with `res[-1] := res[0]`. +- `G2[j][x] = ((G1[j][x-1] + G1[j][x] + G1[j+1][x-1] + G1[j+1][x]) >> 2) + res[j][x]` (unclipped G1; the last row uses G1[j] for G1[j+1]). +- `R = 2*c1 + ((clamp(G1, 0, 4095) + clamp(G2, 0, 4095)) >> 1)`, `B = 2*c2 + (same)` (a negative green, as in deep high-ISO shadows, counts as 0). +- Every code is clipped to 0..4095. Per 2x2 cell: R (0,0), G1 (0,1), G2 (1,0), B (1,1). + +### Output range (companding curve) + +Codes map through one monotone curve, the same for every file and both modes. It is not stored in the file (below); it is our own measurement. Output values are in 2 x 14-bit DN: black 2 x 512 = 1024, the 14-bit white 16383 maps to 32766. + +- The curve is piecewise linear through knots (c_k, u_k), k = 0..306: u_k = 128 k (one knot per 64 DN of the 14-bit scale); c_k = u_k for k <= 11 (the identity through code 1408); c_k = 356.89 + 655.646 ln(k - 6) for k >= 12 (that is, c = -2824.32 + 655.646 ln(u - 768)). F(code) is the linear interpolation of u over c, rounded half up (floor(x + 0.5)). Values: F(1408) = 1408, F(1536) = 1542, F(2048) = 2457, F(3000) = 7979, F(3500) = 16227, F(3977) = 32767, F(4095) = 39077. +- `arw6_curve.rs` builds the 4096-entry table from the formula at first use; a unit test pins the whole table (its sum and position-weighted sum), so a platform whose `ln` rounds differently fails it. +- The decoder reports black 1024 and white = 2 x the file's DNG `WhiteLevel` (16383), i.e. 32766; a file without a usable white level gets 32766 too (2 x 16383), not a data-derived value. Codes above 3977 sit above white and clip as usual. + +#### How it was measured + +Each of the four scenes of the CC0 set (APS-C and full frame, landscape and portrait) has a lossless (LJ92) file and a Compressed and an HQ file, shot handheld one after another. Seven Compressed / HQ files share shutter, aperture and ISO with their lossless sibling. The eighth, APSC_land RAW-C (1/500 s against 1/1000 s), was left out: its brightness ratio to the lossless file is 1.37 rather than 2 and drifts with level, so the light changed between the shots. + +- Data: the ARW6 12-bit codes from LightCraft's decoder with the curve replaced by the identity, and the lossless 14-bit DN from LightCraft's LJ92 path. Both modes share the sensor origin (their crop tags are equal); the lossless frame is the ARW6 frame padded with zeros at the right and bottom. +- Registration: the handheld shifts are large (median 3 to 102 green cells). Patches were registered locally on the green planes with a curve-independent rank transform, normalised cross-correlation and a neighbour-consistency filter. +- Two estimators per pair and CFA channel, each capture with its own gain and offset (the gains' geometric mean fixed to 1, the offsets' mean to 0): + - quantile (CDF) matching over all overlapping pixels: for each code, the mean lossless value over the slice of the distribution the code occupies (precise, slightly smoothed by compression noise); + - block pairs: smooth 8 x 8-cell blocks (16 x 16 sensor pixels) away from clipping, a model linear in F, fitted robustly (unbiased). +- Fitting c = C + A ln(u - B) through the knots gives A 655.88, B 766.9, C -2826.5 on the blocks and A 653.47, B 770.1 on the quantile curves. Holding B at 768 changes the curve by at most 0.08 %; the formula above (A 655.646, C -2824.32) uses it. + +Uncertainty: 95 % intervals of a cluster bootstrap over the 7 captures (40 replicates), as a share of F - 1024 and in codes, and the largest deviation of any subset or method fit (Compressed only, HQ only, greens, red + blue, APS-C, full frame, quantile method): + +| Codes | Bootstrap 95 % | In codes | Largest subset / method deviation | +|---|---|---|---| +| 1408-1536 | -0.42..+0.35 % | -1.6..+2.0 | 0.75 % | +| 1536-2048 | -0.65..+0.58 % | -3.0..+2.8 | 0.80 % | +| 2048-2560 | -0.55..+0.57 % | -3.1..+3.3 | 0.78 % | +| 2560-3072 | -0.59..+0.50 % | -3.1..+3.7 | 0.64 % | +| 3072-3584 | -0.76..+0.54 % | -3.5..+4.9 | 0.77 % (quantile) | +| 3584-3840 | -0.84..+0.57 % | -3.7..+5.5 | 0.90 % (quantile) | +| 3840-3977 | -0.88..+0.59 % | -3.8..+5.8 | 0.97 % (quantile) | +| 3977-4095 | -0.91..+0.62 % | -4.0..+5.9 | 1.03 %, extrapolated | + +The lossless files clip at the 14-bit white, so codes above about 3977 rest on the formula alone. The camera's exact integer table can't be recovered: integer knot codes, the encoder's rounding and a few codes around the knee are below the measurement's resolution. + +#### Evidence for the structure + +- Knots every 128 u: the measured slope dF/dc is flat within segments and jumps at codes 1531, 1632, 1719, 1796, 1865, 1928, 1984, 2037, 2085…, where u = 1536, 1664, 1792…. On codes 1380-2200 a spacing of 128 leaves 0.021 RMS relative slope error; the next best, 126, 64 and a smooth log, leave 0.037, 0.041 and 0.068. Above code 2200 the segments are too short to resolve, but there the piecewise-linear and smooth forms differ by less than 1. +- Log spacing: the measured knot codes follow c = C + A ln(u - B) with chi-squared per degree of freedom 0.90 (bootstrap 95 %: A 654.3-658.2, B 762.8-771.1). +- The knee: the log branch at u = 1408 gives code 1413 (95 % 1410-1415), so the identity runs through knot 11 and the log knots start at k = 12 (segment slope 1.035 between them). Starting them at k = 11 fits as well but would need a slope below 1 right after the identity; a smooth log leaving the identity near code 1340 fits the blocks clearly worse (cost 25.4 against 22.9). +- One curve for everything: Compressed and HQ, all four CFA channels, APS-C and full frame agree within 0.8 %, and the ratio is flat with frame radius to +-0.5 % (no gain map). + +#### Scale + +The x2 unit is measured, not assumed: + +- Below the knee F(c) = c: the measured slope is 1.000 +- 0.01 for codes 1150-1408, with offsets within +-2. +- The overall level is fixed by the captures' average exposure to +-0.4 % (per-capture gains 0.99-1.02). Independently, flat clipped areas of the Compressed files sit at code 3977 +- 1 in all four scenes, and the formula, not fitted to that, puts the 14-bit white (32766) at code 3977.0 (bootstrap 95 % 3973-3983). +- HQ files carry data above the 14-bit clip: their clipped highlights sit at codes 3996-4095 depending on the scene (about 16.8k-19.5k DN), so the curve keeps its values above 32766. The reported white level is unaffected. + +#### Not stored in the file + +Every IFD of all 12 files was dumped and compared across modes: + +- Raw SubIFD: `0x7010` (SonyToneCurve) and `0x7011` are zero. The monotone arrays `0x7032`, `0x7035` and `0x7037` are lens corrections, the same in every mode. The only mode-dependent tag is `0x7000` SonyRawFileType (4 against 6). +- Maker note (`0x9050`, `0x94xx`, deciphered): the Compressed and HQ bytes differ from the lossless ones only in 1-2 bytes (the quality setting), and hold no curve-shaped monotone run. +- SR2SubIFD (encrypted, 42478 bytes): every file uses the same keystream, so XOR-ing two files compares their plaintexts. The lossless and HQ files of a scene differ in only 34 bytes, so there is no ARW6-only region. A curve stored identically in every mode can't be ruled out, but none is known there. `0x7250` likewise has no mode-specific region. +- The bytes after the tile table (480 on APS-C, 408 on full frame; the last 120 high-entropy) are identical in the Compressed and HQ files of a scene, even at different exposure, and differ between scenes: not a curve. + +#### Verification against the oracle + +After the derivation was frozen, the derived table was compared with the black-box oracle's output curve (below), as a check only, with no tuning. They are identical through code 1422 (both are the identity through 1408), then within -1.0 % to +0.25 % of (value - black); the largest difference is 75 (0.2 %) at code 4093. Ours minus the oracle's: -5..+1 for codes 1408-1536, -13..-2 for 1536-2048, -33..-12 for 2048-3072, -34..+40 for 3072-3977, +1..+75 for 3977-4095. + +## Verification + +Method: LibRaw master (commit `d1f0dd95`, 2026-10-08, run as `unprocessed_raw`, 16-bit output before any processing) was used only as a black-box oracle: it was built and run, and its source was never read. A Python reference model of the format (the Phase 0 reference model, local, not in the repo) was written from RDD 34 and from the oracle's behaviour on real and synthetic input, then reproduced the oracle exactly on every tile of the 8 Compressed / HQ files below. A test-only encoder, validated byte for byte against real tiles, probed the oracle with synthetic coefficients (dequantiser, phases, edge mapping). The Rust decoder reproduced the Phase 0 reference sums on all 8 files. Its output then went through a table read off the oracle's output; that table was strictly increasing, so these exact matches prove the 12-bit codes before the curve identical to the oracle's. The table has since been replaced by our own measured curve (*Output range*), so the output values now differ from the oracle's by the amounts in *Verification against the oracle*. + +Geometry was checked against each file's embedded full-resolution JPEG at the DefaultCropOrigin: our output aligns at offset 0 on full frame and APS-C. + +### LibRaw's APS-C bug + +LibRaw assumes the full-frame grid (s = 0) for every tile. On the APS-C crop that drops the first three plane rows, leaves the last three unfed, and shifts its whole output up by 6 sensor rows; its first rows and last rows are also wrong. Our decoder differs from LibRaw there on purpose. To compare against LibRaw on APS-C files, our row y+6 equals LibRaw's row y for 12 <= y < h - 24 (LibRaw's rows 0-11 and the last 18 rows of the shifted overlap differ); on full frame compare directly: the 12-bit codes match over the whole frame. + +### Corpus + +| File | Source path (RawDB, ILCE-7RM6) | Size (bytes) | SHA-256 | Fetched by | +|---|---|---|---|---| +| `arw6-sony-ilce7rm6-apsc-land-hq.arw` | `arw6/ILCE-7RM6_APSC_land_RAW-C-HD.ARW` | 29 720 576 | `b60a7e752d2a8102ccbe6eebcfdbf533c6b0f2545739090873e9e8eb32cc0637` | `cargo xtask corpus --download` | +| `arw6-sony-ilce7rm6-apsc-land-c.arw` | `arw6/ILCE-7RM6_APSC_land_RAW-C.ARW` | 30 900 224 | `1c803304e63ec508b2b5f9d59d4a7e30ed279315b35fa4c5d75f959bed62198b` | `curl -L` | +| `arw6-sony-ilce7rm6-apsc-port-hq.arw` | `arw6/ILCE-7RM6_APSC_port_RAW-C-HD.ARW` | 28 758 016 | `0b6d782571e86868670f613de9fa1f1364623b24fbfd21b704b2c516d7a1c1cb` | `curl -L` | +| `arw6-sony-ilce7rm6-apsc-port-c.arw` | `arw6/ILCE-7RM6_APSC_port_RAW-C.ARW` | 29 044 736 | `c41674a485ffcf6d873e20e545ae62936192d94e95be3d53548e93bc543ecfd4` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-land-hq.arw` | `arw6/ILCE-7RM6_FF_land_RAW-C-HD.ARW` | 63 451 136 | `559fa26501d7813e56afecde2aea3d9965a99228162b194752d986193abb172c` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-land-c.arw` | `arw6/ILCE-7RM6_FF_land_RAW-C.ARW` | 63 844 352 | `d900d6cbe838be864269ad54de4a33fcac7eb3a3d4f91009739f770b8f85a23b` | `cargo xtask corpus --download` | +| `arw6-sony-ilce7rm6-ff-port-hq.arw` | `arw6/ILCE-7RM6_FF_port_RAW-C-HD.ARW` | 62 640 128 | `81c709633cb8f6340e612c3f53bb6ec6b0cccfe5b713c67efb1e977ece9e266d` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-port-c.arw` | `arw6/ILCE-7RM6_FF_port_RAW-C.ARW` | 63 168 512 | `f4448485629c3f82ff1955fd046404350e9a1f09b346038574b89c074d7c50fd` | `curl -L` | +| `arw-sony-ilce7rm6-apsc-land-lossless.arw` | `raw_modes/ILCE-7RM6_APSC_land_RAW.ARW` | 44 167 168 | `7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc` | `curl -L` | +| `arw-sony-ilce7rm6-apsc-port-lossless.arw` | `raw_modes/ILCE-7RM6_APSC_port_RAW.ARW` | 43 692 032 | `0f011933ee96525a39710daa50b59d39702b4ec5934fa0206742e2128c55f366` | `curl -L` | +| `arw-sony-ilce7rm6-ff-land-lossless.arw` | `raw_modes/ILCE-7RM6_FF_land_RAW.ARW` | 100 851 712 | `5cb3c870bc961cf6b079d56c860d0cdf11acd1afd04e5fb8dbe041bcfd2d6201` | `curl -L` | +| `arw-sony-ilce7rm6-ff-port-lossless.arw` | `raw_modes/ILCE-7RM6_FF_port_RAW.ARW` | 100 884 480 | `b1a4026aa6adbaa27973b8b3426d8b4aa0c1cae1845cf5102359327d3b069691` | `curl -L` | + +These 12 files are CC0 1.0. RawDB's record of the set, (the data behind , accessed 2026-10-10), says `"license":"CC0 1.0"` for the set (uploaded by abbradar, 2026-08-29) and again for each of the 12 files, next to the SHA-256 values in the table. + +They live in the gitignored corpus. `cargo xtask corpus --download` fetches two of them, one per geometry and compression mode (full frame Compressed, APS-C HQ), and checks each against its SHA-256, pinned in `RAW_SAMPLES` (`xtask/src/main.rs`). Fetch the other ten by hand: the server answers 303 to a signed object URL, so `curl -L -o corpus/raw/ https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/`, then compare with the SHA-256 above. `cargo test -p lightcraft-raw corpus_sony -- --nocapture` checks the decoder's sensor sum and position-weighted sum of the two pinned files and skips absent files. The sums are regression values from LightCraft's decoder with the derived curve; the 12-bit codes behind them were verified against the oracle in Phase 0. + +Nine further full-frame HQ photos from the maintainer's A7R VI were compared with the oracle locally; they are private and never committed or published. On all 9 (ISO 100-12800, incl. two near-black night shots) the 12-bit codes matched the oracle over the full frame (0 mismatching pixels of 66.8 MP each). + +Unconditional synthetic tests (encode -> decode round trips for both geometries, the RDD 34 worked example, the dequantiser, hostile and truncated input) run without the corpus. + +## Coverage + +| Coverage | Status | +|---|---| +| ILCE-7RM6 Compressed and HQ, full frame, landscape and portrait | 4 files, 12-bit codes exact against the oracle; output through our measured curve | +| ILCE-7RM6 Compressed and HQ, APS-C crop, landscape and portrait | 4 files, 12-bit codes exact against the Phase 0 model (interior-exact against LibRaw after the 6-row shift; LibRaw's own edge rows differ) | +| ILCE-7RM6 lossless (existing LJ92 path) | 4 files decode; not in the corpus test | +| Private A7R VI HQ photos | 9 files, 12-bit codes exact over the full frame against the oracle (local) | + +## Not covered + +- The 12-bit Compressed mode (electronic shutter with continuous drive): no sample exists, so it returns `Unsupported` unless its headers prove identical. +- The ILCE-7M5: same codec, no sample; unverified. +- Tiles whose plane width is not a multiple of 8. +- First-half shifts other than 0 and 3 (other crops / bodies): the geometry is generic and round-trip tested, but no real sample exists. +- Memory: one FF decode peaks at about 896 MB RSS (about 6.7 x the 134 MB output; the lossless path peaks at 249 MB). Follow-ups: dequantise straight into bands, place tiles as they arrive. +- Colour: the ILCE-7RM6 bundled colour profile and Sony's embedded lens corrections are separate work. + +## Sources + +- SMPTE RDD 34:2015 "LLVC (Low Latency Video Codec) for Network Transfer", (accessed 2026-10-09): picture / TU structure, VLD, inverse quantisation, 5/3 lifting with symmetric extension. +- LibRaw master `d1f0dd95`, run only as a black-box oracle (not a product, test or build dependency) for the 12-bit codes; the companding curve was measured without it and compared with it only afterwards. +- The embedded JPEG of each file, for the geometry check. +- Not read, by licence: LibRaw, rawspeed, dnglab, RawTherapee and other ARW6 decoder sources. Exact byte layouts, deviations and geometry were established through sample analysis and black-box comparison; the curve was measured from the CC0 lossless / ARW6 pairs. + +## Timings + +Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Method: a small throwaway Rust program linking `lightcraft-raw` reads the file into memory, then times `lightcraft_raw::decode` alone with `std::time::Instant` (no render, no JPEG encode), three decodes in one process; the best is reported. Files were already in the page cache. + +| File | Size | Sensor | Decode | +|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 63.5 MB | 10016x6672 | 0.45 s | +| ILCE-7RM6 FF, Compressed | 63.8 MB | 10016x6672 | 0.51 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | +| ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | + +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components. diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index e933c64f7..b24d9bf75 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -16,15 +16,21 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged. -One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG. Both fits use the same fixed proxy at every decode resolution. +One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). +**A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before. + ## Source metadata and white balance Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders. -Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. Measured/absolute-Kelvin WB remains future camera-calibration work. +Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. + +**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative. + +What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. @@ -119,11 +125,14 @@ A raw starts from the first of these that is available (`camera_preview::startin 4. the model's spectral matrices, with LightCraft's default tone curve and no JPEG fit; 5. the neutral fallback. +When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). + The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the -gates or the file has no usable JPEG. White balance stays relative to the as-shot look for every raw without matrices -of its own, as before, so the catalog's `Photo::relative_wb` and the controls are unchanged. `lightcraft-cli -calibrate` still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. +gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever +look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot +look (`Photo::relative_wb`). `lightcraft-cli calibrate` still pools the uncorrected camera colour of covered +models, so a profile can be fitted for them. No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bee1fa1d9..cd4ea38a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ filmstrip is visible. | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | -| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | @@ -87,6 +87,14 @@ filmstrip is visible. | `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` | | `app.quit` | — | Close the app | +White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on +raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue +#730) — where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale +whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw +says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` +**without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean +Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use `develop.set` / `develop.wb`. + Image navigation is also available directly as the UI command `view.navigate`, with `{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional values greater than 0 and at most 800; pan is the normalized image centre, with coordinates from 0 to 1. @@ -95,6 +103,12 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, +Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: +"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. +Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, +`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the +XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing. Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or `{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in @@ -122,6 +136,18 @@ are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Co `button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and writes nothing. `app.openLibrary` opening a library ends it. +### Open Library on a Lightroom catalog + +`app.openLibrary` never makes a LightCraft library inside a Lightroom Classic catalog (issue #891): given the catalog +folder (it holds a `*.lrcat`), one of its `*.lrdata` / `*.lrcat-data` folders or `Backups/`, the `.lrcat` itself or +its `-wal` / `-shm` / `.lock` files, it keeps the library open now and returns `{opened: false, lightroomCatalog: +, path}`; the window shows a dialog whose `button:dialogOk` (Import Lightroom Catalog…, or +`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one) and +`button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always +opens as one. With no library open (the library problem window) it is an error instead. The engine refuses the same +places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom +{status: true}` reports the catalog's `path` after the task ends as well as while it runs. + ## Headless rendering (no window, no GPU) The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by diff --git a/docs/cr3.md b/docs/cr3.md index 7745d9301..147a83081 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -24,7 +24,7 @@ are the public prose descriptions and patents cited in embedded-JPEG fallback and its visible warning when a JPEG is present. So does any other CR3 decode error (a corrupt file, or a body whose CRX stream the decoder misreads): every CR3 opened from its embedded JPEG before this decoder, -and only three bodies are verified, so a CR3 never fails to import or load +and only four bodies are verified, so a CR3 never fails to import or load where a preview exists. HEVC-only preview tracks are recognized but are not decoded. @@ -55,6 +55,16 @@ control. The exact inputs and reference hashes are: | Canon R8 RAW, raw.pixls.us 6585 | `7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c` | `63c1c6d8d1eb2312` | | Canon R8 C-RAW, raw.pixls.us 6587 | `df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812` | `f836a663a795a375` | +The EOS R5 Mark II files (raw.pixls.us 7881–7884, CC0) are pinned to LightCraft's own decode; no external +decoder was run on them: + +| File | Input SHA-256 | Sensor FNV-1a (u16 little-endian) | +|---|---|---| +| Canon R5 Mark II RAW, raw.pixls.us 7881 | `d6e6aa3b493ea8936dff0efb0cc17c87471437e7317bce37c8c82ba08b9cab7b` | `ff716fb30248496f` | +| Canon R5 Mark II C-RAW, raw.pixls.us 7882 | `b3c08a5c7a97299dfd58896a12b939e7b43ec78682761812524825e6e5d087be` | `af32b45357b534e5` | +| Canon R5 Mark II crop-mode RAW, raw.pixls.us 7883 | `9f88c0ff012924193b7f4497559d133dfe626766d600b5d67689156721739ead` | `31cf630afe868958` | +| Canon R5 Mark II crop-mode C-RAW, raw.pixls.us 7884 | `6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7` | `4721605b20339332` | + Reference pixels were observed from the DNGLab 0.8.0 macOS arm64 release binary; no decoder source was read or copied, and it is not shipped or linked. The release ZIP SHA-256 was @@ -69,6 +79,34 @@ the nonlinear integer table, then multiplication, division by eight, addition of the subband base and a minimum step of one. QP values 131–167 are verified; unmeasured values are rejected. +The QP map is coded as follows (established on the EOS R5 Mark II). Its first symbol is a 32-bit +big-endian literal, mapped like every other symbol and added to a prediction of zero. Every later +symbol is Rice coded with a limited-length escape: a quotient of 23 or more is written as 23 zeros, +the terminating 1 and the symbol in 8 bits. This is the escape layout of ITU-T T.87 for a 32-bit +code-length limit and 8-bit symbols, without that standard's minus-one offset. In the R8 maps only +the first symbol needs more than 15 leading zeros, and its bits read the same whether taken as a +32-bit literal or as an escape after 16 zeros with a 16-bit literal (the rule LightCraft used before). +The R7 maps decoded to their exact ends under that rule, which a later long prefix would have broken; +no R7 file was at hand to recheck them. R5 Mark II maps (QP 150–162) also jump by up to 12 where the Rice parameter is 0. That +gives 18- to 22-zero prefixes, which the 16-zero rule misread as escapes ("Canon CRX adaptive QP value +−21087" and other out-of-range values; the file then opened as a preview). It also gives real escapes, +symbols 23 and 24. Escapes were seen only where the Rice parameter is 0 (in 100 of 612 maps; all 351 inspected, up to 10 per map); at a larger +parameter they would use the same layout, which is unobserved. With this coding the QP maps of all +33,665 R5 Mark II C-RAW files in a private library (version `0x200`, 8480 × 5650) decode to the exact +end of their entropy streams, with values 150–162. So do the reduced-size tracks of 306 of those +files, the two CC0 R5 Mark II C-RAW files above and the R8 C-RAW fixture; all six fixture hashes are +unchanged. Under the 16-zero rule, 174 of those 306 files (57%) failed and opened as previews, all +with out-of-range values (none at QP 168). The two CC0 C-RAW files are low-ISO shots whose maps need no +long prefix: they decoded under the old rule too, so the escape is covered by synthetic unit tests in +`crx.rs` rather than by a public file. Render cache version 43 redraws thumbnails of files whose decode +changed; photos imported as preview-only switch over with Photo ▸ Reload from Disk. + +The R5 Mark II sensor has 5650 rows (3574 in crop mode), so its Bayer planes have odd heights (2825, +1787). Lossless planes decode as before. In C-RAW every wavelet level has an odd height (2825 → 1413 → +707), and each subband's entropy stream ends exactly where the subband geometry says. The synthesis +uses the same boundary rules that are verified on the R8's odd lengths. Decoded rows show no step +down to the last sensor row, and the developed images match the camera's embedded JPEGs at 100 %. + An odd QP-map height (EOS R7: 4732 sensor rows give 1183 QP rows) leaves the last row of the second-level high-frequency bands without a partner row; that row's QP is used on its own. The odd-height map decodes to the exact end of its diff --git a/docs/denoise.md b/docs/denoise.md index be208a9b8..926b1bee7 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -121,6 +121,51 @@ LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release - LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture ``` +## Another computer's graphics card + +A laptop can hand tiles to a desktop with a bigger graphics card over a LAN or a VPN. The server runs the installed, +selected model the way the app does (the card when it can run it, checked against the CPU first; else the CPU): + +```text +export LIGHTCRAFT_DENOISE_TOKEN='a long random string, at least 16 characters' +lightcraft-cli denoise-serve --listen 100.101.102.103:7990 # e.g. the machine's Tailscale address +``` + +On the laptop, before LightCraft starts: + +```text +export LIGHTCRAFT_DENOISE_URL=100.101.102.103:7990 +export LIGHTCRAFT_DENOISE_TOKEN='…the same token…' +``` + +When the laptop first uses the model it greets the server and times a check tile there and back. If that is faster +than a tile on the laptop (its graphics card, or its processor with the tiles it runs at once), the server's slots +(four tiles in flight) are added to the laptop's own: a tile goes to the server when one of its slots is free and runs +locally otherwise. If it is slower, nothing is sent, so a photo never waits for the slower machine: +`denoise.status` → `device.remote.state` is `slower`, with both times (`remoteMs`, `localMs`). Measured with +RawNIND: an Apple M3 laptop does a tile in 116 ms, an RTX 3060 Ti reached over Tailscale 345 ms there and back, +so that laptop keeps its tiles; one without a fast card hands them over. A tile the server fails runs locally; after +three failures the server is no longer used for that model. Both ends must +run the same model file: the client compares the manifest's SHA-256 (or the file's) when it first greets the server, +and the server checks it again for every tile. `denoise.status` → `device.remote` reports the server's address, its +device and whether it is used, or why not. + +**The protocol is not encrypted.** Listen on a VPN address (Tailscale, WireGuard) or a LAN, or bind every interface +behind a firewall rule that admits only the VPN; never expose the port to the internet. Requests carry the shared +token (compared in constant time); the server refuses other models, other tile sizes, oversized headers and payloads +(also ones that inflate past their tile) and caps concurrent connections (`--max-connections`, default 8). + +Protocol version 1 (`crates/denoise/src/remote.rs`): one JSON line per message, then a payload of `bytes` bytes — +the tile's samples as little-endian half floats, byte-shuffled (low bytes, then high bytes) and deflated: + +```text +→ {"v":1,"token":"…","op":"hello"} +← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"},"tile":T} +→ {"v":1,"token":"…","op":"run","model":"","tile":T,"bytes":N} + N bytes (4 planes of T × T cells) +← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T pixels) +← {"ok":false,"error":"…"} +``` + ## Commands and limits UI, CLI, control channel and MCP dispatch `denoise.toggle`, `denoise.settings`, diff --git a/docs/display-profiles.md b/docs/display-profiles.md index 63c557108..9d6b11af7 100644 --- a/docs/display-profiles.md +++ b/docs/display-profiles.md @@ -3,15 +3,22 @@ LightCraft can show photos through the monitor's ICC profile, so colours are right on wide-gamut and calibrated displays. Without one, previews are sRGB, which a wide-gamut panel shows oversaturated. +**macOS uses the screen's own profile** (the one System Settings ▸ Displays assigns) while none is chosen. macOS doesn't +colour-manage LightCraft's window (wgpu leaves its Metal layer without a colour space, which Core Animation shows +unmatched), so on a P3 screen previews used to look more saturated than the exported files in Preview or Photos (issue +#787). The profile is read again about once a second, so moving the window to another screen is followed. + ## Using it Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB** -goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) and applies at once; a file that -can't be used is reported there and previews stay sRGB. +(on macOS **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) +and applies at once; a file that can't be used is reported there and previews stay sRGB. Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report -only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`. +only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`; the screen's +profile (macOS, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` +(`apps/lightcraft/src/screen_profile.rs`); headless sessions have none and stay sRGB. ## How it works @@ -40,5 +47,5 @@ LUT-based profile's correction about 65 ms. Drafts during slider drags skip the ## Not yet -- Picking up the profile the operating system assigns (X11 `_ICC_PROFILE`, colord, ColorSync, WCS) and following - the window across monitors with different profiles. +- Picking up the profile the operating system assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS) and + following the window across monitors there (macOS does both, above). diff --git a/docs/faces.md b/docs/faces.md index 996c82ca5..5c8e54317 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -200,7 +200,8 @@ photos in the set. ## Suggesting who is in a photo -With **Recognise faces** switched on in Settings ▸ Faces and a recognition model chosen (**Use**), LightCraft works out, in +With **Recognise faces** switched on in Settings ▸ Faces and a recognition model chosen (**Use**; the switch cannot be +turned on before one is, and turns itself off when the last recogniser is removed), LightCraft works out, in the background, what each face in your library looks like to the model, and uses the faces you have already named to suggest names for the ones you have not: @@ -296,3 +297,12 @@ suggestions it would make and how many were right. All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`, `faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`, `faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. + +## Find Blemishes + +Photo ▸ Find Blemishes (`spot.findBlemishes`) uses the installed detector's boxes and landmarks to look for small, +round, darker-or-redder spots on each face's skin and adds a heal spot on each, the way Find Dust Spots does for +sensor dust. Eyes, brows, nostrils and the mouth are cut out around the landmarks; faces narrower than 160 px in +the 1600 px analysis image get nothing, as do faces with more than 12 candidates (stubble, freckles and pores are +texture, not blemishes); at most six per face. It needs no model beyond the detector and never changes anything +without a spot you can see and delete (`crates/pipeline/src/blemish.rs`). diff --git a/docs/fuji-lens-corrections.md b/docs/fuji-lens-corrections.md new file mode 100644 index 000000000..ca25e53a2 --- /dev/null +++ b/docs/fuji-lens-corrections.md @@ -0,0 +1,128 @@ +# Fujifilm embedded lens corrections + +LightCraft reads the distortion and vignetting tables that Fujifilm cameras store in the raw IFD of every RAF +(`0xf00b` GeometricDistortionParams, `0xf010` VignettingParams, signed rationals) and turns them into the existing +`OpcodeList3` pair, `WarpRectilinear` and `FixVignetteRadial`. The profile-correction control, the CPU / GPU optics +path, EXIF orientation handling and DNG export use them like any other embedded correction. + +Corrected: **distortion** (with the camera's frame-filling zoom) and **vignetting**, for the 59 bodies listed in +`crates/raw/src/vendor/raf_lens.rs` (`VALIDATED`). **Not corrected: lateral chromatic aberration** (the third table, +`0xf00f`); see "Chromatic aberration" below. Bodies without a checked sample, and tables in a layout or normalisation +that was not understood, stay uncorrected. + +## Clean-room evidence + +The tag names come from the [ExifTool FujiFilm tag-name documentation](https://exiftool.org/TagNames/FujiFilm.html); +ExifTool was only run as a black box (`exiftool -j -n`, `-v3`) to print the arrays. Everything else was measured: +the 129 CC0 RAFs with these tables under `raw.pixls.us` (Fujifilm, 65 bodies) against the camera's own embedded JPEG, which the +cameras correct in-camera, and against LightCraft's uncorrected raw render. No other decoder's code or data, +lens database or Adobe profile was consulted. Scripts and per-file results: `data/testing/raf-lens/` (local). + +## Layout and units + +Every table is an array of signed rationals with `n` knots: + +``` +[scale, knot_1 … knot_n, value_1 … value_n] +``` + +- `scale × n` is the half diagonal of the recorded image (the RAF crop) in pixels: 3749 for 6240×4160, 4643 for + 7728×5152, 2942 for 4896×3264, 7280 for 11648×8736, on 61 of the 65 bodies. Files that store another number (X-S20, + X-T30 III and X-M5 write the 40 MP value, 4643; an X-T5 in an in-camera crop) are left uncorrected. +- Knots are positions along the radius. The **last knot is the farthest corner**: `ρ = knot_last · r`, `r` the + distance from the centre in half diagonals. Older bodies use 11 knots `0, 0.1 … 1`, newer ones 9 knots evenly + spaced in `r²` (`√(k/8)`, last `√(9/8) = 1.0607`), GFX 50 bodies 8 (`√(k/8)`, last 1). The distortion zero and the 100 % + illumination at the axis are anchored at `ρ = 0` when the first knot lies above it. +- Distortion values are percent: the bracket `1 + D/100` multiplies the radius. +- Vignetting values are percent of the axial illumination at the (source) radius. +- The chromatic-aberration table has the layout `[scale, knots, red × n, blue × n, scale]` (fractions of the radius, + the displacement of the red and blue planes relative to green). + +## Geometry + +The camera's JPEG samples the raw at + +``` +source = z · r · (1 + D(ρ(source)) / 100) ρ = knot_last · source +z = 1 / max over the output border radii of (1 + D(ρ(source)) / 100) +``` + +The table is indexed by the **source** (sensor) radius, not the output radius, and the zoom `z` keeps the output +rectangle inside the source (as for the Sony table, the bracket is largest at the nearest border radius). Source +radius is solved per radius by fixed-point iteration, then `source(r) = r (k0 + k1 r² + k2 r⁴ + k3 r⁶)` is fitted +(minimax, 1025 samples) for the DNG warp; a fit that strays more than 0.0008 half diagonals (largest over the 118 sample +tables 0.0007, median 0.00012) or folds is rejected. The correction itself is the warp's bracket; the camera's rounding +and interpolation between knots are not reproduced beyond linear interpolation between knots. + +Vignetting is the gain `100 / V(ρ(r))` at the source radius, fitted by a 5-term minimax polynomial in `r²` +(`FixVignetteRadial`); a fit more than 0.025 off the table is rejected. + +## Validation + +Method: LightCraft's uncorrected render of each RAF (1200 px long edge) against the camera JPEG (`camera-jpeg/`, +resized to the same size). Per file the high-passed luminance is correlated (NCC) after a warp with a free global scale and +shift; the warp is (a) similarity only, (b) the table with its sign flipped, (c) the table, lookup at the output +radius, (d) the table, lookup at the source radius with the computed zoom (adopted), (e) a free three-term radial +polynomial (the upper bound). 67 body/lens/focal-length combinations from 65 bodies, 26 named lenses and the fixed-lens cameras (129 files). + +| Warp | median NCC | mean NCC | +|---|---:|---:| +| similarity only (nothing corrected) | 0.941 | 0.865 | +| table, opposite sign | 0.493 | 0.530 | +| table, lookup at the output radius | 0.989 | 0.869 | +| **table, lookup at the source radius, computed zoom (adopted)** | **0.991** | 0.872 | +| free radial polynomial | 0.994 | 0.985 | + +The sign is determined (the 48 combinations with more than 1 % distortion reach a median of 0.29, at most 0.91, with the wrong sign), the +source-radius lookup is slightly better where distortion is large (GFX100RF 0.985 vs 0.971, X-S20 0.989 vs 0.971, X-S1 +0.977 vs 0.961). The table reaches the free fit's correlation within 0.02 for 51 of 67 combinations including the +computed zoom, and for 60 of 67 when the zoom is free (shape right). The zoom `z` agrees with the free fit's scale +within 0.0047 on all 51. + +**Where the framing differs:** on five corrected combinations the camera did not zoom (XC16–50 at 16 mm on the X-A10, +XC15–45 on the X-A5 and X-T100, the F770EXR and HS50EXR) and on two more it zoomed about 1 % differently (X-T30 III, XQ2): +the shape matches but the JPEG shows 4 % more of the sensor than the computed zoom does. The same body and table +zoom on other lenses (the X-A1 and X-A2 with the XC16–50), so the rule is not in the table. LightCraft uses the computed zoom +everywhere (no empty border, as for the Sony table). Six bodies whose shape did not match well enough (F550EXR, S1, +SL1000, X10, XF1, XQ1) are not corrected. + +**Through the shipped code path** (`lightcraft-cli render` of the same 67 combinations with the embedded +correction applied, then the free fit of the corrected render against the camera JPEG, global scale removed so +the framing difference does not count): the residual radial shape error falls from a median of +0.72 % of the half diagonal (largest 2.60 %) to a median of 0.05 % (largest 0.42 %), 54 of the 57 corrected combinations land within 0.25 % +(2 px at 1440 px), and the similarity-only correlation rises from a median of 0.957 to 0.993. Per-lens rows are in +`data/testing/raf-lens/out/after_table.md`. + +### Vignetting + +Brightness of the camera JPEG against the aligned uncorrected render, in linear light, modelled per file as +`log Y_jpeg = poly3(log Y_render) + β(radius bin)` (the polynomial absorbs the tone curve): the JPEG's gain β against +`-ln(V/100)` of the table over 42 combinations whose corner gain exceeds 5 %: correlation median 0.987 (37 of 42 above +0.9), proportionality factor 0.98 (interquartile 0.73–1.31), against the tone curve's own slope of about 1.05; flat +tables (X-M5, X-T2, X-T3) give a gain of zero in the JPEG. Through the shipped code path the mean |β| over the outer three radius bins of the corrected render falls from a median of +0.184 to 0.031 (log units; largest 0.81 to 0.15) on the 38 corrected combinations with a corner gain above 5 %, and the corner +β at least halves on 30 of them. Seven combinations show only a fraction of the amplitude +(S1, SL1000, X-A2, X-A3, X-S1, X-T1, X-Pro3); the camera setting that governs them is not recorded. + +### Chromatic aberration (not applied) + +The CA table is real: on the raw mosaics themselves (uncompressed RAFs, colour planes built by normalised convolution so +no demosaic is involved) the radial shift of the red and blue sites against green correlates with the table over 46 +curves from 30 files at a median 0.93 (31 above 0.8), with the table's sign, at about 0.7 of its amplitude (the same +estimator reads 1.0–1.15 on a synthetic mosaic with the table applied). The camera JPEGs show no measurable red/blue +shift, but 4:2:0 chroma shares the luminance edges, so that proves nothing. + +LightCraft's own demosaic, however, already registers the colour planes: its render shows red/blue shifts of only +0.05–0.2 px where the table has 1.5–5 px. Applying the table as per-channel warp planes (tried; the opcode supports +them) shifted the planes the wrong way: the median measured shift rose from 0.10 to 0.13 px and grew by more than 1.5× +on 11 of 28 curves (X-T4 blue 0.16 → 0.58 px, X-T3 red 0.06 → 0.59 px). The table is therefore not applied; a correction +before demosaicing would be needed. + +## Tests + +- `crates/raw/src/vendor/raf_lens.rs`: layout, normalisation, the model list, malformed tables, the zoom and the + vignette gain on the X-T4's real arrays, and the X-T4 geometry against its measured free fit. +- `crates/raw/src/vendor/raf.rs`: a synthetic RAF through header and full decode, only for validated models, and a DNG + round trip. +- `crates/raw/tests/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files, + or any folder of RAFs in `LIGHTCRAFT_FUJI` (the pixls set: 129 decoded, 88 warps, 98 vignette gains). diff --git a/docs/gaps.md b/docs/gaps.md index 64b1cb8ac..152a2dc67 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; absorbs the checklist's Top gaps and the ROADMAP's honest assessment, re-ranked after a full re-measure) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 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,20 +15,20 @@ 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, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix | Δ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 beyond three verified bodies** (LR-IMP-FORMATS): CRX variants other than those verified on M50 / R100 / R8 fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 72%; 17 of 22 popular Canon bodies write CR3, 2 verified (raw-parity.md) | +6.5 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577) | Δ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 beyond four verified bodies** (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; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [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 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): the whole-image preview is capped (2,560 px default); compare, overlays and soft proofing lack sharp zoom windows (#323) | checklist row 🟡 | checking focus, the first thing culling needs, isn't reliable on large files | 8–15 | [ui-parity](ui-parity.md) | +| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | -| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md) | +| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | -| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64) | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 234 of 1,446 Adobe models verified (16%), ~40% of photographers on a verified body; Nikon 5 bodies verified, Canon 11. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 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 | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | -| 14 | **HDR display** (EDR / Windows HDR); HLG; HDR settings from `crs` XMP | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [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) | @@ -41,7 +41,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: JXL encode, PSD export, lossy DNG, Render to DNG; AVIF import | file-format-parity.md | interchange edge cases | 25–45 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | -| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only | ROADMAP, issues | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | +| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -61,7 +61,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345). -- 2026-10-09: HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), +- 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), Ukrainian UI (#564), Sony ILCE-7CR profile. - 2026-10-08: CR3 CRX lossless and C-RAW (#279), Fujifilm compressed RAF (#282), AI RAW denoise (#384), Lightroom catalog import (#218), German, Spanish, Russian UI. @@ -72,6 +72,12 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | +| 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | +| 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | +| 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | +| 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | +| 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 8d7472352..883c3a0bf 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -73,18 +73,32 @@ hosts, tests) waits for its running jobs the same way (`JobPool::shutdown`, `cra **Crash sentinel.** The desktop app writes `gpu-init.marker` into its settings folder (next to `ui.json`: `%APPDATA%\LightCraft`, `~/Library/Application Support/LightCraft`, -`~/.config/lightcraft`) just before the compute device is created and removes it as soon as creation -returns, successfully or not. If the marker is still there at the next launch, the process died inside +`~/.config/lightcraft`) just before the compute device is created and its kernels are compiled (and, on the first +AI denoise, before that network's kernels are) and removes it as soon as that returns, successfully or +not (issue #250: Apple's Metal compiler, `MTLCompilerService`, aborting on a kernel on an older Intel +Mac). A kernel the driver refuses without crashing is caught by wgpu error scopes: the log says why +(`gpu: rendering on the CPU: …`) and renders stay on the CPU. `LIGHTCRAFT_GPU=0` (or Settings ▸ +uncheck *Use the GPU for rendering*) forces the CPU path from the start. If the marker is still there at the next launch, the process died inside the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the marker and says so in a notice. Checking *Use the GPU for rendering* again tries the GPU once more (and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts), and not in a `--memory` session, which writes nothing: it neither arms the sentinel nor removes a marker it finds (it still starts with GPU rendering off when one is there, and the next ordinary launch reports and clears it). Killing the app during the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely — -harmless: rendering is then on the CPU until the box is checked again. The sentinel only covers the -compute device; a crash while the window's renderer starts is avoided by the backend defaults above +harmless: rendering is then on the CPU until the box is checked again. The sentinel covers the compute +device and every kernel build; a crash while the window's renderer starts is avoided by the backend defaults above or worked around with `LIGHTCRAFT_GPU_BACKEND`. +**The window's renderer and its fallback (Windows).** Before the window opens, each renderer is probed in +order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's +pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is +incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app. The renderer +that worked is remembered in the settings folder (`window-renderer`); a start that dies before the window is up +leaves `window-init.marker`, and the next start (relaunched automatically, at most 4 times) tries that +renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACKEND`) forces one; DX12 stays +first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own +device and falls back to the CPU as before. + **Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver @@ -190,10 +204,19 @@ redone on the CPU and why, e.g. `"6000×4000: the GPU returned an incomplete ima 24000000 pixels unwritten); the GPU is not used again"`). The same in `app.gpu` (`reason`, `lastFallback`), Help ▸ System Info and Settings ▸ Performance. `LIGHTCRAFT_PROFILE=1` prints the fallback with the stage timings; `RUST_LOG=warn` logs it. +`perf.gpuFallback` is one value for the whole process, overwritten by the latest render on any +thread. A render's *own* reason comes from `lightcraft_gpu::try_render` (`Err(Fallback::Failed(why))`; +`Fallback::NotTried` when the GPU was not used at all), which `lightcraft_engine::media::develop_reporting` +passes on as `RenderResult::gpu_fallback`; an export carries it as `Exported::gpu_fallback`, and +each file of an `app.export` batch result has `gpuFallback` when its render fell back (issue #675). **Reproducing a smaller GPU:** `LIGHTCRAFT_GPU_LIMITS=webgpu` (or `downlevel`) creates the device with 128 MiB storage bindings / 256 MiB buffers, `LIGHTCRAFT_GPU_LIMITS=` with n MiB / 2n MiB. Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/fallback.rs`, -`export_falls_back_to_the_cpu_when_gpu_work_is_lost`). +`export_falls_back_to_the_cpu_when_gpu_work_is_lost`). The fault is thread-local, and so are its +consequences: a simulated fatal failure stops GPU use on the injecting thread only (`available()` +false, `unavailable_reason()` set there), until `reset_failures()`, which undoes nothing else — a +real failure still stops the whole process. Such a test asserts on its own render's reason, not on +`last_fallback()`, so it can run beside other tests rendering on the GPU (issue #675). ## Correctness: CPU oracle and equivalence tests `crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB: @@ -256,7 +279,9 @@ A view's stages keep an uploaded source only up to 96 MB (a Preview-level source `/usr/bin/time -l lightcraft-cli snapshot --script steps.jsonl -o out.png` → "maximum resident set size" and "peak memory footprint". Run it several times: the high-water mark is noisy (allocator caching, scheduling). On Apple silicon GPU buffers count in the footprint. -- Imports read raw headers only (`lightcraft_raw::probe_info`): no pixel data is decompressed. +- Imports read raw headers only (`lightcraft_raw::probe_info`): no pixel data is decompressed, + except the first rows a few vendor formats need for facts their headers lack (Nikon NEF: the + first 128 rows for the masked trailing columns, issue #708). - One budget (`memory::budget`, default min(25 % of RAM, 1.5 GiB); `LIGHTCRAFT_MEMORY_MB` or `app.memoryBudget {mb}`): half for the engine caches (decoded thumbnail / preview / full sources and rendered previews, evicted least recently used *across* them; the photo on screen diff --git a/docs/lens-profiles.md b/docs/lens-profiles.md new file mode 100644 index 000000000..3503699ee --- /dev/null +++ b/docs/lens-profiles.md @@ -0,0 +1,45 @@ +# Lens profiles from a lens database + +Raws that carry no lens correction of their own (DNG `OpcodeList3`, or a camera correction a raw reader converts, +see [sony-lens-corrections.md](sony-lens-corrections.md)) can be corrected from a **lens database in the lensfun +XML format** that you supply. The app does not ship any lens database: lensfun's data is licensed CC-BY-SA 3.0 and +stays a separate download. Adobe LCP profiles are never used. + +## Setup + +1. Get the lensfun database (the `data/db/*.xml` files of , or the copy a Linux + distribution installs under `/usr/share/lensfun/version_1/`). +2. Point `LIGHTCRAFT_LENSFUN_DIR` at that directory and start the app (or the CLI). + +Files are read once per process. Invalid files are skipped with a warning in the log. Without the variable nothing +changes. + +## What is applied + +The lens is identified from EXIF: camera make / model (for the mount and crop factor; the 35 mm equivalent focal +length is used when the camera isn't listed) and the lens model string. Lens names match on their words; every +number (focal lengths, apertures) in the database name must appear in the EXIF name, and the lens maker must agree +when the file names one. + +| Correction | Models | Interpolated over | +|---|---|---| +| Distortion | `poly3`, `poly5`, `ptlens` | focal length | +| Lateral CA (TCA) | `linear`, `poly3` | focal length | +| Vignetting | `pa` | focal length, aperture (in stops), focus distance (nearest calibrated, by 1/d; infinity when unknown) | + +Fisheye and other non-rectilinear lenses get no distortion correction. The profile is converted by least squares +into the DNG `WarpRectilinear` / `FixVignetteRadial` polynomials, so it goes through the same path as embedded +corrections: **Enable Profile Corrections** and its Distortion / Vignetting amounts, the CPU and GPU geometry +stage (one resample), orientation handling and DNG export. A conversion whose radial error exceeds 0.001 of the +half diagonal, or that would fold the image, is not applied. + +Normalisation follows lensfun's documentation: distortion and TCA radii are 1 at half the shorter side of the +calibration frame (its `cropfactor` and `aspect-ratio`), vignetting radii 1 at its corner; the optical centre is +the image centre (lensfun's `
` offsets are not read). + +## Limits + +- Only raws: rendered images (JPEG, HEIF, TIFF) are often already corrected in camera. +- The focus distance is not read from maker notes yet: vignetting uses the farthest calibrated distance. +- No manual make / model picker yet; matching is automatic. +- Not yet validated against real photos and another renderer; the tests use synthetic profiles and grids. diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 0c18a6521..878603e01 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -4,6 +4,10 @@ File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python, and a C SQLite runtime are not required. The source database is read-only, including committed `-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits. +File → Open Library… given a Lightroom catalog folder (or an `.lrdata` / `.lrcat-data` folder, or the `.lrcat`) +doesn't make a LightCraft library there: it offers this import instead, with the catalog found +(`crates/engine/src/lightroom_folder.rs`, issue #891). `lightcraft-cli --library` refuses those places too. + Equivalent commands: ```text @@ -47,7 +51,11 @@ A Custom white balance on a raw LightCraft develops relative to its as-shot look RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported (issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is -shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). +shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). A raw whose camera has +measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…) edits in Kelvin +and keeps the Kelvin value too (issue #730) — when its file was found and probed, since the camera is +read from the file, not from the Lightroom catalog; a missing file is treated as a relative-scale raw +until Reload reads it. Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings; it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. @@ -62,5 +70,9 @@ Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib- Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths, settings, decoded XMP, keywords, history, snapshots, collection definitions and membership. -The importer uses shared Rust code on macOS, Windows and Linux. If originals move between -computers or volumes, use LightCraft's missing-photo relinking tools to update their paths. +The importer uses shared Rust code on macOS, Windows and Linux. When a root folder's recorded +path isn't a folder on this computer (a catalog copied from another machine), the importer uses +the path relative to the catalog that Lightroom records for root folders on the catalog's volume +(`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying +a project folder with its catalog inside it therefore keeps its originals linked. Other moved +originals stay missing; use LightCraft's missing-photo relinking tools to update their paths. diff --git a/docs/mcp.md b/docs/mcp.md index b3b50f808..6fa3904b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -164,9 +164,9 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | | `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) | -| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable | +| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw (see [control-protocol.md](control-protocol.md)) | | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | -| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` | +| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | | `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e26118b57..bca6695ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -34,7 +34,7 @@ cited here still exists. Formerly docs/parity.md. | F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | -| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | +| I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | | K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | @@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | -| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 20 | 28 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 54 | 66 | 36 | 192/200 (96%) | 143/155 (92%) | +| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | +| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | +| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Lightroom Classic extras | 36 | 21 | 28 | 8 | — | 24/26 (92%) | +| **Total** | 409 | 55 | 66 | 35 | 192/200 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.1%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.0% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.4%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 52.3% of 172. ## Top gaps and shortcut notes @@ -69,7 +69,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | @@ -77,11 +77,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -93,7 +93,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | @@ -106,7 +106,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -128,10 +128,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render | +| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; no zoom-link toggle | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | @@ -164,9 +164,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | -| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | +| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -175,11 +175,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | -| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR switch in the Light panel; see Q. HDR | +| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) | -| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | @@ -189,14 +189,14 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; Shift-drag locks the hue and changes saturation only, showing the spoke as a guide; the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -205,12 +205,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | | LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | -| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); X-Trans/linear RGB, model policy and Lightroom fidelity remain | +| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -233,7 +233,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -245,10 +245,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | -| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) | +| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match | | LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays | @@ -259,7 +259,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | `cmd:spot.add` (`mode: remove`), `crates/pipeline/src/spots.rs` | heal with automatic source; no patch synthesis (M8.4) | +| LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | `cmd:spot.add` (`mode: remove` / `fill`), `crates/pipeline/src/spots.rs`, `crates/inpaint/src/lib.rs` | Remove heals with an automatic source; Fill synthesises the area from its surroundings (PatchMatch completion over a membrane-blended start, `crates/inpaint`, ported from PhotoCraft; bounded by the spot size limit); no generative fill (M8.4) | | LR-REM-HEAL | Heal | P0 | ✅ | `cmd:spot.add` (`mode: heal`) | | | LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | | LR-REM-GEN | Generative remove | OOS | 🚫 | | | @@ -270,6 +270,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | | LR-REM-DUST | Dust detection | P2 | ✅ | `cmd:spot.findDust`, `crates/pipeline/src/dust.rs` | Remove panel ▸ Find Dust Spots: small, soft, round dark spots on smooth areas become heal spots (sensitivity; one undo step); Visualize Spots for checking by eye | +| LR-REM-BLEMISH | Find blemishes on faces (beyond Lightroom) | P2 | ✅ | `cmd:spot.findBlemishes`, `crates/pipeline/src/blemish.rs`, `crates/engine/src/cmd/face_detect.rs` | Remove panel ▸ Find Blemishes: with the YuNet detector installed, small darker-or-redder round spots on each face's skin (eyes, brows, nostrils and mouth cut out around the landmarks) become heal spots (one undo step); faces under 160 px and faces with more than 12 candidates (stubble, freckles) get none, at most 6 per face; sensitivity 0–100. Classical, no learned model beyond the detector; not a Lightroom feature | | LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | | ## J. Red eye (EYE) @@ -290,7 +291,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; coverage builds up by passes, not hold time (holding the brush still adds nothing, #518); live-preview gaps (#517) remain; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | @@ -301,7 +302,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | @@ -360,11 +361,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); HDR settings not yet read from crs XMP | +| LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | | LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops | -| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | +| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | ## R. Video (VID) @@ -381,24 +382,24 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | +| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | -| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, float TIFF; no HDR presets yet | +| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, PQ PNG, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -423,10 +424,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-