Migrate LightCraft to egui 0.37 and reconcile dependency and font locks

Preserve current main, adapt frame/input and IME handling, and keep library
check results stable across layout passes. Include the matching Nix font lock
and reviewed attribution changes.
This commit is contained in:
skylartaylor
2026-10-25 02:54:42 +01:00
210 changed files with 16138 additions and 1394 deletions
+69
View File
@@ -0,0 +1,69 @@
<!--
Title: start with the checklist id or the issue, as the commits do:
`LR-IMP-FORMATS: folder import finds Sony SR2 and SRF files`, or `Issue #123: …` when there is no checklist row.
One change per pull request. Unrelated fixes and clean-ups (clippy, formatting, a second feature) go in their own:
small ones land fast, bundles get sent back to be split. The rules are in AGENTS.md.
Delete the sections and lines that don't apply, and these comments. A translation or a one-line fix needs only the
first three sections.
-->
Fixes #
<!-- `Part of #123` when this does only part of the issue. Built on another open pull request? Say so here
(`Stacked on #123`) and name the commits that are this one's own. -->
## Problem
<!-- What is wrong or missing, as a person using the app meets it. For a bug: the cause. -->
## Change
<!-- What you changed and where. -->
**Not in this PR:** <!-- what you left out on purpose, and known limits -->
## How it was checked
- [ ] `cargo xtask ci` passes on this branch, rebased on current `main`
- [ ] A test that fails without this change (for a crash fix: a regression test that panicked before it)
- [ ] User-visible change: ran the app and looked at the result
<!-- Numbers, when it changes rendering, decoding or speed: before and after, on more than one photo or camera
(ΔE against the reference, `cargo xtask bench`, `/usr/bin/time`, memory). Name the files or corpus entries used. -->
## Screenshots
<!-- For a visible change: before and after, labelled. Never a screenshot of an Adobe app. -->
## Merging
<!-- Pull requests land in batches, merged together. This is what the person merging can't see in the diff. -->
- **Overlap:** <!-- open pull requests that fix the same issue or change the same functions, by number, and how
this one differs. Search the open pull requests for the issue number and checklist id first. "none found" is fine. -->
- **Existing edits and libraries:** <!-- does any photo already edited render differently, or any saved setting,
preset, sort order or default change? Then it goes behind a new process version (docs/process-versions.md) or is a
setting that is off by default, with a line in docs/whats-new.md. "unchanged" is fine. -->
- **Shared numbers taken:** <!-- catalog format `VERSION` (crates/catalog/src/journal.rs), `RENDER_CACHE_VERSION`,
`LOOK_VERSION`, process version: the number this takes. If another pull request lands with it first, take one
past main's when you rebase. -->
- **Where it came from:** <!-- for a decoder, camera or lens data, or fitted constants: the public spec, your own
measurements, or observed output, and the files measured. Never GPL / LGPL code (dcraw, LibRaw, rawspeed, darktable,
RawTherapee, lensfun …), text derived from it, or Adobe profiles. -->
- **New dependencies, `unsafe`, build scripts, network access, spawned processes, deleted files, CI or `xtask` gate
changes:** <!-- each with its reason, or "none". Dependencies are pure Rust and use `workspace = true`; actions are
pinned by SHA; downloads are checked against a sha256; nothing leaves the machine unless the person asked. -->
## Checklist
- [ ] No new panic path in non-test code: no `unwrap` / `expect` / `panic!`, no indexing or unchecked arithmetic on a
number from a file, a person or an agent (`get`, `checked_mul`, NaN and char boundaries handled)
- [ ] New text on screen goes through `tr()` and has its key in `crates/ui-egui/locales/*.json`; a new shortcut is
free in `docs/library-shortcuts.md`
- [ ] The feature's rows in `docs/parity-checklist.md` and its entry in `docs/gaps.md` are updated, honestly (🟡 when
it is partial), in the same commit
- [ ] New code has a caller: no command, module or setting that nothing reaches yet
- [ ] Every new image, icon or other asset has its entry in `assets/ATTRIBUTION.md`; no font or media files added
- [ ] Ready to merge (otherwise open it as a draft)
+4 -1
View File
@@ -68,7 +68,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Quality gates** before every commit: `cargo xtask ci --quick` (fmt, clippy -D warnings, the changed packages'
tests, layers, assets, and heif / parity / wasm when touched). GitHub runs the full `cargo xtask ci` on every PR
(`.github/workflows/ci.yml`); run the full one locally before merging branches (below) or when `--quick` can't see
the risk. One `ci` runs at a time per machine; others wait their turn (`xtask/src/ci_lock.rs`).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
Generated
+20 -28
View File
@@ -1085,7 +1085,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
dependencies = [
"egui",
"serde",
@@ -1347,7 +1347,7 @@ dependencies = [
[[package]]
name = "ecolor"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1380,7 +1380,7 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1416,7 +1416,7 @@ dependencies = [
[[package]]
name = "egui"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1435,7 +1435,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1492,7 +1492,7 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"dify",
"eframe",
@@ -1539,7 +1539,7 @@ dependencies = [
[[package]]
name = "emath"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1612,7 +1612,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -3371,7 +3371,7 @@ version = "0.13.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"quick-xml 0.38.4",
"quick-xml",
]
[[package]]
@@ -3409,7 +3409,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
"proptest",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
]
@@ -3418,6 +3418,7 @@ dependencies = [
name = "lightcraft-pipeline"
version = "0.13.0"
dependencies = [
"dhat",
"image",
"lightcraft-color",
"lightcraft-develop",
@@ -4884,15 +4885,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -6412,7 +6404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@@ -6933,7 +6925,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -7419,19 +7411,19 @@ dependencies = [
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+18 -18
View File
@@ -6,11 +6,11 @@ members = ["crates/*", "apps/*", "xtask"]
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
@@ -60,7 +60,7 @@ libc = "0.2" # dev-only: process CPU time in benches
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
# formats
quick-xml = "0.38"
quick-xml = "0.41"
miniz_oxide = "0.8"
# codecs
@@ -175,18 +175,18 @@ miniz_oxide = { opt-level = 3 }
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+27 -5
View File
@@ -50,11 +50,15 @@ maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implement
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans
Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto
Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's
licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.txt.
also embed the default fonts listed in
https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian,
NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, Noto Sans Hebrew and
Noto Sans Devanagari), under the SIL Open Font License 1.1. The opt-in LXGW WenKai Lite face
(fonts/simplified-chinese-manifest.txt in craft-fonts) is not embedded. The web build embeds
BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular, and loads Noto Sans
CJK SC separately. Release packages include each font's licence as OFL-<family>.txt; Windows
installers include their texts in FONT-LICENCES.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
@@ -62,3 +66,21 @@ downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoName
(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.
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+9
View File
@@ -405,8 +405,17 @@ cargo run --release -p lightcraft -- --control 7980 # with the automation ch
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
cargo xtask ci --quick # only what your branch's changes can break (minutes)
```
`cargo xtask ci --quick` compares with the merge base of `origin/main` (`--base REF` for another)
and runs fmt, clippy, the tests of the changed packages, layers and assets, plus HEIF, parity and
the wasm check when the changes touch them; a change to `Cargo.toml`, `Cargo.lock` or the lint
config tests everything. GitHub runs the full `cargo xtask ci` on every pull request
(`.github/workflows/ci.yml`). Only one `ci` runs at a time per machine: a second one, from another
worktree say, waits for the first and names it, since several at once take longer in total than in
turn. `LIGHTCRAFT_CI_SLOTS=2` allows two; `LIGHTCRAFT_CI_LOCK=off` skips the lock.
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
available when it starts (at most one per CPU; 4 if memory can't be read), so an
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
+4 -4
View File
@@ -298,10 +298,10 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md)):
- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
[docs/parity-checklist.md](docs/parity-checklist.md)。
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
@@ -309,7 +309,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#upcoming)。
| 领域 | 状态 |
|---|---|
@@ -333,7 +333,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#upcoming)) |
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
+7 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -114,6 +114,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 2026-10-22: Print page setup and PDF output gain the Japanese photo and postcard papers — L判
(89 × 127 mm), 2L判 (127 × 178), KG判 (102 × 152), はがき (100 × 148), 往復はがき (148 × 200),
六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog
and `print.set` take their customary names, and names are matched case-, space- and
punctuation-insensitively (`L判`, `l`, `L-size` are one paper).
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
@@ -186,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged |
| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
+48
View File
@@ -0,0 +1,48 @@
//! The page's automation entry point, `lightcraft.command(id, params)` (see `web.rs`): what the
//! params text means. Plain Rust, so it is tested natively.
use serde_json::Value;
/// The command parameters in `text`: a JSON object, or `{}` when the text is empty or blank (or
/// `null`). Anything else is an error, never `{}`: a command run without its params acts on the
/// selection or with defaults, so a typo such as `{ids: [3]}` must not delete or reset the
/// selected photos. The control channel and MCP refuse such params the same way.
pub fn params(text: &str) -> Result<Value, String> {
let text = text.trim();
if text.is_empty() {
return Ok(Value::Object(Default::default()));
}
match serde_json::from_str::<Value>(text) {
Ok(Value::Null) => Ok(Value::Object(Default::default())),
Ok(v @ Value::Object(_)) => Ok(v),
Ok(_) => Err("params must be a JSON object, e.g. {\"ids\": [3]}".into()),
Err(e) => Err(format!("params are not valid JSON ({e}); nothing was run")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn blank_or_null_params_are_an_empty_object() {
for text in ["", " ", "\n", "null", " null "] {
assert_eq!(params(text), Ok(json!({})), "{text:?}");
}
}
#[test]
fn an_object_is_passed_through() {
assert_eq!(params(r#"{"ids": [3], "rating": 5}"#), Ok(json!({"ids": [3], "rating": 5})));
}
/// Before, any of these ran the command with `{}`: `photo.delete` then removed the selection.
#[test]
fn invalid_json_or_a_non_object_is_refused() {
for text in ["{ids: [3]}", "{\"ids\": [3]", "ids=3", "[3]", "3", "\"{}\"", "true"] {
assert!(params(text).is_err(), "{text:?} must not run the command");
}
assert!(params("{ids: [3]}").unwrap_err().contains("nothing was run"));
}
}
+1
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod automation;
pub mod backup;
pub mod bench;
pub mod files;
+43 -8
View File
@@ -23,7 +23,11 @@ use crate::store::{Originals, content_hash, download_name, storage_key};
use crate::wire::ThumbIndex;
use crate::workers::{THUMB_INDEX, Workers};
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
/// The web picker's accept list, derived from the engine's import extensions (both
/// cases) so no format the engine can import is silently filtered out of the dialog.
fn accept_list() -> String {
lightcraft_engine::import::accept_list()
}
/// After a failed save, wait this long before trying again (ms).
const SAVE_RETRY_MS: f64 = 2000.0;
@@ -123,7 +127,7 @@ fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Contex
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
input.set_accept(ACCEPT);
input.set_accept(&accept_list());
let inp = input.clone();
let on_change = Closure::<dyn FnMut()>::new(move || {
if let Some(files) = inp.files() {
@@ -276,6 +280,8 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bo
}
}
flushing.set(false);
// the page title drops its unsaved • now, even when nothing else wakes the page
request_repaint();
}
/// Everything loaded before the UI starts.
@@ -405,6 +411,8 @@ struct WebApp {
lib_dir: String,
bench: Option<Bench>,
first_frame_logged: bool,
/// The page title last set (`document.title`), so it is written only when it changes.
title: String,
last_save: f64,
ui_written: Vec<u8>,
}
@@ -504,6 +512,7 @@ impl WebApp {
lib_dir,
bench: opts.bench.then(|| Bench::new(origin)),
first_frame_logged: false,
title: String::new(),
last_save: 0.0,
ui_written,
}
@@ -513,22 +522,37 @@ impl WebApp {
/// hidden logic tick), and add originals whose bytes have arrived.
fn import_dropped(&mut self, ctx: &egui::Context) {
let dropped = self.app.take_dropped_files();
// a dialog is on screen (the card said so while the files hovered): nothing is read or imported
// (files read from an earlier drop still finish below)
if !dropped.is_empty() && self.app.ui.dialog.is_some() {
self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again"));
return;
}
for f in dropped {
let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string();
match egui::DroppedFile::bytes_async(f.as_ref()).await {
Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await,
Err(e) => log::warn!("reading dropped {name}: {e}"),
Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())),
}
});
}
let paths = self.originals.take_pending();
if !paths.is_empty() {
let n = paths.len();
match self.app.run("library.import", json!({"paths": paths})) {
Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })),
Err(e) => log::warn!("import: {e}"),
// what the import did, not what was submitted: duplicates and unsupported files add nothing,
// a drop that only finds duplicates says nothing (as the desktop import's toast does when it
// shows where they are), and one whose files could not be read says so
Ok(report) => {
if let Some(notice) = lightcraft_ui_egui::i18n::dropped_photos_outcome(&report) {
self.app.toast(ctx, notice);
}
}
Err(e) => {
log::warn!("import: {e}");
self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_import_failed(&e));
}
}
}
}
@@ -644,8 +668,11 @@ impl WebApp {
fn run_inbox(&mut self) {
let inbox = INBOX.with(|q| std::mem::take(&mut *q.borrow_mut()));
for (id, params, (resolve, reject)) in inbox {
let params: serde_json::Value = serde_json::from_str(&params).unwrap_or(json!({}));
let r = if id == "web.stats" { Ok(self.web_stats()) } else { self.app.run(&id, params) };
let r = match crate::automation::params(&params) {
Err(e) => Err(format!("{id}: {e}")),
Ok(_) if id == "web.stats" => Ok(self.web_stats()),
Ok(params) => self.app.run(&id, params),
};
let _ = match r {
Ok(v) => resolve.call1(&JsValue::NULL, &v.to_string().into()),
Err(e) => reject.call1(&JsValue::NULL, &e.into()),
@@ -680,6 +707,14 @@ impl eframe::App for WebApp {
let ctx = ui.ctx().clone();
self.import_dropped(&ctx);
self.app.ui(ui);
// eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title
let title = self.app.page_title(self.files.is_dirty() || self.flushing.get());
if title != self.title
&& let Some(doc) = window().and_then(|w| w.document())
{
doc.set_title(&title);
self.title = title;
}
// The benchmark is a UI-phase step: a second layout pass or a hidden tick must not count a frame.
if ui.ctx().current_pass_index() == 0 {
if let Some(b) = self.bench.as_mut()
+1 -1
View File
@@ -522,7 +522,7 @@ mod tests {
}
};
frame(&mut watch, &mut index); // an empty filtered view: no render request yet
cache.clear(); // File ▸ Clear Preview Cache
cache.clear(); // File ▸ Discard Preview Cache
frame(&mut watch, &mut index);
// the filter is removed: the first thumbnail request (`Workers::try_start`)
gone.extend(watch.observe(&cache, cache.generation(), &mut index));
+7
View File
@@ -247,6 +247,9 @@ mod tests {
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let (stream, _) = listener.accept().unwrap();
// Small test messages must not wait for Nagle's algorithm or a delayed ACK.
client.set_nodelay(true).unwrap();
stream.set_nodelay(true).unwrap();
before(&mut client);
let (tx, requests) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
@@ -316,6 +319,10 @@ mod tests {
// A dispatched command waits for its reply, not for socket input.
std::thread::sleep(Duration::from_millis(250));
req.reply.send(json!({"ok": true, "result": id})).unwrap();
if id == 2 {
// The worker must reach EOF even before the client reads its final reply.
connection.join();
}
let mut reply = String::new();
connection.client.read_line(&mut reply).unwrap();
let reply: Value = serde_json::from_str(&reply).unwrap();
+134 -23
View File
@@ -31,22 +31,14 @@ pub fn check(app: &str) -> Result<(), String> {
/// 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 = "macos") => return run_open(&[path]),
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::MacApp(app) => return run_open(&["-a", &app, path]),
Launcher::Program(p) => {
let mut c = Command::new(p);
c.arg(path);
@@ -61,6 +53,22 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// macOS: run `open` and wait for it (it returns as soon as LaunchServices has handed the file
/// over), so an application it can't find or start is reported instead of silently doing nothing.
fn run_open(args: &[&str]) -> Result<(), String> {
let out = Command::new("open").args(args).output().map_err(|e| e.to_string())?;
open_result(out.status.success(), &String::from_utf8_lossy(&out.stderr))
}
/// What `open` said, as a result: its own message when it failed.
fn open_result(ok: bool, stderr: &str) -> Result<(), String> {
if ok {
return Ok(());
}
let msg = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("the application couldn't be started");
Err(msg.to_string())
}
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
/// still starting when it is started once per file. The system default opens them one by one
@@ -119,10 +127,15 @@ fn launcher(app: &str) -> Result<Launcher, String> {
fn find_photocraft() -> Option<Launcher> {
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
// the usual places, then wherever Spotlight knows it (by bundle id or bundle name)
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())));
let usual = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
let spotlight = || {
let query = format!("kMDItemCFBundleIdentifier == '{PHOTOCRAFT_ID}' || kMDItemFSName == 'PhotoCraft.app'");
let out = Command::new("mdfind").arg(query).output().ok().filter(|o| o.status.success())?;
mdfind_app(&String::from_utf8_lossy(&out.stdout))
};
return usual.or_else(spotlight).map(|p| Launcher::MacApp(p.to_string_lossy().to_string()));
}
if cfg!(target_os = "windows") {
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
@@ -130,16 +143,34 @@ fn find_photocraft() -> Option<Launcher> {
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::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
// registers one on first run; its Exec a path or a name on the PATH), an AppImage where people
// keep them, else the Flatpak
let path_dirs: Vec<PathBuf> = env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).collect();
let on_path = |name: &Path| resolve_program(name, &path_dirs);
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())
desktop_exec(&text)
// a wrapper (`Exec=env VAR=1 /opt/PhotoCraft/photocraft %F`) would be started with the photo only:
// skip it and look further
.filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().to_lowercase().contains("photocraft")))
.and_then(|p| on_path(&p))
})
};
if let Some(p) = on_path.or_else(from_entry) {
let appimage = || {
let home = env("HOME")?;
let dirs = [Path::new(&home).join("Applications"), Path::new(&home).join(".local/bin")];
// both folders, so the newest version wins whichever it is in
let mut found: Vec<PathBuf> = dirs
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|rd| rd.flatten().map(|e| e.path()).filter(|p| is_photocraft_appimage(p) && p.is_file()).collect::<Vec<_>>())
.collect();
// the newest version by name (1.10 after 1.9)
found.sort_by_key(|p| version_key(&p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default()));
found.pop()
};
if let Some(p) = on_path(Path::new("photocraft")).or_else(from_entry).or_else(appimage) {
return Some(Launcher::Program(p));
}
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
@@ -205,10 +236,44 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
program.starts_with('/').then(|| PathBuf::from(program))
// a path, or a bare name (`photocraft %F`) the caller looks for on the PATH
(!program.is_empty()).then(|| PathBuf::from(program))
}
/// A program from a desktop entry or the PATH: a path as it is (if it is a file), a bare name
/// looked for in `dirs`. A leading `/` counts as absolute on every platform (desktop entries are
/// a POSIX format; `Path::is_absolute` wants a drive letter on Windows, where the tests run too).
fn resolve_program(name: &Path, dirs: &[PathBuf]) -> Option<PathBuf> {
if name.is_absolute() || name.starts_with("/") {
return Some(name.to_path_buf()).filter(|p| p.is_file());
}
dirs.iter().map(|d| d.join(name)).find(|p| p.is_file())
}
/// A sort key that orders the digit runs in a name by value (`1.9` before `1.10`).
fn version_key(name: &str) -> Vec<(u64, String)> {
let mut out = Vec::new();
let mut rest = name;
while !rest.is_empty() {
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let (num, tail) = rest.split_at(digits);
let text_len = tail.find(|c: char| c.is_ascii_digit()).unwrap_or(tail.len());
let (text, next) = tail.split_at(text_len);
out.push((num.parse().unwrap_or(u64::MAX), text.to_string()));
rest = next;
}
out
}
/// An AppImage of PhotoCraft by its file name (`PhotoCraft-1.2-x86_64.AppImage`, any case).
fn is_photocraft_appimage(p: &Path) -> bool {
let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
name.contains("photocraft") && name.ends_with(".appimage")
}
/// The first application bundle in `mdfind` output (one path per line).
fn mdfind_app(output: &str) -> Option<PathBuf> {
output.lines().map(str::trim).find(|l| l.ends_with(".app")).map(PathBuf::from)
}
#[cfg(test)]
@@ -275,13 +340,59 @@ mod tests {
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("Exec=photocraft %F\nTryExec=photocraft\n"), Some(PathBuf::from("photocraft")), "a bare name, for 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 a_bare_exec_name_is_found_on_the_path() {
let a = std::env::temp_dir().join(format!("lc-editors-a-{}", std::process::id()));
let b = std::env::temp_dir().join(format!("lc-editors-b-{}", std::process::id()));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("photocraft"), b"").unwrap();
let dirs = vec![a.clone(), b.clone()];
assert_eq!(resolve_program(Path::new("photocraft"), &dirs), Some(b.join("photocraft")));
assert_eq!(resolve_program(Path::new("nothere"), &dirs), None);
let abs = b.join("photocraft");
assert_eq!(resolve_program(&abs, &[]), Some(abs.clone()), "a path needs no PATH");
assert_eq!(resolve_program(&b.join("missing"), &dirs), None);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn appimage_versions_sort_numerically() {
let mut names = ["photocraft-1.10-x86_64.appimage", "photocraft-1.9-x86_64.appimage", "photocraft-1.2-x86_64.appimage"];
names.sort_by_key(|n| version_key(n));
assert_eq!(names[2], "photocraft-1.10-x86_64.appimage");
assert_eq!(names[0], "photocraft-1.2-x86_64.appimage");
}
#[test]
fn a_failed_open_is_an_error() {
assert!(open_result(true, "").is_ok());
assert!(open_result(false, "Unable to find application\n").is_err());
assert!(open_result(false, "").is_err(), "even when it says nothing");
}
#[test]
fn odd_detection_input_does_not_panic() {
for text in ["", "Exec=", "Exec=\"", "Exec=\"\"", "\u{0}\u{fffd}", "Exec= %F", "[Desktop Entry]\nExec=\u{e9}\u{301}"] {
let _ = desktop_exec(text);
let _ = mdfind_app(text);
let _ = reg_value(text);
}
for p in ["", "/", ".AppImage", "\u{e9}.appimage", "a/../b"] {
let _ = is_photocraft_appimage(Path::new(p));
}
let _ = desktop_entry_dirs(|_| None);
let _ = windows_candidates(None, Some("\"".into()), |_| None);
}
#[test]
fn other_editors_are_used_as_named() {
assert_eq!(launcher("").unwrap(), Launcher::Default);
+60 -19
View File
@@ -3,8 +3,10 @@
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
//!
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
//! `~/Pictures/LightCraft Library`; a new library starts with the
//! `$LIGHTCRAFT_LIBRARY`, else — with Settings ▸ General ▸ At launch set to *Ask me*, or Alt/Option
//! held as the window appears — the one chosen in a list of the recent libraries, else the library
//! last opened, else `~/Pictures/LightCraft Library`
//! ([`lightcraft_ui_egui::panels::library_problem::launch_plan`]); a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
@@ -39,7 +41,7 @@ mod window_gpu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
@@ -363,7 +365,7 @@ struct PrefsWriter {
impl PrefsWriter {
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
self.save_ui(&app.ui, app.library_problem.is_some())
self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing))
}
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
@@ -727,9 +729,13 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
}
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in.
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
/// (`<config>/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet:
/// the window lists the recent ones.
fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = match launch {
Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None),
Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo),
};
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
@@ -778,7 +784,9 @@ lightcraft — photo library + raw developer
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
OPTIONS:
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY)
--library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or
a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held,
else ~/Pictures/LightCraft Library)
--no-demo don't seed a new library with the procedural demo photos
--memory throwaway in-memory session (alias --demo); nothing is saved
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
@@ -909,15 +917,15 @@ fn main() -> eframe::Result {
if let Some(ui) = &prefs {
lightcraft_ui_egui::i18n::set_language(ui.language);
}
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
.as_ref()
.map(|u| u.settings.library_path.clone())
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
.map(Into::into)
});
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// --library, else LIGHTCRAFT_LIBRARY, else the list of recent libraries if Settings say Ask
// me, else the library last opened from Settings, else the default location
let launch = lightcraft_ui_egui::panels::library_problem::launch_plan(
in_memory,
library_dir,
std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into),
prefs.as_ref().map(|u| &u.settings),
lightcraft_engine::library::default_dir(),
);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory));
@@ -970,7 +978,7 @@ fn main() -> eframe::Result {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
came_up_app.store(true, std::sync::atomic::Ordering::Relaxed);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let (mut session, mut problem) = open_session(in_memory, &launch, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/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)
@@ -981,6 +989,11 @@ fn main() -> eframe::Result {
if let Some(ui) = prefs {
app.ui = ui;
}
// the recent libraries to choose from (issue #1290), checked on a worker once shown
if launch == Launch::Ask {
problem = Some(LibraryProblem::choose_later(&app.ui.settings));
}
app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. });
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
@@ -1154,12 +1167,40 @@ mod tests {
/// adds to Apple Photos on a worker (headless sessions wait instead).
#[test]
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
let (s, problem) = open_session(true, None, false);
let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false);
assert!(problem.is_none());
assert!(s.apple_photos_background);
assert!(!lightcraft_engine::Session::new().apple_photos_background);
}
/// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo
/// photos) before the user chooses, and the settings are still saved while the list shows (a
/// removed entry is kept), unlike the failed-library window's temporary session.
#[test]
fn asking_at_launch_opens_nothing_and_still_saves_the_settings() {
let (s, problem) = open_session(false, &Launch::Ask, true);
assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty());
assert!(s.apple_photos_background, "the same desktop session as an opened one");
let d = dir("writer-ask");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(s, Services::default());
app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()];
app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings));
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
app.ui.settings.recent_libraries.remove(0);
w.save(&app).unwrap();
let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved.settings.recent_libraries, ["/gone/b"]);
// the failed-library window still writes nothing
app.library_problem = Some(LibraryProblem::new("/gone/b", "not there"));
app.ui.settings.recent_libraries.clear();
w.save(&app).unwrap();
let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved.settings.recent_libraries, ["/gone/b"]);
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
+30 -67
View File
@@ -16,7 +16,9 @@ use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use lightcraft_ui_egui::LightcraftApp;
use lightcraft_ui_egui::menubar::{MenuNode, menu_bar};
use lightcraft_ui_egui::menubar::{
MenuNode, menu_bar, native_item_presentation, native_menu_delivers as menu_delivers, native_menu_structure as structure_of,
};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
@@ -57,8 +59,10 @@ struct Item {
}
impl Item {
/// The row's text as installed: `label` is already what the row shows (translated, with a
/// borrowed key's hint, `native_item_presentation`).
fn native_label(&self) -> String {
lightcraft_ui_egui::menubar::display_item_label(&self.cmd, &self.params, &self.label).replace('&', "&&")
self.label.replace('&', "&&")
}
/// Puts `want` on the native item. muda's `set_accelerator(None)` does nothing on macOS (the
@@ -276,13 +280,6 @@ fn yields_to_text(sc: &str) -> bool {
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
}
/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
/// those even though their menu item shows the key.
fn menu_delivers(sc: &str) -> bool {
sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok()))
}
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
@@ -293,34 +290,6 @@ fn label_text(s: &str) -> String {
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
}
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
fn walk(n: &[MenuNode], out: &mut String) {
for x in n {
match x {
MenuNode::Item { id, params, checked, shortcut, .. } => {
out.push_str(&MenuNode::key(id, params));
out.push(if checked.is_some() { 'c' } else { 'i' });
out.push_str(shortcut.as_deref().unwrap_or(""));
}
MenuNode::Separator => out.push('-'),
MenuNode::Submenu { label, children } => {
out.push('[');
out.push_str(label);
walk(children, out);
out.push(']');
}
}
out.push(';');
}
}
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
}
s
}
/// Drop a trailing separator (left behind when an item moved to the app menu).
fn tidy_separators(mut nodes: Vec<MenuNode>) -> Vec<MenuNode> {
while matches!(nodes.last(), Some(MenuNode::Separator)) {
@@ -353,9 +322,10 @@ impl NativeMenu {
m
}
/// Recreate native handles and shortcut ownership from the current shared menu model.
fn rebuild(&mut self, app: &mut LightcraftApp) {
let bar = menu_bar(app);
self.structure = structure_of(&bar);
self.structure = structure_of(&bar, &app.ui.settings.keymap);
self.menu = Menu::new();
self.items.clear();
self.app_items.clear();
@@ -389,7 +359,7 @@ impl NativeMenu {
shortcut: Some(shortcut.to_string()),
accel,
current: accel,
label: text.to_string(),
label: lightcraft_ui_egui::menubar::display_item_label(cmd, &Value::Null, text).to_string(),
enabled: true,
checked: None,
});
@@ -414,7 +384,7 @@ impl NativeMenu {
let nodes = tidy_separators(nodes);
// Swapping an item in the Help menu leaves the old row in place and adds a second one
// (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row.
self.append_nodes(&sub, &nodes, title != "Help");
self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help");
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[
@@ -441,8 +411,9 @@ impl NativeMenu {
self.publish_shortcuts(app);
}
/// Install translated rows with their effective accelerators, preserving hint-only keys.
/// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`).
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], swappable: bool) {
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) {
for n in nodes {
match n {
MenuNode::Separator => {
@@ -450,7 +421,7 @@ impl NativeMenu {
}
MenuNode::Submenu { label, children } => {
let s = Submenu::new(label_text(label), true);
self.append_nodes(&s, children, swappable);
self.append_nodes(&s, children, keymap, swappable);
let _ = sub.append(&s);
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
@@ -458,26 +429,16 @@ impl NativeMenu {
if self.items.contains_key(&key) {
continue; // muda ids must be unique
}
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
let (native_label, native_key) = native_item_presentation(keymap, id, params, label, shortcut.as_deref());
let accel = native_key.filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
let handle = match checked {
Some(c) => {
let it = CheckMenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
*c,
accel,
);
let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel);
let _ = sub.append(&it);
Handle::Check(it)
}
None => {
let it = MenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
accel,
);
let it = MenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, accel);
let _ = sub.append(&it);
Handle::Plain(it)
}
@@ -493,7 +454,7 @@ impl NativeMenu {
shortcut: shortcut.clone(),
accel,
current: accel,
label: label.clone(),
label: native_label,
enabled: *enabled,
checked: *checked,
},
@@ -533,23 +494,25 @@ impl NativeMenu {
}
}
let bar = menu_bar(app);
if structure_of(&bar) != self.structure {
if structure_of(&bar, &app.ui.settings.keymap) != self.structure {
// (no early return: a rebuild installs every accelerator and resets the state they were synced
// to, so the sync below runs in this same frame and no key reaches the menu in between)
self.rebuild(app);
}
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
/// Synchronize live labels and states; accelerator ownership changes already triggered a rebuild.
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>, keymap: &lightcraft_ui_egui::shortcuts::Keymap) {
for n in nodes {
match n {
MenuNode::Submenu { children, .. } => walk(children, items),
MenuNode::Item { id, params, label, enabled, checked, .. } => {
MenuNode::Submenu { children, .. } => walk(children, items, keymap),
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
if it.label != *label {
let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref());
if it.label != native_label {
match &it.handle {
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")),
Handle::Check(h) => h.set_text(native_label.replace('&', "&&")),
}
it.label = label.clone();
it.label = native_label;
}
if it.enabled != *enabled {
match &it.handle {
@@ -570,7 +533,7 @@ impl NativeMenu {
}
}
}
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items, &app.ui.settings.keymap);
let focus = ctx.egui_wants_keyboard_input();
let capturing = app.recording_shortcut.is_some();
@@ -610,7 +573,7 @@ mod tests {
let mut seen = std::collections::BTreeSet::new();
for language in Locale::ALL {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
assert!(seen.insert(structure_of(&bar, &app.ui.settings.keymap)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::En);
}
+34 -13
View File
@@ -93,7 +93,6 @@ pub fn choose(files: &Files) -> Chosen {
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)
@@ -104,18 +103,7 @@ pub fn choose(files: &Files) -> Chosen {
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",
)
});
let notice = user_notice(failed, forced, &problems, which);
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
let mut wgpu_options = base_options();
let renderer = match (which, existing) {
@@ -134,6 +122,25 @@ pub fn choose(files: &Files) -> Chosen {
Chosen { renderer, wgpu_options, which, notice }
}
/// What to tell the user about the window's renderer. A renderer that failed its probe is skipped
/// silently (it is logged, and the one that worked is remembered): the app works, so a pop-up with
/// wgpu's error only alarms (#1624). Shown: the last start died, or the forced renderer failed.
fn user_notice(failed: Option<WindowRenderer>, forced: Option<WindowRenderer>, problems: &[String], which: WindowRenderer) -> Option<String> {
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
if forced.is_some() && !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
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",
)
})
}
/// 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();
@@ -269,6 +276,20 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// #1624: a renderer skipped by the probe (DX12 → Vulkan) is no pop-up; a died start or a
/// failed forced renderer still is.
#[test]
fn probe_fallback_is_silent() {
let (dx, vk) = (WindowRenderer::Wgpu(Backends::DX12), WindowRenderer::Wgpu(Backends::VULKAN));
let problems = vec!["The dx12 renderer failed (Validation Error).".to_string()];
assert_eq!(user_notice(None, None, &problems, vk), None);
assert_eq!(user_notice(None, None, &[], vk), None);
let forced = user_notice(None, Some(dx), &problems, vk).unwrap();
assert!(forced.starts_with("The dx12 renderer failed") && forced.contains("now draws its window with vulkan"), "{forced}");
let died = user_notice(Some(dx), None, &problems, vk).unwrap();
assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}");
}
/// 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]
+2 -1
View File
@@ -8,6 +8,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|---|---|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | egui icon subset and monochrome fallback | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | 2026-10-24 (notice audit) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full fallback. Registered by production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
@@ -29,7 +30,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular, Noto Sans Georgian Regular, NanumGothic Regular, Noto Sans Symbols Regular, Noto Sans Symbols 2 Regular, Noto Sans Hebrew Regular, Noto Sans Devanagari Regular | The Noto Project Authors (Arabic, Thai, Georgian, Symbols, Symbols 2, Hebrew and Devanagari); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; NHN Corporation and Sandoll Communications (NanumGothic); Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the default `fonts/manifest.txt` set (all official releases; the opt-in LXGW WenKai Lite face in `fonts/simplified-chinese-manifest.txt` is not embedded); the web build embeds BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
+3
View File
@@ -61,6 +61,9 @@ sync_github_name = false
real_name = "Ege Gursel"
sync_github_name = true
[people.valenfys]
sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
+107 -4
View File
@@ -43,19 +43,63 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows.
/// anything, it never overflows. Stricter than [`iso_seconds`], which reads what it can for sorting:
/// a time cut short, seconds that aren't digits or anything after the zone is `None` here, not
/// written back as a clean-looking time.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let iso = iso.trim();
let tail = iso.get(tail_start(iso)?..)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
let iso = iso.trim();
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
/// Where the part of an ISO time that [`shift_iso`] keeps as it was (fraction, zone) starts, or
/// `None` unless `iso` is a date alone (`2026-01-01`) or a date and `HH:MM`, optional `:SS` with
/// an optional fraction (`.5`), and an optional zone (`Z`, `+02:00`, `-0500`, `+02`), and nothing after.
fn tail_start(iso: &str) -> Option<usize> {
let b = iso.as_bytes();
let digits = |x: &[u8]| !x.is_empty() && x.iter().all(u8::is_ascii_digit);
let digits_at = |a: usize, z: usize| b.get(a..z).is_some_and(digits);
let at = |i: usize, c: u8| b.get(i) == Some(&c);
if !(digits_at(0, 4) && at(4, b'-') && digits_at(5, 7) && at(7, b'-') && digits_at(8, 10)) {
return None;
}
if b.len() == 10 {
return Some(10);
}
if !((at(10, b'T') || at(10, b' ')) && digits_at(11, 13) && at(13, b':') && digits_at(14, 16)) {
return None;
}
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
let start = if !at(16, b':') {
16
} else if digits_at(17, 19) {
19
} else {
return None;
};
let mut zone = b.get(start..)?;
// a fraction only after the seconds: `10:00.5` is half a minute, not half a second
if let ([b'.', rest @ ..], 19) = (zone, start) {
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
zone = rest.get(n..)?;
if n == 0 {
return None;
}
}
let ok = match zone {
[] | [b'Z'] => true,
[b'+' | b'-', h1, h2, b':', m1, m2] => digits(&[*h1, *h2, *m1, *m2]),
[b'+' | b'-', hm @ ..] => matches!(hm.len(), 2 | 4) && digits(hm),
_ => false,
};
ok.then_some(start)
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
/// seconds or time). `None` if it isn't a valid date.
pub fn normalize_iso(s: &str) -> Option<String> {
@@ -260,6 +304,65 @@ mod tests {
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
}
#[test]
fn shift_refuses_a_time_it_cant_fully_read() {
for bad in [
"2026-01-01T10:0",
"2026-01-01T10",
"2026-01-01T",
"2026-01-01T10:00:xx",
"2026-01-01T10:00:0",
"2026-01-01T10:00:00+",
"2026-01-01T10:00:00+2",
"2026-01-01T10:00:00+02:0",
"2026-01-01T10:00:00.",
"2026-01-01T10:00:00.5.5",
"2026-01-01T10:00.5",
"2026-01-01T10:00:00ZZ",
"2026-01-01T10:00:00 and more",
"2026-01-01T10:00 and more",
"2026-01-01x10:00:00",
"2026/01/01T10:00:00",
] {
assert_eq!(shift_iso(bad, 3600), None, "{bad} doesn't fully read, so it must not be written back");
}
// multi-byte characters where the time, the seconds and the zone start never panic
for at in [11, 16, 18, 21] {
let mut s = String::from("2026-01-01T10:00:00+02:00");
s.replace_range(at..at + 1, "é");
assert_eq!(shift_iso(&s, 3600), None, "{s}");
assert_eq!(shift_iso(s.get(..at + 2).unwrap(), 3600), None, "{s}");
}
}
#[test]
fn shift_keeps_a_fraction_and_zone_and_reads_back() {
for (from, to) in [
("2026-01-01", "2026-01-01T01:00:00"),
(" 2026-01-01T10:00 ", "2026-01-01T11:00:00"),
("2026-01-01 10:00:30", "2026-01-01T11:00:30"),
("2026-01-01T10:00:30.5", "2026-01-01T11:00:30.5"),
("2026-01-01T10:00:30Z", "2026-01-01T11:00:30Z"),
("2026-01-01T10:00:30+02:00", "2026-01-01T11:00:30+02:00"),
("2026-01-01T10:00:30-0500", "2026-01-01T11:00:30-0500"),
("2026-01-01T10:00:30+02", "2026-01-01T11:00:30+02"),
("2026-01-01T10:00:30.125+02:00", "2026-01-01T11:00:30.125+02:00"),
] {
assert_eq!(shift_iso(from, 3600).as_deref(), Some(to), "{from}");
assert_eq!(shift_iso(to, 0).as_deref(), Some(to), "{to} reads back");
}
}
/// Sorting, grouping and date rules read with `iso_seconds`, which stays lenient: only the
/// write path refuses what it can't fully read.
#[test]
fn reading_stays_lenient_for_sorting() {
let at_ten = iso_seconds("2026-01-01T10:00:00");
assert_eq!(iso_seconds("2026-01-01T10:00:00 and more"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:00:xx"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:0"), iso_seconds("2026-01-01"));
}
#[test]
fn impossible_times_are_rejected_not_rolled_over() {
for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
+19 -2
View File
@@ -207,14 +207,31 @@ impl Catalog {
/// folder that is no longer there and are dropped (undo restores them). Empty when there is
/// nothing to carry or drop.
pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec<Op> {
self.folder_records_carried(from, to, true)
}
/// [`Catalog::folder_records_follow`] for a folder located where it already is on disk
/// (`folder.updateLocation`): nothing arrived at `to`, the library only learned that the
/// folder is there. So what it knows about folders at or below `to` (other folders of the
/// library there) stays; a record carried over replaces the one at the same place.
pub fn folder_records_located(&self, from: &str, to: &str) -> Vec<Op> {
self.folder_records_carried(from, to, false)
}
fn folder_records_carried(&self, from: &str, to: &str, clear_destination: bool) -> Vec<Op> {
let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
if root == dest {
return Vec::new();
}
// a folder located below itself: what is already at or below `to` belongs to the folders
// of the library there, not to the folder located, so it is neither carried nor cleared
// (the record of `from` itself still lands at `to`, replacing the one there)
let nested = !clear_destination && key_within(&dest, &root);
let carried = |k: &str| key_within(k, &root) && !(nested && key_within(k, &dest));
let moved: Vec<(String, FolderRecord)> = self
.folder_records
.iter()
.filter(|(k, _)| key_within(k, &root))
.filter(|(k, _)| carried(k))
.filter_map(|(k, r)| {
let rest = folder_rest(k, &root)?;
let base = dest.trim_end_matches('/');
@@ -224,7 +241,7 @@ impl Catalog {
.collect();
// what was known at the old place goes, and so does what is left at the new one (also
// when the moved folder carries nothing: the folder now there is not the one it was about)
let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
let stale = self.folder_records.keys().filter(|k| carried(k) || (clear_destination && key_within(k, &dest)));
let mut ops: Vec<Op> = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
ops
+39 -5
View File
@@ -36,7 +36,7 @@ pub use folders::{FolderNode, FolderRecord};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, LocalRecords, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
@@ -284,7 +284,8 @@ pub struct Catalog {
/// What the library keeps about its folders (folder identity → record), see [`folders`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
folder_records: BTreeMap<String, FolderRecord>,
/// Increments on every applied op.
/// Increments on every applied op, and when a batch fails and its rollback cannot undo it whole (the catalog
/// changed, so what is keyed by the revision is out of date).
#[serde(skip)]
pub revision: u64,
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
@@ -348,6 +349,8 @@ struct Changes {
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
/// taken for this one's): nothing is known of how the catalog got to where it is.
seen: u64,
/// A batch that failed could not be rolled back whole: what it changed stays, so what it noted must too.
torn: bool,
}
impl PartialEq for Changes {
@@ -610,13 +613,40 @@ impl Catalog {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
/// Apply an op; returns its inverse. On error the catalog is as it was, the revision included, and nothing is
/// remembered of the op in what the latest ops touched. The one exception is a batch whose rollback fails: the
/// catalog did change, so the revision advances and anything may be taken to have changed.
pub fn apply(&mut self, op: Op) -> Result<Op> {
if self.changes.seen != self.revision {
// the revision was set from outside: what is remembered is of another past
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
}
let inv = self.apply_inner(op)?;
let broad = self.changes.broad;
self.changes.torn = false;
let inv = match self.apply_inner(op) {
Ok(inv) => inv,
Err(e) => {
let made = self.revision.saturating_add(1);
if self.changes.torn {
// (a batch the rollback of which failed changed something and says not what: it makes a
// revision, so what is kept for the one before is dropped, and anything may have changed, as
// after an op of a wider reach)
self.changes.torn = false;
self.changes.broad = made;
self.revision = made;
self.changes.seen = made;
} else {
// (everything the failed op, or the rollback of a batch, noted was made for the revision it
// would have made: nothing earlier has that one. A note the cap already dropped stays
// dropped, which only says less than is known, as the cap does.)
while self.changes.touched.back().is_some_and(|(r, _, _)| *r == made) {
self.changes.touched.pop_back();
}
self.changes.broad = broad;
}
return Err(e);
}
};
self.revision += 1;
self.changes.seen = self.revision;
Ok(inv)
@@ -962,7 +992,9 @@ impl Catalog {
Err(e) => {
// roll back what was applied
for inv in inverses.into_iter().rev() {
let _ = self.apply_inner(inv);
if self.apply_inner(inv).is_err() {
self.changes.torn = true;
}
}
return Err(e);
}
@@ -1179,4 +1211,6 @@ mod tests_repair;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_tally;
#[cfg(test)]
mod tests_torn_append;
+39 -1
View File
@@ -34,6 +34,15 @@ use serde::Serialize;
use crate::{Catalog, Op, Photo, PhotoId, Source};
/// The Local records of some files ([`Catalog::local_records_of`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LocalRecords {
/// Only a record of having looked: nothing was changed on them and nothing refers to them.
pub untouched: Vec<PhotoId>,
/// Holding something of the person's, with their files.
pub kept: Vec<(PhotoId, String)>,
}
/// Default for how long a Local folder may go unbrowsed before its untouched records are
/// forgotten (days).
pub const DEFAULT_FORGET_DAYS: u32 = 30;
@@ -64,7 +73,8 @@ pub fn folder_of(p: &Photo) -> Option<String> {
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
Source::File { path } => {
let key = crate::query::folder_key(path);
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
// a drive or share root keeps its trailing `/` in its key (`c:/`, `//srv/share/`)
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { crate::query::folder_key(&format!("{dir}/")) })
}
Source::Demo { .. } => None,
}
@@ -176,6 +186,34 @@ impl Catalog {
&self.browsed
}
/// The Local records of `files` (paths as stored), for library photos about to point at
/// those files (a folder located where it was browsed, `folder.updateLocation`): a file has one
/// record, so the Local one gives way. Told apart: those that are only a record of having
/// looked (untouched, see the module docs, and nothing refers to them), which can go, and
/// those that hold something of the person's (a rating, an edit, a place in an album), with
/// their files. Records in Recently Deleted are not Local records to this.
pub fn local_records_of(&self, files: &HashSet<String>) -> LocalRecords {
let mut out = LocalRecords::default();
if files.is_empty() {
return out;
}
let referenced = self.referenced();
for p in self.photos.values().filter(|p| p.local && !p.deleted) {
let Source::File { path } = &p.source else { continue };
if !files.contains(path) {
continue;
}
if p.untouched_local() && !referenced.contains(&p.id) {
out.untouched.push(p.id);
} else {
out.kept.push((p.id, path.clone()));
}
}
out.untouched.sort_unstable();
out.kept.sort_unstable();
out
}
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
/// unreadable `now` forgets nothing. See the module docs.
+11
View File
@@ -240,6 +240,17 @@ fn has_person(p: &Photo, name: &str) -> bool {
}
impl Filter {
/// The labels that `label` and `labels` let through together (they AND), in `labels`' order;
/// `None` when neither is set, empty when they exclude each other. The chip and the filter
/// bar's buttons show this, so they match what is filtered.
pub fn labels_shown(&self) -> Option<Vec<ColorLabel>> {
match (self.label, self.labels.is_empty()) {
(None, true) => None,
(Some(l), true) => Some(vec![l]),
(l, false) => Some(self.labels.iter().copied().filter(|x| l.is_none_or(|l| l == *x)).collect()),
}
}
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
self.describe_in(None)
+68 -1
View File
@@ -432,12 +432,79 @@ thread_local! {
std::cell::RefCell::new(std::collections::HashMap::new());
}
thread_local! {
/// While [`AlbumPass`] is alive on this thread: the photos of each smart album that was counted whole, with
/// the longest chain of smart albums (itself included) its rules lead through.
static PASS: std::cell::RefCell<Option<std::collections::HashMap<crate::AlbumId, (usize, crate::tally::Members)>>> =
const { std::cell::RefCell::new(None) };
}
/// One pass over one catalog state that counts every smart album whole. Each album put in with `remember`
/// answers [`smart_album_holds`] from its photos, not from its rules, until the pass is dropped (it can't outlive
/// the state it was made of: it exists only inside the counting function).
///
/// Only an album whose rules lead to no loop may be remembered, and only with the height of its chain: its answer
/// then doesn't depend on which albums are being evaluated around it, as long as the chain still fits under
/// [`MAX_ALBUM_DEPTH`] from where it is asked (checked on every use; otherwise the rules are evaluated as before).
pub(crate) struct AlbumPass {
outer: Option<std::collections::HashMap<crate::AlbumId, (usize, crate::tally::Members)>>,
}
impl AlbumPass {
pub(crate) fn begin() -> AlbumPass {
AlbumPass { outer: PASS.with_borrow_mut(|p| p.replace(std::collections::HashMap::new())) }
}
/// `id` holds exactly `members`, and its rules lead through a chain of `height` smart albums (itself included),
/// none of them in a loop.
pub(crate) fn remember(&self, id: crate::AlbumId, height: usize, members: crate::tally::Members) {
PASS.with_borrow_mut(|p| {
if let Some(p) = p {
p.insert(id, (height, members));
}
});
}
/// Ends the pass, giving back what was remembered.
pub(crate) fn finish(self) -> std::collections::HashMap<crate::AlbumId, crate::tally::Members> {
PASS.with_borrow_mut(Option::take).unwrap_or_default().into_iter().map(|(id, (_, members))| (id, members)).collect()
}
}
impl Drop for AlbumPass {
fn drop(&mut self) {
let outer = self.outer.take();
PASS.with_borrow_mut(|p| *p = outer);
}
}
/// What a counting pass knows of smart album `id` for `photo`, when asking now can't give another answer than
/// evaluating its rules would: the chain below it fits under [`MAX_ALBUM_DEPTH`] from the albums being evaluated.
fn pass_answer(id: crate::AlbumId, photo: crate::PhotoId) -> Option<bool> {
PASS.with_borrow(|p| {
let (height, members) = p.as_ref()?.get(&id)?;
let depth = EVALUATING.with_borrow(Vec::len);
(depth + height <= MAX_ALBUM_DEPTH).then(|| members.holds(photo))
})
}
#[cfg(test)]
thread_local! {
/// How many times a smart album's rules were worked out on this thread (tests count them).
pub(crate) static EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the
/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing).
pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool {
if let Some(known) = pass_answer(id, photo) {
return known;
}
if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) {
return known;
}
#[cfg(test)]
EVALUATED.with(|n| n.set(n.get() + 1));
// the outermost question forgets the answers when it ends, however it ends
struct Forget(bool);
impl Drop for Forget {
@@ -456,7 +523,7 @@ pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, match
}
/// How deep smart albums may test smart albums before the innermost counts as holding nothing.
const MAX_ALBUM_DEPTH: usize = 32;
pub(crate) const MAX_ALBUM_DEPTH: usize = 32;
/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id`
/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is
+91 -14
View File
@@ -8,7 +8,9 @@
use std::collections::{HashMap, HashSet};
use crate::{AlbumId, Aspects, Catalog, PhotoId};
use crate::query::Filter;
use crate::rules::{AlbumPass, MAX_ALBUM_DEPTH};
use crate::{Album, AlbumId, Aspects, Catalog, PhotoId};
/// The most photos an edit may touch for the counts to follow it photo by photo.
const MAX_FOLLOWED: usize = 256;
@@ -20,7 +22,7 @@ const WORDS_TO_START: usize = 1024;
/// The photos a smart album holds.
#[derive(Clone, Debug)]
enum Members {
pub(crate) enum Members {
/// One bit a photo id, and how many are set.
Bits {
words: Vec<u64>,
@@ -41,8 +43,7 @@ impl Members {
}
}
#[cfg(test)]
fn holds(&self, id: PhotoId) -> bool {
pub(crate) fn holds(&self, id: PhotoId) -> bool {
match self {
Members::Bits { words, .. } => {
usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0)
@@ -114,21 +115,46 @@ pub struct AlbumTally {
impl AlbumTally {
/// Counted from every photo.
///
/// A smart album that tests another (`album is #14`) reads that album's photos, counted once for the whole
/// pass, instead of working its rules out again for every photo: albums are counted shortest chain first.
/// The photos are kept only until this function returns, so they can't be older than the catalog counted.
/// An album whose rules lead into a loop, or through a chain too deep to be sure of, is counted by its rules
/// as before: the pass ends before the first of them (they sort last), so nothing they evaluate reads it.
pub fn of(cat: &Catalog) -> AlbumTally {
let heights = chain_heights(cat);
let height_of = |id: AlbumId| heights.get(&id).copied().flatten().filter(|h| *h <= MAX_ALBUM_DEPTH);
let mut smart: Vec<AlbumId> = cat.albums().filter(|a| a.is_smart()).map(|a| a.id).collect();
smart.sort_by_key(|id| height_of(*id).unwrap_or(usize::MAX));
let held_by = |id: AlbumId| {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(id, p)) {
held.set(p.id, true);
}
held
};
let mut counts = HashMap::new();
let mut members = HashMap::new();
for album in cat.albums() {
if album.is_smart() {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) {
held.set(p.id, true);
}
counts.insert(album.id, held.len());
members.insert(album.id, held);
} else {
counts.insert(album.id, cat.album_count(album.id));
let split = smart.partition_point(|id| height_of(*id).is_some());
let (with_height, without) = smart.split_at(split);
let pass = AlbumPass::begin();
for id in with_height {
let held = held_by(*id);
counts.insert(*id, held.len());
if let Some(height) = height_of(*id) {
pass.remember(*id, height, held);
}
}
members.extend(pass.finish());
for id in without {
let held = held_by(*id);
counts.insert(*id, held.len());
members.insert(*id, held);
}
for album in cat.albums().filter(|a| !a.is_smart()) {
counts.insert(album.id, cat.album_count(album.id));
}
AlbumTally { revision: cat.revision, counts, members }
}
@@ -184,6 +210,57 @@ impl AlbumTally {
}
}
/// For each smart album, the most smart albums in a chain its rules lead through (itself included), or `None`
/// when a loop is reachable from it (its answer may then depend on which albums are being evaluated around it).
fn chain_heights(cat: &Catalog) -> HashMap<AlbumId, Option<usize>> {
struct Frame {
id: AlbumId,
tests: Vec<AlbumId>,
next: usize,
tallest: usize,
loops: bool,
}
let tests = |id: AlbumId| -> Vec<AlbumId> {
let tested = cat.album(id).and_then(|a| a.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
tested.into_iter().filter(|t| cat.album(*t).is_some_and(Album::is_smart)).collect()
};
let frame = |id: AlbumId| Frame { id, tests: tests(id), next: 0, tallest: 0, loops: false };
let mut done: HashMap<AlbumId, Option<usize>> = HashMap::new();
let mut open: HashSet<AlbumId> = HashSet::new();
for root in cat.albums().filter(|a| a.is_smart()).map(|a| a.id) {
if done.contains_key(&root) {
continue;
}
open.insert(root);
let mut stack = vec![frame(root)];
while let Some(top) = stack.last_mut() {
if let Some(&kid) = top.tests.get(top.next) {
top.next += 1;
match done.get(&kid) {
Some(Some(h)) => top.tallest = top.tallest.max(*h),
Some(None) => top.loops = true,
None if open.contains(&kid) => top.loops = true,
None => {
open.insert(kid);
stack.push(frame(kid));
}
}
} else if let Some(finished) = stack.pop() {
open.remove(&finished.id);
let height = (!finished.loops).then_some(finished.tallest + 1);
done.insert(finished.id, height);
if let Some(parent) = stack.last_mut() {
match height {
Some(h) => parent.tallest = parent.tallest.max(h),
None => parent.loops = true,
}
}
}
}
}
done
}
#[cfg(test)]
mod tests {
use super::*;
+72 -4
View File
@@ -952,12 +952,13 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them()
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
assert_eq!(c.touched_since(loaded), Some(vec![a]));
assert_eq!(c.touched_since(old), None);
// an op that fails changes nothing, and what is remembered never says less than what happened
// an op that fails changes nothing and is not remembered
let before = c.revision;
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
assert_eq!(c.revision, before);
assert_eq!(c.touched_since(before), Some(vec![]));
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
assert_eq!(c.touched_since(before), Some(vec![a]));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
@@ -965,8 +966,8 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them()
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
// (the failed batch is not remembered: only the batch that worked)
assert_eq!(c.touched_since(before), Some(vec![a]));
let before = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
assert_eq!(c.touched_since(before), None);
@@ -978,3 +979,70 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them()
assert_eq!(c.touched_since(long_ago), None);
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
}
/// An op that fails leaves nothing behind in what the catalog remembers of its ops: no photo it never changed (one
/// that does not even exist), and, for a batch rolled back, none of what its first ops had touched.
#[test]
fn an_op_that_fails_leaves_no_touched_photos_behind() {
let mut c = Catalog::default();
let add = |c: &mut Catalog, name: &str| {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
})
.unwrap();
id
};
let (a, b) = (add(&mut c, "a.jpg"), add(&mut c, "b.jpg"));
let missing = PhotoId(9_999);
let start = c.revision;
// every op that names a photo, on one that does not exist
for op in [Op::SetRating { id: missing, rating: 1 }, Op::SetFlag { id: missing, flag: Flag::Pick }, Op::RemovePhoto { id: missing }] {
assert!(c.apply(op).is_err());
assert_eq!(c.revision, start, "a failed op makes no revision");
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
}
// an op that changes more than photos and fails does not make earlier revisions unknown
assert!(c.apply(Op::RenameAlbum { id: AlbumId(77), name: "x".into() }).is_err());
assert_eq!(c.touched_since(start), Some(vec![]));
// a batch that fails on its second op names nothing of its first, nor of the rollback
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: missing, rating: 1 }] }).is_err());
assert_eq!(c.revision, start);
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
assert_eq!(c.photo(a).map(|p| p.rating), Some(0));
// a batch that ends in a failed removal of a photo that exists
assert!(c.apply(Op::Batch { ops: vec![Op::RemovePhoto { id: b }, Op::RemovePhoto { id: b }] }).is_err());
assert!(c.photo(b).is_some());
assert_eq!(c.touched_since(start), Some(vec![]));
// what succeeded after the failures is remembered, and only that
c.apply(Op::SetRating { id: a, rating: 2 }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a]));
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
}
/// A batch the rollback of which fails halfway leaves the catalog changed (here a stack is lost), so what the
/// catalog remembers must not say that nothing changed: anything may have.
#[test]
fn a_batch_that_cannot_be_rolled_back_whole_makes_earlier_changes_unknown() {
let mut c = Catalog::default();
let mut ids = Vec::new();
for name in ["a.jpg", "b.jpg"] {
let id = c.alloc_photo_id();
let photo = Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
c.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
ids.push(id);
}
let stack = c.alloc_stack_id();
c.apply(Op::AddStack { stack: Stack { id: stack, photos: ids.clone(), collapsed: false } }).unwrap();
let start = c.revision;
// the photo goes, then its stack, then an op fails: putting the stack back needs the photo, which comes later
let failing =
Op::Batch { ops: vec![Op::RemovePhoto { id: ids[0] }, Op::RemoveStack { id: stack }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] };
assert!(c.apply(failing).is_err());
assert_eq!(c.revision, start + 1, "the catalog changed, so what is kept for the revision before is out of date");
assert!(c.photo(ids[0]).is_some() && c.stack(stack).is_none(), "the rollback went only part of the way");
assert_eq!(c.touched_since(start), None, "so nothing is said of what changed");
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
}
@@ -161,6 +161,63 @@ fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
}
#[test]
fn a_located_folder_keeps_what_the_library_knows_of_the_folders_already_at_the_destination() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
// other folders of the library at the place it is said to be at: nothing arrived there
label(&mut c, "/pics/home", Some(ColorLabel::Blue));
label(&mut c, "/pics/home/old", Some(ColorLabel::Yellow));
let before = c.to_snapshot();
let ops = c.folder_records_located("/pics/trip", "/pics/home");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes");
assert_eq!(c.folder_record("/pics/trip/day1"), None);
assert_eq!(c.folder_color_label("/pics/home"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place");
assert_eq!(c.folder_color_label("/pics/home/old"), Some(ColorLabel::Yellow), "a folder of the library there keeps its own");
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_located_folder_with_labels_inside_lands_them_at_the_same_places_below() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
let ops = c.folder_records_located("/pics/trip", "/mnt/nas/trip");
c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_color_label("/mnt/nas/trip"), Some(ColorLabel::Red));
assert_eq!(c.folder_color_label("/mnt/nas/trip/day1"), Some(ColorLabel::Green));
}
#[test]
fn a_folder_located_below_itself_leaves_the_records_of_the_folders_already_there_in_place() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
// another folder of the library already sits at the place the folder is said to be at
label(&mut c, "/pics/trip/day1/raw", Some(ColorLabel::Blue));
let before = c.to_snapshot();
let ops = c.folder_records_located("/pics/trip", "/pics/trip/day1");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes");
assert_eq!(c.folder_color_label("/pics/trip/day1"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place");
assert_eq!(c.folder_color_label("/pics/trip/day1/raw"), Some(ColorLabel::Blue), "a folder below the destination keeps its own");
assert_eq!(c.folder_record("/pics/trip/day1/day1"), None, "the record at the destination is not carried below itself");
assert_eq!(c.folder_record("/pics/trip/day1/day1/raw"), None);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_folder_located_where_it_is_or_without_labels_gives_no_ops() {
let mut c = library();
assert!(c.folder_records_located("/pics/trip", "/elsewhere/trip").is_empty());
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
assert!(c.folder_records_located("/pics/trip", "/pics/trip").is_empty());
}
#[test]
fn labels_survive_the_journal_without_a_snapshot() {
let m = MemStore::new();
+49
View File
@@ -1,5 +1,6 @@
//! Forgetting untouched Local browse records (see [`crate::local`]).
use std::collections::HashSet;
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
@@ -265,3 +266,51 @@ fn op_photo_ids_cover_nested_ops() {
op.photo_ids(&mut |id| ids.push(id.0));
assert_eq!(ids, vec![1, 2, 3, 4]);
}
#[test]
fn drive_and_share_root_records_are_forgotten() {
for (path, folder) in [("C:\\IMG_1.JPG", "C:\\"), ("//srv/share/x.jpg", "//srv/share/")] {
let mut c = Catalog::new();
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, OLD);
p.local = true;
p.set_local_baseline();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
c.apply(Op::SetBrowsed { folder: folder.into(), at: Some(OLD.into()) }).unwrap();
assert_eq!(plan(&c, 30).evict, vec![id], "{path}");
}
}
fn paths(folder: &str, ns: &[u32]) -> HashSet<String> {
ns.iter().map(|i| format!("/pics/{folder}/IMG_{i}.JPG")).collect()
}
#[test]
fn local_records_of_files_tell_what_can_give_way_from_what_holds_something() {
let mut c = Catalog::new();
let untouched = local(&mut c, "trip", 1);
let rated = local(&mut c, "trip", 2);
let in_album = local(&mut c, "trip", 3);
let _elsewhere = local(&mut c, "trip", 4);
c.apply(Op::SetRating { id: rated, rating: 3 }).unwrap();
let a = c.alloc_album_id();
let mut album = Album::new(a, "A");
album.photos.push(in_album);
c.apply(Op::AddAlbum { album }).unwrap();
let found = c.local_records_of(&paths("trip", &[1, 2, 3]));
assert_eq!(found.untouched, vec![untouched]);
assert_eq!(found.kept, vec![(rated, "/pics/trip/IMG_2.JPG".to_string()), (in_album, "/pics/trip/IMG_3.JPG".to_string())]);
}
#[test]
fn local_records_of_files_leave_out_library_photos_and_recently_deleted_ones() {
let mut c = Catalog::new();
let gone = local(&mut c, "trip", 1);
c.apply(Op::SetDeleted { id: gone, deleted: true }).unwrap();
let id = c.alloc_photo_id();
let p = Photo::new(id, Source::File { path: "/pics/trip/IMG_2.JPG".into() }, "IMG_2.JPG", "JPEG", 60, 40, OLD);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
assert_eq!(c.local_records_of(&paths("trip", &[1, 2])), LocalRecords::default());
assert_eq!(c.local_records_of(&HashSet::new()), LocalRecords::default());
}
+172
View File
@@ -0,0 +1,172 @@
//! A whole count of every album (`AlbumTally::of`) keeps each smart album's photos for the pass, so an album testing
//! another doesn't work its rules out again. The counts must be the ones each album gets counted alone.
use serde_json::json;
use super::*;
use crate::tally::AlbumTally;
fn smart(id: u64, rules: serde_json::Value) -> Album {
let rule_set = Some(serde_json::from_value(rules).unwrap());
Album { smart: Some(Box::new(Filter { rule_set, ..Default::default() })), ..Album::new(AlbumId(id), format!("Smart {id}")) }
}
fn photos(n: u64) -> Vec<Op> {
(0..n)
.map(|i| {
// folders with capitals, accents and a Windows path, so the text rules see every spelling
let dir = ["Asian/Japanese", "Été/Ünï", "C:\\Users\\Me\\Pictures", "plain", "[SITES]/x"][(i % 5) as usize];
let mut p = Photo::new(
PhotoId(i + 1),
Source::File { path: format!("/Volumes/share/photos/{dir}/name{}/IMG_{i:04}.jpg", i % 9) },
&format!("IMG_{i:04}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.rating = (i % 6) as u8;
p.flag = [Flag::None, Flag::Pick, Flag::Reject][(i % 3) as usize];
p.label = (i % 4 == 0).then_some(ColorLabel::Red);
p.meta.keywords = (0..i % 4).map(|k| format!("kw{k}")).collect();
p.deleted = i % 37 == 0;
Op::AddPhoto { photo: Box::new(p) }
})
.collect()
}
fn is(album: u64) -> serde_json::Value {
json!({"field": "album", "op": "is", "value": album})
}
fn is_not(album: u64) -> serde_json::Value {
json!({"field": "album", "op": "isNot", "value": album})
}
fn path(op: &str, value: &str) -> serde_json::Value {
json!({"field": "filePath", "op": op, "value": value})
}
/// Every album's count in `c`, each album counted alone (no pass, rules worked out per photo).
fn counted_alone(c: &Catalog) -> Vec<(AlbumId, usize)> {
let mut v: Vec<_> = c.albums().map(|a| (a.id, c.album_count(a.id))).collect();
v.sort_unstable();
v
}
fn counted_together(c: &Catalog) -> Vec<(AlbumId, usize)> {
let tally = AlbumTally::of(c);
let mut v: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect();
v.sort_unstable();
v
}
#[test]
fn a_whole_count_equals_each_album_counted_alone() {
let mut c = Catalog::new();
let mut ops = photos(240);
let mut plain = Album::new(AlbumId(1), "Picks");
plain.photos = (0..240).step_by(4).map(|i| PhotoId(i + 1)).collect();
let folder = Album { folder: true, ..Album::new(AlbumId(2), "Trips") };
let inside = Album { parent: Some(AlbumId(2)), photos: (0..240).step_by(3).map(|i| PhotoId(i + 1)).collect(), ..Album::new(AlbumId(3), "In") };
ops.extend([plain, folder, inside].map(|album| Op::AddAlbum { album }));
// the inner albums: #2-like, #14-like, then albums chained on it, the other way round, in groups and in different orders
ops.push(Op::AddAlbum {
album: smart(
10,
json!({"match": "any", "rules": [{"field": "flag", "op": "is", "value": "reject"}, {"field": "label", "op": "is", "value": "red"}]}),
),
});
ops.push(Op::AddAlbum {
album: smart(11, json!({"match": "all", "rules": [is_not(10), path("notContains", "Été"), path("notContains", "plain")]})),
});
for k in 0..9u64 {
let tail = path("contains", &format!("NAME{k}"));
let rules = match k % 3 {
0 => json!({"match": "all", "rules": [is(11), tail]}),
1 => json!({"match": "all", "rules": [json!({"field": "keywordCount", "op": "lte", "value": 2}), is(11), tail]}),
_ => json!({"match": "all", "rules": [tail, is(11)]}),
};
ops.push(Op::AddAlbum { album: smart(12 + k, rules) });
}
ops.push(Op::AddAlbum {
album: smart(
21,
json!({"match": "all", "rules": [{"group": {"match": "any", "rules": [is(11), is(1)]}}, {"field": "rating", "op": "gte", "value": 2}]}),
),
});
ops.push(Op::AddAlbum { album: smart(22, json!({"match": "all", "rules": [is_not(21), is(3)]})) });
ops.push(Op::AddAlbum { album: smart(23, json!({"match": "any", "rules": [is(2), is(999), is(1)]})) });
// a loop saved anyway (30 ↔ 31), albums that test it from outside (32, 33), and one that tests both worlds
ops.push(Op::AddAlbum { album: smart(30, json!({"rules": []})) });
ops.push(Op::AddAlbum { album: smart(31, json!({"rules": [is_not(30)]})) });
ops.push(Op::AddAlbum { album: smart(32, json!({"rules": [is(31), {"field": "rating", "op": "gte", "value": 1}]})) });
ops.push(Op::AddAlbum { album: smart(33, json!({"rules": [is_not(32), is(11)]})) });
ops.push(Op::AddAlbum { album: smart(34, json!({"rules": [is(34)]})) });
// a chain deeper than the limit on how deep albums may test albums
ops.push(Op::AddAlbum { album: smart(100, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 101..=140u64 {
ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k - 1)]})) });
}
ops.push(Op::AddAlbum { album: smart(141, json!({"rules": [is(140), is_not(11)]})) });
// an album past the depth limit that also tests a memoized album directly: where the shared album is read from the
// pass near the top, the chain below must still find it answered as it always did (200 ← 231 … 201, height 32)
ops.push(Op::AddAlbum { album: smart(200, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 201..=230u64 {
ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k + 1)]})) });
}
ops.push(Op::AddAlbum { album: smart(231, json!({"rules": [is(200)]})) });
ops.push(Op::AddAlbum { album: smart(250, json!({"match": "all", "rules": [is(200), is(201)]})) });
c.apply(Op::Batch { ops }).unwrap();
c.apply(Op::SetAlbumRules {
id: AlbumId(30),
rules: Box::new(Filter { rule_set: Some(serde_json::from_value(json!({"rules": [is_not(31)]})).unwrap()), ..Default::default() }),
})
.unwrap();
let alone = counted_alone(&c);
let count = |id: u64| alone.iter().find(|(a, _)| a.0 == id).map(|(_, n)| *n);
assert!(count(11) > Some(0) && count(131) > Some(0), "{alone:?}");
assert_eq!(count(132), Some(0), "the chain is too deep from here: holds nothing, as it did");
assert!(count(250) > Some(0), "200 is answered near the top, so the chain finds it answered");
assert_eq!(counted_together(&c), alone);
// the pass leaves nothing behind: the same answers whichever album asks first, and again after an edit
c.apply(Op::SetAlbumRules {
id: AlbumId(10),
rules: Box::new(Filter {
rule_set: Some(serde_json::from_value(json!({"rules": [{"field": "rating", "op": "is", "value": 5}]})).unwrap()),
..Default::default()
}),
})
.unwrap();
let after = counted_alone(&c);
assert_ne!(after, alone);
assert_eq!(counted_together(&c), after);
}
/// The reason for the pass: with an album tested by many, its rules are worked out once per photo, not once per
/// photo for each album that tests it.
#[test]
fn an_album_tested_by_many_is_worked_out_once_a_photo() {
let mut c = Catalog::new();
let mut ops: Vec<Op> = (0..60u64)
.map(|i| {
let mut p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-01-01T00:00:00");
p.rating = (i % 6) as u8;
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
ops.push(Op::AddAlbum { album: smart(1, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 0..10u64 {
ops.push(Op::AddAlbum { album: smart(2 + k, json!({"rules": [is(1), {"field": "rating", "op": "lte", "value": k % 6}]})) });
}
c.apply(Op::Batch { ops }).unwrap();
let alone = counted_alone(&c);
crate::rules::EVALUATED.with(|n| n.set(0));
let tally = AlbumTally::of(&c);
let worked = crate::rules::EVALUATED.with(std::cell::Cell::get);
assert_eq!(worked, 11 * 60, "each of the 11 smart albums asks each of the 60 photos once");
let mut together: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect();
together.sort_unstable();
assert_eq!(together, alone);
}
+108 -8
View File
@@ -17,6 +17,8 @@ pub(crate) struct Markers {
pub sof: u8,
/// Number of components in the first scan (< `components` for non-interleaved sequential files).
pub first_scan_components: u8,
/// Number of APP1 segments: capped before the allocating decoders walk them again.
pub app1: u32,
pub exif: Option<Vec<u8>>,
/// Offset of the EXIF TIFF header within the file.
pub exif_offset: usize,
@@ -83,6 +85,7 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
m.components = seg[5];
}
0xE1 => {
m.app1 = m.app1.saturating_add(1);
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
m.exif = Some(seg[6..].to_vec());
m.exif_offset = start + 6;
@@ -121,6 +124,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
decode_with_fallback(bytes, opts, None)
}
const MAX_APP1: u32 = 128;
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
/// type or component count, or when its markers don't run to an end-of-image marker after the
@@ -178,6 +183,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, opts)?;
// The allocating decoders below parse the markers again, all of them: the count is capped so
// their quadratic/unbounded handling of APP1 metadata stays linear in file size.
if m.app1 > MAX_APP1 {
return Err(Error::Malformed(F, "too many APP1 markers".into()));
}
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling)
@@ -185,9 +195,9 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
let lossless = m.sof == 0xC3;
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless {
decode_jpeg_decoder(bytes, &m, scale_to)?
decode_jpeg_decoder(bytes, &m, scale_to, opts)?
} else {
decode_zune(bytes, &m)?
decode_zune(bytes, &m, opts)?
};
// A raw container may carry the colour declaration of an otherwise untagged preview.
// Apply it before linearization/resizing; existing JPEG metadata always wins.
@@ -196,7 +206,7 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
finish(F, raw, meta, (m.width, m.height), opts)
}
fn decode_zune(bytes: &[u8], m: &Markers) -> Result<Raw> {
fn decode_zune(bytes: &[u8], m: &Markers, budget: &DecodeOptions) -> Result<Raw> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
@@ -207,6 +217,11 @@ fn decode_zune(bytes: &[u8], m: &Markers) -> Result<Raw> {
ColorSpace::RGB
});
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts);
// zune's `decode` allocates straight from the frame header it parses, with its own caps
// set in us: check that header against the same limits the other decoders honour first.
d.decode_headers().map_err(|e| Error::Malformed(F, e.to_string()))?;
let (zw, zh) = d.info().map(|i| (i.width as u64, i.height as u64)).ok_or_else(|| Error::Malformed(F, "no info".into()))?;
check_size(F, zw, zh, budget)?;
let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?;
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
@@ -217,10 +232,15 @@ fn decode_zune(bytes: &[u8], m: &Markers) -> Result<Raw> {
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf: Buf::U8(px), bit_depth: 8 })
}
fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) -> Result<Raw> {
fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>, budget: &DecodeOptions) -> Result<Raw> {
use jpeg_decoder::PixelFormat;
let mut d = jpeg_decoder::Decoder::new(bytes);
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
// Its frame header is what a decode allocates from (progressive coefficient buffers follow the
// unscaled MCU grid): check it against the same limits the other decoders honour, before any
// DCT scale is requested.
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
check_size(F, info.width as u64, info.height as u64, budget)?;
// `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the
// output to the top-left of each plane. Those decode at full size; `finish` scales after.
if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) {
@@ -369,11 +389,18 @@ pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec<u8
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if m.app1 > MAX_APP1 {
return Err(Error::Malformed(F, "too many APP1 markers".into()));
}
if !matches!(m.components, 1 | 3) || m.precision != 8 {
return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
}
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
let raw = if non_interleaved {
decode_jpeg_decoder(bytes, &m, None, &DecodeOptions::default())?
} else {
decode_zune(bytes, &m, &DecodeOptions::default())?
};
let ch = raw.model.channels();
match raw.buf {
Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
@@ -513,7 +540,7 @@ mod tests {
fn lossless_grayscale_samples_are_native_endian_and_left_justified() {
let bytes = lossless_jpeg(1, 8, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified");
assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16));
@@ -525,7 +552,7 @@ mod tests {
fn lossless_high_precision_rgb_is_16_bit() {
let bytes = lossless_jpeg(3, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 6]);
assert_eq!(raw.model, Model::Rgb);
@@ -536,7 +563,7 @@ mod tests {
fn lossless_high_precision_cmyk_is_16_bit() {
let bytes = lossless_jpeg(4, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8]);
assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16));
@@ -551,4 +578,77 @@ mod tests {
assert_eq!((d.width, d.height), (8, 1));
assert_eq!(d.bit_depth, 8);
}
/// Rewrites an encoded file's frame-header dimensions: same tables and scan, a hostile
/// annotated size.
fn with_dims(bytes: &[u8], w: u16, h: u16) -> Vec<u8> {
let mut out = bytes.to_vec();
let mut p = 2usize;
while p + 1 < out.len() {
if out[p] != 0xFF {
p += 1;
continue;
}
match out[p + 1] {
0xC0..=0xCF if out[p + 1] != 0xC4 && out[p + 1] != 0xC8 && out[p + 1] != 0xCC => {
out[p + 5..p + 7].copy_from_slice(&h.to_be_bytes());
out[p + 7..p + 9].copy_from_slice(&w.to_be_bytes());
return out;
}
0x01 | 0xD0..=0xD9 => p += 2,
0xFF => p += 1,
_ => {
let Some(l) = out.get(p + 2..p + 4) else { return out };
p += 2 + usize::from(u16::from_be_bytes([l[0], l[1]]));
}
}
}
out
}
/// A file claiming 65535×65535 must be refused for size before zune-jpeg allocates from
/// its frame header (4 GB+ of samples from roughly five hundred bytes of tables).
#[test]
fn zune_refuses_its_own_frame_header_over_the_budget() {
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = vec![128u8; 32 * 24];
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 1, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let hostile = with_dims(&jpeg, 65535, 65535);
assert_ne!(hostile, jpeg);
match crate::decode(&hostile, DecodeOptions::default()) {
Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)),
other => panic!("unexpected: {other:?}"),
}
}
/// The jpeg-decoder branch (a 4-component file) is checked the same way: its own
/// `read_info` result is what a full-size decode allocates from.
#[test]
fn jpeg_decoder_refuses_its_own_frame_header_over_the_budget() {
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = vec![128u8; 32 * 24 * 4];
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 4, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let hostile = with_dims(&jpeg, 65535, 65535);
match crate::decode(&hostile, DecodeOptions::default()) {
Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)),
other => panic!("unexpected: {other:?}"),
}
}
/// Extended XMP is reassembled from every APP1 fragment: an unbounded count is quadratic
/// memory in a marker walk, so the count is capped.
#[test]
fn an_app1_flood_is_refused_before_the_decoders_rewalk_it() {
let mut b = vec![0xFF, 0xD8];
// a 1-component SOF0: 8-bit, 2×2, component (1, 0x11, Q0)
b.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x0B, 8, 0, 2, 0, 2, 1, 0x01, 0x11, 0x00]);
for _ in 0..200 {
b.extend_from_slice(&[0xFF, 0xE1, 0x00, 0x02]);
}
b.extend_from_slice(&[0xFF, 0xD9]);
match crate::decode(&b, DecodeOptions::default()) {
Err(Error::Malformed(F, m)) => assert!(m.contains("APP1"), "{m}"),
other => panic!("unexpected: {other:?}"),
}
}
}
+83
View File
@@ -5,6 +5,9 @@ use crate::{DecodeOptions, Decoded, Error, Format, Result};
use image::{DynamicImage, ImageDecoder};
pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
if format == Format::Gif {
gated_first_frame(bytes, opts)?;
}
let err = |e: image::ImageError| Error::Malformed(format, e.to_string());
let cursor = std::io::Cursor::new(bytes);
let (img, icc) = match format {
@@ -36,3 +39,83 @@ pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Resu
let raw = Raw { width: w as usize, height: h as usize, model, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(format, raw, Meta { icc, ..Default::default() }, (w, h), opts)
}
/// Bounds a GIF's first image descriptor: the decoder composites frames into the logical screen
/// (bounded by [`check_size`] on the canvas) but allocates its own frame buffers at the frame's
/// declared size, which a small file can inflate far past the canvas. The canvas check cannot
/// see that size, so it is read from the block headers here.
fn gated_first_frame(bytes: &[u8], opts: &DecodeOptions) -> Result<()> {
// The constants of the header grammar: signature, logical screen descriptor (with its global
// colour table), extension blocks (0x21, skipped by their sub-block chains) and image
// descriptors (0x2C).
const SCREEN: usize = 13;
if bytes.len() < SCREEN {
return Ok(()); // too broken for a frame; the decoder reports the damage itself
}
let gct_entries = 1usize << (u32::from(bytes[10] & 0x07) + 1); // at most 256
let gct_bytes = if (bytes[10] & 0x80) != 0 { gct_entries * 3 } else { 0 };
let mut p = SCREEN.saturating_add(gct_bytes);
while let Some(&kind) = bytes.get(p) {
match kind {
0x2C => {
// Image descriptor: two LE coordinates then two LE edges, after the separator.
let end = p.checked_add(10).filter(|&e| e <= bytes.len());
let Some(d) = end.and_then(|e| bytes.get(p + 1..e)) else {
break; // cut short mid-descriptor: the decoder reports that
};
let (w, h) = (u16::from_le_bytes([d[4], d[5]]) as u64, u16::from_le_bytes([d[6], d[7]]) as u64);
return check_size(Format::Gif, w, h, opts);
}
0x21 => {
// Extension: a label byte, then <len,data> sub-blocks ended by a zero length.
p = p.saturating_add(2);
loop {
let Some(&len) = bytes.get(p) else {
return Ok(()); // ran past the data: decode reports the truncation
};
p = p.saturating_add(1);
if len == 0 {
break;
}
p = p.saturating_add(len as usize);
}
}
_ => p = p.saturating_add(1),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::{DecodeOptions, Error};
fn gif(blocks: &[u8]) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
// logical screen: 2×2, no global colour table, background 0, aspect 0
b.extend_from_slice(&[2, 0, 2, 0, 0x00, 0, 0]);
b.extend_from_slice(blocks);
b
}
/// `image`'s GIF decoder allocates its frame buffer at the image descriptor's declared
/// size, which the canvas bound ([`crate::decode`]'s `max_pixels` on the logical screen)
/// does not cover. A 20-byte file used to buy a 4 GB allocation before the gate.
#[test]
fn a_first_frame_over_the_bound_is_refused_without_allocating_it() {
let bytes = gif(&[0x2C, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0x00]);
match crate::decode(&bytes, DecodeOptions::default()) {
Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)),
other => panic!("unexpected: {other:?}"),
}
}
/// A frame within the bound still reaches the decoder (which reports the truncated data);
/// the gate may only refuse on size.
#[test]
fn a_first_frame_within_the_bound_is_not_refused() {
let bytes = gif(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0x00]);
let r = crate::decode(&bytes, DecodeOptions::default());
assert!(matches!(r, Err(Error::Malformed(..)) | Err(Error::TooLarge(..))), "{r:?}");
}
}
+64 -1
View File
@@ -7,6 +7,10 @@ use lightcraft_color::{Mat3, RgbSpace, Xy};
const F: Format = Format::Png;
/// Expansion cap for a compressed text chunk's payload: real XMP documents are a few hundred
/// kilobytes, so anything past this is hostile input, not metadata.
const MAX_TEXT_CHUNK: usize = 4 << 20;
fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
@@ -42,11 +46,18 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
};
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
// png's `get_text()` inflates a compressed chunk with no bound of its own: a tiny
// iTXt can claim an XMP document of arbitrary size. Expand through the bounded path
// instead, and drop any XMP bigger than the cap.
let xmp = info
.utf8_text
.iter()
.find(|t| t.keyword == "XML:com.adobe.xmp")
.and_then(|t| t.get_text().ok())
.and_then(|t| {
let mut t = t.clone();
t.decompress_text_with_limit(MAX_TEXT_CHUNK).ok()?;
t.get_text().ok()
})
.or_else(|| info.uncompressed_latin1_text.iter().find(|t| t.keyword == "XML:com.adobe.xmp").map(|t| t.text.clone()));
let exif = info.exif_metadata.as_ref().map(|e| {
let e = e.to_vec();
@@ -168,3 +179,55 @@ fn rgb_to_xyz_d50_checked(space: &RgbSpace) -> Option<Mat3> {
}
Some(crate::space::rgb_to_xyz_d50(space))
}
#[cfg(test)]
mod tests {
use crate::DecodeOptions;
/// A compressed text chunk is expanded by `png` through our own cap: an XMP document
/// bigger than it is dropped, not inflated. Compressing an 8 MiB string is a few
/// kilobytes — the reverse of what the unbounded `get_text()` path allowed.
#[test]
fn compressed_xmp_over_the_cap_is_dropped() {
let out = png_with_compressed_xmp(&"x".repeat(8 << 20));
let d = crate::decode(&out, DecodeOptions::default()).unwrap();
assert!(d.xmp.is_none());
assert_eq!((d.width, d.height), (1, 1));
}
/// XMP within the cap is still read, compressed or not.
#[test]
fn compressed_xmp_under_the_cap_is_read() {
let out = png_with_compressed_xmp(&"<?xpacket?><xmp/>".repeat(100));
let d = crate::decode(&out, DecodeOptions::default()).unwrap();
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("xmp/")));
}
/// A little-endian PNG with one compressed iTXt chunk carrying `payload` as XMP.
fn png_with_compressed_xmp(payload: &str) -> Vec<u8> {
use png::text_metadata::{EncodableTextChunk, ITXtChunk};
let mut base = Vec::new();
{
let mut enc = png::Encoder::new(&mut base, 1, 1);
enc.set_color(png::ColorType::Grayscale);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0x7F]).unwrap();
w.finish().unwrap();
}
// `ITXtChunk::encode` writes the complete chunk (length, type, payload, CRC), with
// the text compressed.
let mut chunk = Vec::new();
let mut xmp = ITXtChunk::new("XML:com.adobe.xmp", payload);
xmp.compressed = true;
xmp.compress_text().unwrap();
xmp.encode(&mut chunk).unwrap();
// Text chunks after IDAT land in `info` once the stream is finished: insert ours
// before the trailing IEND (a zero-length chunk is exactly 12 bytes).
let split = base.len() - 12;
let mut out = base[..split].to_vec();
out.extend_from_slice(&chunk);
out.extend_from_slice(&base[split..]);
out
}
}
+347 -28
View File
@@ -7,6 +7,16 @@
//! there"; the render worker finds out if it isn't), known answers are rechecked after
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
//! fresh check, as before.
//!
//! The same worker can check a list of files, such as every library photo's, now and then
//! ([`Availability::sweep`]), one at a time and only while nothing is asked. That is for the
//! Missing Photos count ([`Availability::is_gone`]): one stream of checks on a share, not one per
//! thing that wants to know. The app starts the sweeps; nothing here does.
//!
//! A sweep of a large library on a share takes tens of minutes, and its `stat`s compete with the
//! reads of an import or a scan on the same share. So the worker starts no sweep check while the
//! caller says files are busy ([`Availability::sweep_yields_to`]); what is asked still gets
//! answered (within [`YIELD_POLL`]), and the sweep goes on where it stopped when the work ends.
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
@@ -14,9 +24,16 @@ use std::time::Duration;
/// Checks whether a path exists (injectable: tests use a slow one).
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
/// Called (from the worker) when an answer changed, e.g. to repaint the UI (a sweep's first look
/// at a file that is there changes no answer anyone sees, so it wakes nobody).
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// Whether file work that a sweep should not compete with is going on.
pub type Busy = Arc<dyn Fn() -> bool + Send + Sync>;
/// How often a sweep that is giving way looks whether the work has ended.
const YIELD_POLL: Duration = Duration::from_millis(250);
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
pub const RECHECK: Duration = Duration::from_secs(15);
@@ -31,9 +48,17 @@ struct State {
known: HashMap<String, (bool, std::time::Instant)>,
queue: VecDeque<String>,
queued: HashSet<String>,
/// What a sweep has left to check: after the queue, whatever it holds.
sweep: VecDeque<String>,
/// The files found not there (and not found there since).
gone: HashSet<String>,
worker: bool,
/// Bumped whenever an answer changes (callers recompute what depends on it).
generation: u64,
/// Bumped when the sweep under way is dropped for another one ([`Availability::restart_sweeps`]).
sweep_epoch: u64,
/// Whether file work a sweep gives way to is going on.
busy: Option<Busy>,
/// Bumped whenever a file is found gone, or found back.
gone_generation: u64,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
@@ -66,7 +91,8 @@ impl Availability {
self.probe = probe;
let mut st = lock(&self.state);
st.known.clear();
st.generation += 1;
st.gone.clear();
st.gone_generation += 1;
}
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
@@ -78,6 +104,14 @@ impl Availability {
self.background = Some(wake);
}
/// The sweep starts no check while `busy` says so (the caller's import, scan or Local read in
/// flight); asked files are answered all the same, and the sweep resumes where it was left when
/// `busy` turns false. `busy` should read state that a task's guard owns (the activity stack), so
/// a task that never ends cleanly can't hold the sweep for good.
pub fn sweep_yields_to(&self, busy: Busy) {
lock(&self.state).busy = Some(busy);
}
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
pub fn probe(&self) -> ExistsProbe {
self.probe.clone()
@@ -99,12 +133,7 @@ impl Availability {
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
st.queued.insert(path.to_string());
st.queue.push_back(path.to_string());
if !st.worker {
st.worker = true;
drop(st);
self.spawn_worker(wake.clone());
return cached.map(|c| c.0);
}
self.start(st, wake);
}
cached.map(|c| c.0)
}
@@ -114,9 +143,10 @@ impl Availability {
self.exists(path) == Some(false)
}
/// Changes whenever a cached answer changes.
pub fn generation(&self) -> u64 {
lock(&self.state).generation
/// Changes whenever a file is found gone or found back: what [`Availability::is_offline`]
/// says changed for some file. (A file found there at its first check changes nothing.)
pub fn gone_generation(&self) -> u64 {
lock(&self.state).gone_generation
}
/// Paths waiting for the worker.
@@ -124,37 +154,123 @@ impl Availability {
lock(&self.state).queue.len()
}
/// Check `paths` too, in the background and after whatever is asked meanwhile, skipping those
/// checked less than [`RECHECK`] ago. One sweep at a time: `false` (and nothing queued) while
/// one is under way, or in synchronous mode. A sweep's answers wake only when a file is found
/// gone or back.
pub fn sweep(&self, paths: Vec<String>) -> bool {
self.sweep_checked(None, paths)
}
fn sweep_checked(&self, epoch: Option<u64>, paths: Vec<String>) -> bool {
let Some(wake) = &self.background else { return false };
let mut st = lock(&self.state);
if !st.sweep.is_empty() || epoch.is_some_and(|e| e != st.sweep_epoch) {
return false;
}
st.sweep = paths.into();
self.start(st, wake);
true
}
/// Drop what the sweep under way has left (another library was opened: its files are not the
/// ones to check now) and say which sweeps may start from here ([`Availability::sweep_in`]): a
/// list of files being made for the old one is then refused.
pub fn restart_sweeps(&self) -> u64 {
let mut st = lock(&self.state);
st.sweep.clear();
st.sweep_epoch += 1;
st.sweep_epoch
}
/// [`Availability::sweep`], unless [`Availability::restart_sweeps`] was called since it
/// returned `epoch`.
pub fn sweep_in(&self, epoch: u64, paths: Vec<String>) -> bool {
self.sweep_checked(Some(epoch), paths)
}
/// Whether the file at `path` was found gone (and not found there since): `false` for a file
/// not checked yet.
pub fn is_gone(&self, path: &str) -> bool {
lock(&self.state).gone.contains(path)
}
/// How many files are found gone.
pub fn gone_len(&self) -> usize {
lock(&self.state).gone.len()
}
/// What the sweep under way has left to check (0: none under way, though its last file may still
/// be in flight).
pub fn sweeping(&self) -> usize {
lock(&self.state).sweep.len()
}
/// Start the worker, unless it runs.
fn start(&self, mut st: MutexGuard<'_, State>, wake: &Waker) {
if !st.worker {
st.worker = true;
drop(st);
self.spawn_worker(wake.clone());
}
}
fn spawn_worker(&self, wake: Waker) {
let (state, probe) = (self.state.clone(), self.probe.clone());
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
loop {
let path = {
// what was asked first; a sweep's next file when nothing is (unless it is asked
// about too, or was checked a moment ago)
let (path, asked) = {
let mut st = lock(&state);
let Some(p) = st.queue.pop_front() else {
st.worker = false;
return;
};
p
let giving_way = if st.queue.is_empty() && !st.sweep.is_empty() { st.busy.clone() } else { None };
if let Some(busy) = giving_way {
drop(st);
if busy() {
// (the worker stays: a question asked meanwhile is answered within YIELD_POLL)
std::thread::sleep(YIELD_POLL);
continue;
}
st = lock(&state);
}
match st.queue.pop_front() {
Some(p) => (p, true),
None => loop {
let Some(p) = st.sweep.pop_front() else {
st.worker = false;
return;
};
let fresh = st.known.get(&p).is_some_and(|(_, at)| at.elapsed() < RECHECK);
if !fresh && !st.queued.contains(&p) {
break (p, false);
}
},
}
};
let ok = probe(&path);
let changed = {
let wakes = {
let mut st = lock(&state);
st.queued.remove(&path);
if asked {
st.queued.remove(&path);
}
let gone_changed = if ok { st.gone.remove(&path) } else { st.gone.insert(path.clone()) };
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
let changed = prev.map(|p| p.0) != Some(ok);
if changed {
st.generation += 1;
if gone_changed {
st.gone_generation += 1;
}
changed
(asked && changed) || gone_changed
};
if changed {
if wakes {
wake();
}
}
});
if started.is_err() {
// try again on the next question
lock(&self.state).worker = false;
// try again on the next question; a sweep nobody works on would refuse every new one
let mut st = lock(&self.state);
st.worker = false;
st.sweep.clear();
}
}
}
@@ -210,6 +326,209 @@ mod tests {
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
assert!(woke.load(Ordering::SeqCst) >= 2);
assert!(a.generation() >= 2);
assert!(a.gone_generation() >= 2);
}
fn wait_until(what: &str, done: impl Fn() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while !done() {
assert!(std::time::Instant::now() < deadline, "timed out waiting: {what}");
std::thread::sleep(Duration::from_millis(2));
}
}
fn held<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A background `Availability` whose probe logs what it checks; files named `gone…` are not there.
fn logged() -> (Availability, Arc<Mutex<Vec<String>>>, Arc<AtomicUsize>) {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::new()));
let l = log.clone();
a.set_probe(Arc::new(move |p| {
held(&l).push(p.to_string());
!p.starts_with("gone")
}));
let woke = Arc::new(AtomicUsize::new(0));
let w = woke.clone();
a.run_in_background(Arc::new(move || {
w.fetch_add(1, Ordering::SeqCst);
}));
(a, log, woke)
}
#[test]
fn the_files_a_sweep_finds_gone_are_counted() {
let (a, _, _) = logged();
assert!(a.sweep(vec!["here".into(), "gone 1".into(), "also here".into(), "gone 2".into()]));
wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0);
wait_until("the answers", || a.gone_len() == 2);
assert!(a.is_gone("gone 1") && a.is_gone("gone 2") && !a.is_gone("here") && !a.is_gone("also here"));
}
#[test]
fn files_found_there_at_first_look_change_nothing_for_missing_photos() {
let (a, _, woke) = logged();
let before = a.gone_generation();
a.sweep(vec!["a".into(), "b".into(), "c".into()]);
wait_until("the answers", || ["a", "b", "c"].iter().all(|p| lock(&a.state).known.contains_key(*p)));
assert_eq!(a.gone_generation(), before, "nothing went missing");
assert_eq!(woke.load(Ordering::SeqCst), 0, "a sweep finding files there wakes nobody");
a.sweep(vec!["gone".into()]);
wait_until("the gone file", || a.gone_generation() != before);
wait_until("the wake", || woke.load(Ordering::SeqCst) == 1);
}
#[test]
fn what_is_asked_is_checked_before_the_rest_of_a_sweep_and_one_sweep_runs_at_a_time() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
// the first file of the sweep holds the worker until the test has asked about another
let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
let (l, g) = (log.clone(), gate.clone());
a.set_probe(Arc::new(move |p| {
if p == "sweep 0" {
let (open, cv) = &*g;
let mut open = held(open);
while !*open {
open = cv.wait(open).unwrap_or_else(PoisonError::into_inner);
}
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
assert!(a.sweep((0..50).map(|i| format!("sweep {i}")).collect()));
assert!(!a.sweep(vec!["another".into()]), "a sweep under way: the next one waits for its turn");
assert_eq!(a.exists("asked"), None, "not known yet");
*held(&gate.0) = true;
gate.1.notify_all();
wait_until("the log", || held(&log).len() == 51);
let log = held(&log);
// (at most the file the worker was already checking comes first)
assert!(matches!(log.iter().position(|p| p == "asked"), Some(0 | 1)), "asked next, ahead of the sweep: {log:?}");
assert!(!log.iter().any(|p| p == "another"));
}
#[test]
fn a_new_sweep_replaces_the_one_under_way_and_a_list_made_for_the_old_one_is_refused() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
// the old sweep's first file holds the worker until the new one is in place
let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
let (l, g) = (log.clone(), gate.clone());
a.set_probe(Arc::new(move |p| {
if p == "old 0" {
let (open, cv) = &*g;
let mut open = held(open);
while !*open {
open = cv.wait(open).unwrap_or_else(PoisonError::into_inner);
}
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
let old = a.restart_sweeps();
assert!(a.sweep_in(old, (0..20).map(|i| format!("old {i}")).collect()));
wait_until("the worker takes the first file", || a.sweeping() == 19);
// another library is opened: what is left of the old sweep goes, and a list still being made for it too
let new = a.restart_sweeps();
assert_eq!(a.sweeping(), 0);
assert!(!a.sweep_in(old, vec!["old late".into()]), "a list made for the old library is refused");
assert!(a.sweep_in(new, (0..3).map(|i| format!("new {i}")).collect()));
*held(&gate.0) = true;
gate.1.notify_all();
wait_until("the new sweep", || held(&log).len() == 4);
assert_eq!(*held(&log), ["old 0", "new 0", "new 1", "new 2"]);
}
#[test]
fn files_found_gone_are_told_apart_from_files_never_checked() {
let (a, _, _) = logged();
assert!(!a.is_gone("gone 1") && a.gone_len() == 0);
assert!(a.sweep(vec!["gone 1".into(), "here".into()]));
wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0 && a.gone_len() == 1);
assert!(a.is_gone("gone 1") && !a.is_gone("here") && !a.is_gone("never asked"));
}
fn busy_flag(a: &Availability) -> Arc<std::sync::atomic::AtomicBool> {
let busy = Arc::new(std::sync::atomic::AtomicBool::new(true));
let b = busy.clone();
a.sweep_yields_to(Arc::new(move || b.load(Ordering::SeqCst)));
busy
}
#[test]
fn a_sweep_gives_way_to_file_work_answers_what_is_asked_and_resumes_after() {
let (a, log, _) = logged();
let busy = busy_flag(&a);
assert!(a.sweep((0..20).map(|i| format!("sweep {i}")).collect()));
std::thread::sleep(YIELD_POLL * 3);
assert!(held(&log).is_empty(), "no sweep check while files are busy");
assert_eq!(a.sweeping(), 20, "nothing of the sweep is lost");
assert_eq!(a.exists("asked"), None);
wait_until("the answer", || a.exists("asked") == Some(true));
assert_eq!(*held(&log), ["asked"], "what is asked is answered, the sweep still waits");
busy.store(false, Ordering::SeqCst);
wait_until("the sweep", || a.sweeping() == 0);
wait_until("the log", || held(&log).len() == 21);
// it went on from its first file, not from scratch
assert_eq!(held(&log).iter().filter(|p| *p == "sweep 0").count(), 1);
}
#[test]
fn a_sweep_stopped_part_way_goes_on_from_there() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
let l = log.clone();
// the worker is held inside the fourth check until the sweep is told to give way
let hold = Arc::new(std::sync::atomic::AtomicBool::new(true));
let h = hold.clone();
a.set_probe(Arc::new(move |p| {
while p == "sweep 3" && h.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(1));
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
let busy = busy_flag(&a);
busy.store(false, Ordering::SeqCst);
assert!(a.sweep((0..100).map(|i| format!("sweep {i}")).collect()));
wait_until("a few checks", || held(&log).len() >= 3);
busy.store(true, Ordering::SeqCst);
hold.store(false, Ordering::SeqCst);
std::thread::sleep(YIELD_POLL * 2);
let stopped = held(&log).len();
assert_eq!(stopped, 4, "stopped part way, after the check it was in");
std::thread::sleep(YIELD_POLL * 2);
assert_eq!(held(&log).len(), stopped, "paused");
assert_eq!(a.sweeping(), 100 - stopped, "what is left is kept");
busy.store(false, Ordering::SeqCst);
wait_until("the sweep", || a.sweeping() == 0 && held(&log).len() == 100);
let mut all = held(&log).clone();
all.sort();
all.dedup();
assert_eq!(all.len(), 100, "every file checked once, none again from the start");
}
#[test]
fn a_sweep_that_yields_to_nothing_runs_as_before() {
let (a, log, _) = logged();
assert!(a.sweep(vec!["a".into(), "b".into()]));
wait_until("the sweep", || held(&log).len() == 2);
}
#[test]
fn a_sweep_skips_files_checked_a_moment_ago() {
let (a, log, _) = logged();
assert_eq!(a.exists("a"), None);
wait_until("the answer", || a.exists("a").is_some());
a.sweep(vec!["a".into(), "b".into()]);
wait_until("b", || held(&log).iter().any(|p| p == "b"));
wait_until("the sweep", || a.sweeping() == 0);
assert_eq!(held(&log).iter().filter(|p| *p == "a").count(), 1, "`a` was checked a moment ago");
}
}
+26 -7
View File
@@ -9,10 +9,12 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, cmd};
use crate::{Result, Session};
/// Seconds between shots of the same burst at most.
const BURST_GAP: i64 = 10;
/// Signature similarity for "the same scene".
const SAME: f32 = 0.93;
/// Seconds between shots of the same burst at most (default of `burstGap`).
const BURST_GAP: f64 = 10.0;
/// Signature similarity for "the same scene" (default of `similarity`).
const SAME: f64 = 0.93;
/// The longest `burstGap` accepted: a day.
const MAX_GAP: f64 = 86_400.0;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
@@ -26,6 +28,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
let (gap, same) = burst_params(p)?;
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
let mut failed = Vec::new();
@@ -45,8 +48,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = 1u32;
for i in 1..rows.len() {
let (a, b) = (&rows[i - 1], &rows[i]);
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y.saturating_sub(x)).unsigned_abs() as f64 <= gap);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= same {
let g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
@@ -86,6 +89,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
/// `burstGap` (seconds, 0..=a day) and `similarity` (0..=1) of `photo.analyze`, refused before
/// anything is measured when they are not numbers in range.
fn burst_params(p: &Value) -> Result<(f64, f32)> {
const C: &str = "photo.analyze";
let num = |key: &str, d: f64, max: f64| -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(d),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (0.0..=max).contains(&x) => Ok(x),
_ => Err(super::bad(C, format!("`{key}` must be a number from 0 to {max}"))),
},
}
};
Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
@@ -139,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assisted Culling",
[],
None,
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool, burstGap?: seconds 0..86400 (10), similarity?: 0..1 (0.93)} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots (signature similarity at least `similarity`) taken at most `burstGap` seconds apart and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
always,
analyze
),
+4 -1
View File
@@ -451,7 +451,10 @@ pub fn specs() -> Vec<CommandSpec> {
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
Some(path) => {
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
s.check_write_target(path).map_err(|e| bad("keyword.export", e))?;
// A folder that isn't there is an error, not created.
lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes())
.map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
r["path"] = json!(path);
}
None => r["text"] = json!(file.text),
+130 -43
View File
@@ -7,6 +7,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it).
/// Its callers end an open edit (a slider drag) before their own change, as moving to the next
/// photo does, so it is its own undo step and comes first.
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
@@ -201,7 +204,6 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
@@ -220,10 +222,36 @@ fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> F
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar,
/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is
/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so
/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey
/// keep showing the photo they rate.
fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null()));
// the view as it was, then an open drag ends first, as its own undo step (the selection may
// move on; the drag itself can take the photo out of the view)
let before = (!named).then(|| s.visible_shared().1);
if !named {
s.end_interaction()?;
}
let active = s.selection.active;
let v = edit(s)?;
if let Some(before) = before
&& active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a))
{
select_after_deletion(s, &before);
s.active_mask = None;
s.active_spot = None;
} else {
advance_if(s, p);
}
Ok(v)
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -371,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> {
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
}
// one album at a time: `to_filter` makes the album shown replace the filter's, and the
// filter bar has no album chip, so a left-over one would narrow the next source unseen
s.filter.album = None;
LibrarySource::Album(a)
}
"libraryFolder" => {
@@ -764,40 +795,66 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| { step(s, -row_step(p)) }
),
// ---- rating / flags / labels
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
if !(0..=5).contains(&r) {
return Err(bad("photo.rate", "rating must be 0..5"));
cmd!(
"photo.rate",
"Set Rating",
["Photo", "Set Rating"],
None,
"{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
let r = super::index_req(p, "rating", "photo.rate")
.ok()
.filter(|r| *r <= 5)
.ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?;
cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 })))
}
let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?;
advance_if(s, p);
Ok(v)
}),
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, -1, "Decrease Rating")
}),
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, 1, "Increase Rating")
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
advance_if(s, p);
Ok(v)
}),
),
cmd!(
"photo.decreaseRating",
"Decrease Rating",
[],
None,
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) }
),
cmd!(
"photo.increaseRating",
"Increase Rating",
[],
None,
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) }
),
cmd!(
"photo.flag",
"Set Flag",
["Photo", "Set Flag"],
None,
"{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f })))
}
),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
cull(s, p, |s| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
})
}
),
@@ -806,34 +863,52 @@ pub fn specs() -> Vec<CommandSpec> {
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
|s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
|s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) }
),
cmd!(
"photo.pick",
"Flag as Pick",
["Photo", "Set Flag"],
Some("P"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) }
),
cmd!(
"photo.reject",
"Flag as Reject",
["Photo", "Set Flag"],
Some("X"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) }
),
cmd!(
"photo.unflag",
"Unflag",
["Photo", "Set Flag"],
Some("U"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))
}),
cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))
}),
cmd!(
"photo.label",
"Set Color Label",
["Photo", "Set Color Label"],
None,
"{label: red|yellow|green|blue|purple|none, ids?}",
"{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
// `none` (or null) clears; a missing label or one that isn't text is an error, not a clear
@@ -844,7 +919,7 @@ pub fn specs() -> Vec<CommandSpec> {
None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")),
Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")),
};
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l })))
}
),
// ---- orientation
@@ -915,6 +990,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let targets = s.targets(p)?;
// an open drag ends first, as its own undo step (the selection moves on)
s.end_interaction()?;
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(json!({"changed": n}))
@@ -944,6 +1021,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p)?;
// before the inverse ops are taken, so undo brings the photo back with its edit
s.end_interaction()?;
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
select_after_deletion(s, &before);
@@ -1635,7 +1714,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.compact_library()?;
ok()
}),
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| {
s.media.rendered.clear();
s.media.clear_sources();
ok()
@@ -1804,6 +1883,14 @@ mod relative_rating_tests;
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
#[cfg(test)]
#[path = "library/tests_rated_out.rs"]
mod rated_out_tests;
#[cfg(test)]
#[path = "library/tests_enable_filters.rs"]
mod enable_filters_tests;
#[cfg(test)]
#[path = "library/tests_source_album_filter.rs"]
mod source_album_filter_tests;
@@ -0,0 +1,139 @@
//! A rating, flag or label that takes the active photo out of the view (the filter bar, Picks) goes on from its place:
//! the photo that took it, not the view's first photo (#2312).
use lightcraft_catalog::{ColorLabel, Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
/// 100 photos with one star each, in file-name order (photo `n` is the `n`th), the filter showing one-star photos.
fn one_star() -> Result<Session> {
let mut s = Session::new();
for id in 1..=100 {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-rated-out/{id:03}.jpg") },
&format!("{id:03}.jpg"),
"JPEG",
40,
30,
"2026-10-19",
);
photo.rating = 1;
photo.flag = Flag::Pick;
photo.label = Some(ColorLabel::Red);
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"}))?;
Ok(s)
}
#[test]
fn a_star_taken_off_goes_on_to_the_next_photo() {
// Given photo 82 of 100 one-star photos, the filter showing one-star photos
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [82]})).unwrap();
// When its star is taken off, Then it leaves the view and the photo after it is the one selected
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert!(!s.visible_cloned().contains(&PhotoId(82)));
assert_eq!(s.selection, Selection::single(PhotoId(83)));
// and the next photo is the one after that, not the first one of the view
s.execute("library.next", &json!({})).unwrap();
assert_eq!(s.active(), Some(PhotoId(84)));
// With `advance`, the photo that took its place is the next one: no photo is skipped
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(85)));
s.execute("photo.decreaseRating", &json!({"advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(86)));
// The last photo of the view hands on to the one before it
s.execute("library.select", &json!({"ids": [100]})).unwrap();
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(99)));
// A rating that keeps the photo in the view advances as before
s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(99)), "the last photo of the view stays");
s.execute("library.select", &json!({"ids": [10]})).unwrap();
s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(11)));
}
#[test]
fn flags_and_labels_that_leave_the_view_go_on_to_the_next_photo_too() {
for (filter, command, params) in [
(json!({"flag": "pick"}), "photo.reject", json!({})),
(json!({"flag": "pick"}), "photo.flag", json!({"flag": "none"})),
(json!({"flag": "pick"}), "photo.cycleFlag", json!({})),
(json!({"flag": "pick"}), "photo.decreaseFlag", json!({})),
(json!({"label": "red"}), "photo.label", json!({"label": "none"})),
(json!({}), "photo.increaseRating", json!({})),
] {
let mut s = one_star().unwrap();
s.execute("library.filter", &filter).unwrap();
s.execute("library.select", &json!({"ids": [40]})).unwrap();
s.execute(command, &params).unwrap();
assert!(!s.visible_cloned().contains(&PhotoId(40)), "{command} {params}");
assert_eq!(s.selection, Selection::single(PhotoId(41)), "{command} {params}");
}
}
#[test]
fn several_photos_rated_out_go_on_after_the_active_one() {
let mut s = one_star().unwrap();
// three selected, the middle one active: all leave, the photo after the active one is selected
s.execute("library.select", &json!({"ids": [20, 21, 22], "active": 21})).unwrap();
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert_eq!(s.selection, Selection::single(PhotoId(23)));
// the active one leaves and another selected photo stays: that one is active
s.execute("photo.rate", &json!({"ids": [30], "rating": 3})).unwrap();
s.execute("library.filter", &json!({"rating": 1, "ratingOp": "atLeast"})).unwrap();
s.execute("library.select", &json!({"ids": [29, 30], "active": 29})).unwrap();
s.execute("photo.decreaseRating", &json!({})).unwrap();
assert_eq!(s.selection, Selection { ids: vec![PhotoId(30)], active: Some(PhotoId(30)) });
}
#[test]
fn named_photos_and_a_held_view_keep_the_selection() {
// Photos named by `ids` leave the selection to the caller (Compare and Survey keep the photo they show)
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [5, 6], "active": 6})).unwrap();
s.execute("photo.rate", &json!({"ids": [6], "rating": 0})).unwrap();
assert_eq!(s.selection, Selection { ids: vec![PhotoId(5), PhotoId(6)], active: Some(PhotoId(6)) });
// A held view keeps the photo, so the selection stays with it
let mut s = one_star().unwrap();
s.execute("library.holdView", &json!({"on": true})).unwrap();
// (what the grid shows is what is held)
assert_eq!(s.visible_cloned().len(), 100);
s.execute("library.select", &json!({"ids": [7]})).unwrap();
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert!(s.visible_cloned().contains(&PhotoId(7)));
assert_eq!(s.selection, Selection::single(PhotoId(7)));
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(8)));
}
/// A slider drag still open when a rating takes the photo out of the view, or when the photo is
/// deleted, ends as its own undo step before that change, as moving to the next photo does: undo
/// brings the rating or the photo back first, then the edit. Before, the move left the drag open
/// with no undo step (CodeRabbit on #2689).
#[test]
fn an_open_edit_ends_before_the_selection_moves_on() {
let exposure = |s: &Session, id| s.develop_of(PhotoId(id)).map(|d| d.light.exposure);
for (command, params) in [("photo.rate", json!({"rating": 0})), ("photo.delete", json!({})), ("photo.deletePermanently", json!({}))] {
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [82]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
s.execute(command, &params).unwrap();
assert_eq!(s.active(), Some(PhotoId(83)), "{command}");
assert!(s.interaction.is_none(), "{command}: the drag ended with the move");
if command != "photo.deletePermanently" {
assert_eq!(exposure(&s, 82), Some(1.5), "{command}");
}
// undo: the rating or the photo comes back first, with the edit; then the edit goes
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.visible_cloned().contains(&PhotoId(82)), "{command}: back in the view");
assert_eq!(exposure(&s, 82), Some(1.5), "{command}: with its edit");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(exposure(&s, 82), Some(0.0), "{command}");
}
}
@@ -0,0 +1,61 @@
//! `library.source` with an album while the filter holds an album: the album shown wins.
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
/// Photos 1 to 3 (rated 5, 5 and 2). Album X holds photo 1 and album A holds photo 3.
fn session_with_two_albums() -> Result<(Session, u64, u64)> {
let mut s = Session::new();
for (id, rating) in [(1, 5), (2, 5), (3, 2)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-source-album/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-23",
);
photo.rating = rating;
photo.flag = Flag::None;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
let mut make = |name: &str, id: u64| {
s.selection = Selection { ids: vec![PhotoId(id)], active: None };
s.execute("album.create", &json!({"name": name, "addSelected": true})).unwrap()["id"].as_u64().unwrap()
};
let (x, a) = (make("X", 1), make("A", 3));
Ok((s, x, a))
}
#[test]
fn showing_an_album_drops_the_album_the_filter_held() {
let (mut s, x, a) = session_with_two_albums().unwrap();
s.execute("library.filter", &json!({"album": x, "rating": 4})).unwrap();
assert_eq!(s.visible().len(), 1, "the album narrows the grid, or the hidden album would not matter");
s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap();
assert_eq!(s.filter.album, None, "one album at a time: the one shown wins, as for a library folder");
assert_eq!(s.filter.rating, 4, "the rest of the filter stays");
// the old album must not narrow the next source, with no chip to show it
s.execute("library.source", &json!({"kind": "all"})).unwrap();
assert_eq!(s.visible().len(), 2, "photos 1 and 2 have four stars or more");
}
#[test]
fn the_album_filter_stays_while_the_source_is_not_an_album() {
let (mut s, x, _) = session_with_two_albums().unwrap();
s.execute("library.filter", &json!({"album": x})).unwrap();
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
s.execute("library.source", &json!({"kind": "all"})).unwrap();
assert_eq!(s.filter.album.map(|a| a.0), Some(x));
assert_eq!(s.visible().len(), 1);
}
#[test]
fn a_refused_album_source_leaves_the_filter_alone() {
let (mut s, x, _) = session_with_two_albums().unwrap();
s.execute("library.filter", &json!({"album": x})).unwrap();
assert!(s.execute("library.source", &json!({"kind": "album", "id": 9999})).is_err());
assert_eq!(s.filter.album.map(|a| a.0), Some(x));
}
+20 -5
View File
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Capture Time",
[],
None,
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}",
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..], unreadable: photos skipped because their capture time can't be read}",
has_selection,
|s, p| {
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
@@ -213,12 +213,25 @@ pub fn specs() -> Vec<CommandSpec> {
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
.and_then(|_| iso_seconds(&anchor_date))
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
delta += iso_seconds(&t).unwrap_or(from) - from;
// the others move by the anchor's change, so it must read in full, not just
// its leading digits (a Lightroom catalog's capture time is copied verbatim);
// alone, it simply gets the time, which repairs it
if shift_iso(&anchor_date, 0).is_some() {
delta += iso_seconds(&t).unwrap_or(from) - from;
} else if targets.len() == 1 {
each = Some(t);
} else {
return Err(bad(
c,
"the active photo's capture time can't be read, so the other photos can't move by the same amount; set its time with it selected alone",
));
}
}
}
let mut ops = Vec::new();
let mut out = Vec::new();
let mut out_of_range = 0usize;
let mut unreadable = 0usize;
for id in &targets {
let from = match &each {
Some(t) => t.clone(),
@@ -226,9 +239,11 @@ pub fn specs() -> Vec<CommandSpec> {
};
let Some(new) = shift_iso(&from, delta) else {
// a date that reads but would leave years 0000–9999 is refused below; one
// that doesn't read is skipped, as before
if iso_seconds(&from).is_some() {
// that doesn't read is skipped and counted
if shift_iso(&from, 0).is_some() {
out_of_range += 1;
} else {
unreadable += 1;
}
continue;
};
@@ -244,7 +259,7 @@ pub fn specs() -> Vec<CommandSpec> {
if n > 0 {
s.commit("Edit Capture Time", Op::Batch { ops })?;
}
Ok(json!({"changed": n, "captured": out}))
Ok(json!({"changed": n, "captured": out, "unreadable": unreadable}))
}
),
cmd!(
+70 -2
View File
@@ -90,9 +90,77 @@ fn stored_hash(p: &Photo) -> Option<&str> {
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
}
/// The content hash of the file at `path` (as import computes it), if it can be read.
/// The content hash of the file at `path` (as import computes it), if it can be read. Read in
/// pieces: the file is whatever is on the disk, of any size.
fn file_hash(path: &str) -> Option<String> {
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
hash_reader(std::fs::File::open(path).ok()?)
}
/// The content hash of what `r` gives, read in pieces (the hash import computes of the whole).
fn hash_reader(mut r: impl std::io::Read) -> Option<String> {
let mut hasher = lightcraft_preview::Hasher128::new();
let mut piece = vec![0u8; 1 << 16];
loop {
match r.read(&mut piece) {
Ok(0) => return Some(hasher.finish().to_string()),
Ok(n) => {
hasher.update(piece.get(..n)?);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A reader that gives its bytes a few at a time, and an interruption in between.
struct Trickle<'a>(&'a [u8], bool);
impl std::io::Read for Trickle<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.1 = !self.1;
if self.1 {
return Err(std::io::ErrorKind::Interrupted.into());
}
let n = self.0.len().min(buf.len()).min(7);
buf[..n].copy_from_slice(&self.0[..n]);
self.0 = &self.0[n..];
Ok(n)
}
}
#[test]
fn a_hash_read_in_pieces_is_the_hash_of_the_whole() {
// longer than one piece, so the read loop runs more than once
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let whole = lightcraft_preview::hash_bytes(&bytes).to_string();
assert_eq!(hash_reader(bytes.as_slice()).as_deref(), Some(whole.as_str()));
assert_eq!(hash_reader(Trickle(&bytes[..1000], false)).as_deref(), Some(lightcraft_preview::hash_bytes(&bytes[..1000]).to_string().as_str()));
assert_eq!(hash_reader(std::io::empty()).as_deref(), Some(lightcraft_preview::hash_bytes(&[]).to_string().as_str()));
}
/// A scratch folder that goes away with the test, however it ends.
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_file_hash_is_the_import_hash_and_a_missing_file_has_none() {
let dir = Scratch(std::env::temp_dir().join(format!("lc-file-hash-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&dir.0);
std::fs::create_dir_all(&dir.0).unwrap();
let f = dir.0.join("a.bin");
std::fs::write(&f, b"pixels").unwrap();
assert_eq!(file_hash(f.to_str().unwrap()), Some(lightcraft_preview::hash_bytes(b"pixels").to_string()));
assert_eq!(file_hash(dir.0.join("nope").to_str().unwrap()), None);
}
}
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
+9 -5
View File
@@ -1,6 +1,8 @@
//! Read-only queries (not journaled): catalog, photos, develop state, controls, presets, albums.
use lightcraft_catalog::{Album, Photo, PhotoId};
use std::collections::HashMap;
use lightcraft_catalog::{Album, AlbumId, Photo, PhotoId};
use lightcraft_develop::{CONTROLS, controls};
use serde_json::{Value, json};
@@ -36,9 +38,9 @@ pub fn photo_summary(p: &Photo) -> Value {
/// instead of overflowing the stack.
const MAX_ALBUM_DEPTH: usize = 64;
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
fn album_json(a: &Album, all: &[Album], counts: &HashMap<AlbumId, usize>, cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH {
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, counts, cat, depth + 1)).collect()
} else {
Vec::new()
};
@@ -46,7 +48,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": cat.album_count(a.id),
"count": counts.get(&a.id).copied().unwrap_or(0),
"cover": a.cover.map(|c| c.0),
"children": children,
});
@@ -152,7 +154,9 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
// every album counted in one pass, so a smart album tested by others is worked out once
let counts = lightcraft_catalog::tally::AlbumTally::of(&s.catalog);
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, counts.counts(), &s.catalog, 0)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn load(s: &mut Session) {
..Default::default()
};
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
// View ▸ Focus Point and Zoom to Focus Point can be tried without a camera file
if sc.name == "Cosmos bloom" {
p.meta.focus = Some(lightcraft_meta::FocusPoint {
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
+174 -34
View File
@@ -501,11 +501,12 @@ pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
}
/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the
/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded.
/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it.
/// The error says why it couldn't be read or decoded.
pub fn read_graphic(path: &str) -> Result<Rgba8, String> {
let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?;
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?;
Ok(decoded.to_srgb8())
Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation)))
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
@@ -518,20 +519,27 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
return;
}
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
let turns = wm.quarter_turns();
// Reserve the inset on anchored axes before fitting the turned box. Centered axes
// ignore insets, just as placement below does. One scale preserves the graphic's shape.
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);
use Anchor::*;
let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 };
let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 };
let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) };
let scale =
(wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64);
let lw = (logo.width as f64 * scale).round().max(1.0) as usize;
let lh = (logo.height as f64 * scale).round().max(1.0) as usize;
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
let p = logo.get(x, y);
let a = p[3] as f32 / 255.0;
[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 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 => ix,
Top | Center | Bottom => (w - lwf) / 2.0,
@@ -658,8 +666,8 @@ pub struct ExportOptions {
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
pub bit_depth: Option<u8>,
/// 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, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
/// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both
/// images and their metadata), 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), JPEG XL as 16-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.
@@ -1392,29 +1400,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta);
match o.limit_kb {
Some(kb) => {
let limit = kb as usize * 1024;
// Binary search for the highest quality that fits.
let (mut lo, mut hi) = (1u8, o.quality);
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let b = jpeg(mid).map_err(|e| e.to_string())?;
if b.len() <= limit {
best = Some(b);
lo = mid + 1;
} else if mid == 1 {
break;
} else {
hi = mid - 1;
}
}
return best.ok_or_else(|| format!("cannot fit within {kb} KB"));
}
None => jpeg(o.quality),
}
return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta));
}
ExportFormat::Png => encode::encode_png(&e, &meta),
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
@@ -1428,6 +1414,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
r.map_err(|e| e.to_string())
}
/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs.
fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<Vec<u8>, String> {
let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) };
let limit = u64::from(kb) * 1024;
let (mut lo, mut hi) = (1u8, o.quality.min(100));
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let b = jpeg(mid).map_err(|e| e.to_string())?;
if b.len() as u64 <= limit {
best = Some(b);
lo = mid + 1;
} else if mid == 1 {
break;
} else {
hi = mid - 1;
}
}
best.ok_or_else(|| format!("cannot fit within {kb} KB"))
}
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
let mut img = img.clone();
@@ -1475,7 +1482,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em))
}
/// The codecs' name of an output space (for its ICC profile).
@@ -2441,6 +2448,139 @@ mod tests {
assert!(b.len() <= kb as usize * 1024);
}
#[test]
fn hdr_jpeg_size_limit_respected() {
let img = Rgba8::from_fn(256, 192, |x, y| {
let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663);
[n as u8, (n >> 8) as u8, (n >> 16) as u8, 255]
});
let hdr = DeepImage {
width: img.width,
height: img.height,
space: OutputSpace::Srgb,
samples: DeepSamples::F32(
img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(),
),
};
let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() };
let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap();
let kb = (full.len() / 2048) as u32;
let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap();
assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len());
let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap();
assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting");
let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None);
assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB")));
}
#[test]
fn graphic_watermark_honours_exif_orientation() {
let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] });
for orientation in [2, 6] {
let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() };
let exif = lightcraft_meta::write_exif(&meta);
let bytes = lightcraft_codecs::encode_jpeg(
&EncodeImage::rgba8(&logo),
100,
ChromaSubsampling::S444,
&EncodeMeta { exif: Some(&exif), ..Default::default() },
)
.unwrap();
let path = dir.join(format!("oriented-{orientation}.jpg"));
std::fs::write(&path, bytes).unwrap();
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: if orientation == 6 { 0.25 } else { 0.5 },
opacity: 1.0,
inset_x: 0.0,
inset_y: 0.0,
..Default::default()
};
let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
if orientation == 6 {
assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue");
} else {
assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red");
}
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() {
let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tall.png");
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] {
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: 0.5,
opacity: 1.0,
inset_x: 0.0,
inset_y: 0.0,
rotation,
..Default::default()
};
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
let bounds = (
lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1,
lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1,
);
assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn graphic_watermark_fit_preserves_anchor_insets() {
let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tall.png");
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
use Anchor::*;
for (anchor, rotation, expected) in [
(BottomRight, 90, (0, 44, 111, 71)),
(TopLeft, 270, (8, 8, 119, 35)),
(Right, 90, (0, 26, 111, 53)),
(BottomRight, 0, (94, 0, 111, 71)),
(TopLeft, 180, (8, 8, 25, 79)),
(Bottom, 0, (51, 0, 68, 71)),
// Centered axes ignore their inset rather than shrinking the image unnecessarily.
(Center, 90, (0, 25, 119, 54)),
(Top, 90, (0, 8, 119, 37)),
] {
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: 0.5,
opacity: 1.0,
inset_x: 0.1,
inset_y: 0.1,
rotation,
anchor,
..Default::default()
};
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
let bounds = (
lit.iter().map(|p| p.0).min().unwrap(),
lit.iter().map(|p| p.1).min().unwrap(),
lit.iter().map(|p| p.0).max().unwrap(),
lit.iter().map(|p| p.1).max().unwrap(),
);
assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn sharpen_increases_edge_contrast() {
let mut img = Rgba8::new(8, 8);
+82
View File
@@ -374,6 +374,42 @@ pub fn is_supported(path: &Path) -> bool {
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
}
/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension
/// is listed in both cases: browsers' `accept` matching is case-insensitive per
/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and
/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws
/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker.
///
/// The picker only offers files this build can actually import: `.avif` has no
/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs'
/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]).
/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised,
/// and import reports them as failed, saying why.
pub fn accept_list() -> String {
let heif_decodable = lightcraft_codecs::Format::Heif.can_decode();
let mut out = String::new();
for &ext in EXTENSIONS {
// Skip extensions a web user could pick but this build could not import.
let pickable = match ext {
"avif" => false,
"heic" | "heif" => heif_decodable,
_ => true,
};
if !pickable {
continue;
}
if !out.is_empty() {
out.push(',');
}
out.push('.');
out.push_str(ext);
out.push(',');
out.push('.');
out.push_str(&ext.to_uppercase());
}
out
}
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
/// walk that stops at a bound is logged.
@@ -1607,6 +1643,52 @@ mod tests_known;
mod prepared_tests {
use super::*;
/// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the
/// files this build cannot import (see [`accept_list`]).
fn pickable_extensions() -> Vec<&'static str> {
let heif_decodable = lightcraft_codecs::Format::Heif.can_decode();
EXTENSIONS
.iter()
.copied()
.filter(|ext| match *ext {
"avif" => false,
"heic" | "heif" => heif_decodable,
_ => true,
})
.collect()
}
#[test]
fn accept_list_covers_every_pickable_extension_in_both_cases() {
let list = accept_list();
for ext in pickable_extensions() {
assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list");
assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase());
}
}
/// The web picker must not offer files this build cannot import: `.avif` has
/// no AV1 decoder in any build, so it is never listed, in either case.
#[test]
fn accept_list_never_offers_avif() {
let list = accept_list();
assert!(!list.contains(".avif"), "{list}");
assert!(!list.contains(".AVIF"), "{list}");
}
/// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases)
/// only when this build can decode them; otherwise hidden, so a web user
/// cannot pick a file that would fail to import.
#[test]
fn accept_list_offers_heic_heif_only_when_decodable() {
let list = accept_list();
let listed = lightcraft_codecs::Format::Heif.can_decode();
for ext in ["heic", "heif"] {
assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}");
assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}");
}
}
#[test]
fn revalidate_add_only_converts_matching_ready_item() {
let mut s = Session::new();
+65 -12
View File
@@ -195,6 +195,31 @@ pub struct Interaction {
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
const MAX_FOLLOWED: usize = 256;
/// The established clock precision for relative-date views and sidebar counts.
fn clock_minute(now: &str) -> &str {
now.get(..16).unwrap_or(now)
}
/// Whether a query can change membership without a catalog edit, including the smart albums it tests.
fn filter_depends_on_clock(filter: &Filter, catalog: &Catalog) -> bool {
if filter.depends_on_now() {
return true;
}
let mut pending = filter.albums_tested();
let mut seen = std::collections::HashSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let Some(smart) = catalog.album(id).and_then(|album| album.smart.as_deref()) else { continue };
if smart.depends_on_now() {
return true;
}
pending.extend(smart.albums_tested());
}
false
}
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -215,6 +240,8 @@ pub struct Session {
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<(u64, String)>,
/// The minute used for a cached view whose rules depend on the session clock.
visible_clock_minute: Option<String>,
/// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
/// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once.
pub hold_view: bool,
@@ -234,7 +261,7 @@ pub struct Session {
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
/// Photos in the current source with no filter on (cached like `visible`).
total: Option<((u64, String), usize)>,
total: Option<((u64, String), usize, Option<String>)>,
pub undo: Vec<UndoEntry>,
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
@@ -429,6 +456,7 @@ impl Session {
selection: Selection::default(),
visible: Vec::new().into(),
visible_key: None,
visible_clock_minute: None,
hold_view: false,
base: None,
view_followed: 0,
@@ -1050,12 +1078,20 @@ impl Session {
};
let view = format!("{}|{rules}", key.1);
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
// the view fills in as the background checks find files gone (a file found there at
// its first check changes nothing it shows)
key.1.push_str(&format!("|{}", self.media.availability.gone_generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let key_changed = self.visible_key.as_ref() != Some(&key);
// A time-dependent cache needs one clock read to test its minute stamp. A new view also
// needs one value for the query, even when its previous cached view did not use the clock.
let current_now = (key_changed || self.visible_clock_minute.is_some()).then(|| (self.clock)());
let current_minute = current_now.as_deref().map(clock_minute);
let clock_changed = self.visible_clock_minute.as_deref().is_some_and(|cached| current_minute != Some(cached));
if key_changed || clock_changed {
// Use this same value to evaluate relative rules and stamp the result.
let now = current_now.unwrap_or_else(|| (self.clock)());
lightcraft_catalog::rules::set_now(Some(now.clone()));
let f = self.view_filter(self.source, &self.filter);
// A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's
// rules, a flag, the filter bar), as long as they are still where the view looks: in the library for
@@ -1082,8 +1118,7 @@ impl Session {
let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing);
// A view that asks about "the last N days" (itself, or through a smart album it looks into) changes
// with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole.
let clocked = f.depends_on_now()
|| (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now)));
let clocked = filter_depends_on_clock(&f, &self.catalog);
let base = self.base.take().filter(|(of, _, _)| *of == view);
// A small edit to the same view: the photos it touched are taken out, asked about again, and put back
// at their place in the order. The others neither stopped matching nor moved.
@@ -1157,6 +1192,7 @@ impl Session {
self.visible = visible.into();
self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.visible_key = Some(key);
self.visible_clock_minute = clocked.then(|| clock_minute(&now).to_owned());
}
&self.visible
}
@@ -1189,12 +1225,20 @@ impl Session {
/// view, so a list followed many times over is compared, not only one followed once.
#[cfg(test)]
pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> {
let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out);
let kept = (
self.base.clone(),
self.visible_key.clone(),
self.visible_clock_minute.clone(),
self.visible.clone(),
self.visible_gen,
self.view_followed,
self.view_worked_out,
);
self.never_follow = true;
self.visible_key = None;
let whole = self.visible().to_vec();
self.never_follow = false;
(self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept;
(self.base, self.visible_key, self.visible_clock_minute, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept;
whole
}
@@ -1222,7 +1266,15 @@ impl Session {
}
}
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let key_changed = self.total.as_ref().map(|total| &total.0) != Some(&key);
let cached_minute = self.total.as_ref().and_then(|total| total.2.as_deref());
let current_now = cached_minute.map(|_| (self.clock)());
let current_minute = current_now.as_deref().map(clock_minute);
let clock_changed = cached_minute.is_some_and(|cached| current_minute != Some(cached));
if key_changed || clock_changed {
// The source count can be requested before `visible`; bind it to the session's clock itself.
let now = current_now.unwrap_or_else(|| (self.clock)());
lightcraft_catalog::rules::set_now(Some(now.clone()));
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
if self.source == LibrarySource::Folder {
let b = self.browse.clone().unwrap_or_default();
@@ -1234,7 +1286,8 @@ impl Session {
}
// (how many, not which: nothing to put in order)
let n = self.catalog.count(&f);
self.total = Some((key, n));
let clocked = filter_depends_on_clock(&f, &self.catalog);
self.total = Some((key, n, clocked.then(|| clock_minute(&now).to_owned())));
}
self.total.as_ref().map(|t| t.1)
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 69;
pub const RENDER_CACHE_VERSION: u64 = 70;
/// 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];
+126 -3
View File
@@ -46,6 +46,41 @@ const PAPER: &[(&str, f64, f64)] = &[
("5x7", 5.0, 7.0),
("8x10", 8.0, 10.0),
("13x19", 13.0, 19.0),
// Japanese photo printing and postcard sizes, at their exact metric dimensions (the US photo
// sizes above are close but not identical: L is 89 x 127 mm, 5x7 in is 127 x 177.8 mm).
("l", 89.0 / 25.4, 127.0 / 25.4),
("2l", 127.0 / 25.4, 178.0 / 25.4),
("kg", 102.0 / 25.4, 152.0 / 25.4),
("postcard", 100.0 / 25.4, 148.0 / 25.4),
("double-postcard", 148.0 / 25.4, 200.0 / 25.4),
("6cut", 203.0 / 25.4, 254.0 / 25.4),
("4cut", 254.0 / 25.4, 305.0 / 25.4),
("business-card", 55.0 / 25.4, 91.0 / 25.4),
];
/// Japanese customary names (and a few English spellings) for the papers in [`PAPER`], matched
/// after [`normalize_paper`]: `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on.
const PAPER_ALIASES: &[(&str, &str)] = &[
("L判", "l"),
("Lサイズ", "l"),
("L-size", "l"),
("2L判", "2l"),
("2Lサイズ", "2l"),
("2L-size", "2l"),
("KG判", "kg"),
("KGサイズ", "kg"),
("KG-size", "kg"),
("はがき", "postcard"),
("ハガキ", "postcard"),
("葉書", "postcard"),
("ポストカード", "postcard"),
("往復はがき", "double-postcard"),
("往復ハガキ", "double-postcard"),
("六切", "6cut"),
("6切", "6cut"),
("四切", "4cut"),
("4切", "4cut"),
("名刺", "business-card"),
];
/// Names accepted by [`PageSetup::paper`] (besides `custom`).
@@ -53,10 +88,39 @@ pub fn paper_names() -> impl Iterator<Item = &'static str> {
PAPER.iter().map(|p| p.0)
}
/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`.
/// The form [`paper_name`] matches names in: lower case, letters and digits only, and `×`/`*` read
/// as `x`, so `L判`, `L判。`, `l-size` and `(l)` are one name and `4 × 6` is `4x6`.
fn normalize_paper(name: &str) -> String {
name.chars()
.filter_map(|c| match c {
'×' | '*' => Some('x'),
c if c.is_alphanumeric() => Some(c),
_ => None,
})
.collect::<String>()
.to_lowercase()
}
/// The canonical name of the paper `name` stands for, `None` for unknown names and `custom`.
/// `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on.
pub fn paper_name(name: &str) -> Option<&'static str> {
let n = normalize_paper(name);
if n.is_empty() {
return None;
}
PAPER
.iter()
.find(|p| normalize_paper(p.0) == n)
.map(|p| p.0)
.or_else(|| PAPER_ALIASES.iter().find(|(alias, _)| normalize_paper(alias) == n).map(|(_, name)| *name))
}
/// Portrait size in inches of a named paper, `None` for unknown names and `custom`. Case, spaces
/// and punctuation don't matter, and the Japanese customary names (`L判`, `2L判`, `KG判`, `はがき`,
/// `往復はがき`, `六切`, `四切`, `名刺`) are aliases of the papers above.
pub fn paper_size(name: &str) -> Option<(f64, f64)> {
let name = name.to_ascii_lowercase();
PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2))
let canonical = paper_name(name)?;
PAPER.iter().find(|p| p.0 == canonical).map(|p| (p.1, p.2))
}
/// An axis-aligned rectangle in inches.
@@ -935,6 +999,65 @@ mod tests {
assert!(p.size().is_err());
}
/// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as
/// aliases, and the ISO/US papers around them unchanged.
#[test]
fn japanese_photo_and_postcard_papers() {
let pt = |name: &str| {
let p = PageSetup { paper: name.into(), ..Default::default() };
p.size_points().unwrap_or_else(|e| panic!("{name}: {e}"))
};
for (name, w_mm, h_mm) in [
("L判", 89.0, 127.0),
("2L判", 127.0, 178.0),
("KG判", 102.0, 152.0),
("はがき", 100.0, 148.0),
("往復はがき", 148.0, 200.0),
("六切", 203.0, 254.0),
("四切", 254.0, 305.0),
("名刺", 55.0, 91.0),
] {
let (w, h) = pt(name);
let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0);
assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}");
assert!((w - h).abs() > 1e-6, "{name} is portrait");
}
// The customary and English spellings resolve to the same paper, including spaces and case.
for (a, b) in [
("L判", "l"),
("L判", " L "),
("L判", "L-size"),
("2L判", "2l"),
("KG判", "kg"),
("はがき", "postcard"),
("はがき", "ハガキ"),
("往復はがき", "double-postcard"),
("六切", "6cut"),
("六切", "6切"),
("四切", "4cut"),
("名刺", "business-card"),
("A4", "a 4"),
("4 × 6", "4x6"),
] {
assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}");
}
// Punctuation is ignored, including the periods and Japanese marks a pasted name may carry.
for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")]
{
assert_eq!(paper_name(name), Some(canonical), "{name}");
}
assert_eq!(paper_name("custom"), None);
assert!(paper_size("poster").is_none());
// The papers that were already there keep their sizes.
assert_eq!(pt("letter"), (612.0, 792.0));
assert_eq!(pt("4x6"), (288.0, 432.0));
assert_eq!(pt("5x7"), (360.0, 504.0));
assert_eq!(pt("8x10"), (576.0, 720.0));
assert_eq!(pt("13x19"), (936.0, 1368.0));
let (w, h) = pt("A4");
assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}");
}
#[test]
fn hostile_numbers_are_rejected() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] {
+18 -2
View File
@@ -4,7 +4,7 @@
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `%Y %y %m %B %b %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
//! kept.
@@ -25,6 +25,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use lightcraft_catalog::dates::MONTHS;
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde::Serialize;
@@ -57,9 +58,12 @@ fn sanitize(s: &str) -> String {
t.trim().trim_matches('.').trim().to_string()
}
/// `%Y%m%d`-style formatting of an ISO local time.
/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name
/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month
/// that doesn't parse gives nothing.
fn format_date(iso: &str, fmt: &str) -> String {
let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string();
let month = iso.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or("");
let mut out = String::new();
let mut it = fmt.chars();
while let Some(c) = it.next() {
@@ -71,6 +75,8 @@ fn format_date(iso: &str, fmt: &str) -> String {
Some('Y') => out.push_str(&part(0, 4)),
Some('y') => out.push_str(&part(2, 4)),
Some('m') => out.push_str(&part(5, 7)),
Some('B') => out.push_str(month),
Some('b') => out.push_str(month.get(..3).unwrap_or(month)),
Some('d') => out.push_str(&part(8, 10)),
Some('H') => out.push_str(&part(11, 13)),
Some('M') => out.push_str(&part(14, 16)),
@@ -120,6 +126,8 @@ pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%B", "month name (January)"),
("%b", "short month name (Jan)"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
@@ -1670,6 +1678,8 @@ pub(crate) mod tests {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]);
assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]);
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
@@ -1711,6 +1721,12 @@ pub(crate) mod tests {
let v = format_date("2026-01-14T05:58:48", d);
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
}
assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep");
assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026");
// a month that isn't 01–12 gives no name instead of panicking
for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] {
assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}");
}
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
let json = token_help_json();
+14
View File
@@ -108,6 +108,20 @@ fn demo_library_loads() {
assert!(albums.as_array().unwrap().iter().any(|a| a["folder"] == true));
}
#[test]
fn rating_rejects_fractional_values() {
let mut s = demo();
let id = s.active().unwrap();
for bad in [5.9, -0.9, 0.5] {
let err = s.execute("photo.rate", &json!({"rating": bad})).unwrap_err();
assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}");
assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "{bad} changed the rating");
}
s.execute("photo.rate", &json!({"rating": 5.0})).unwrap();
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().rating, 3);
}
#[test]
fn rating_flag_undo_redo() {
let mut s = demo();
+24
View File
@@ -313,6 +313,30 @@ fn curve_preset_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard");
}
/// #1944: Export Keywords went straight to `std::fs::write`, replacing a library original.
#[test]
fn keyword_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("keyword.export", json!({}), "keyword-export-guard");
}
#[test]
fn keyword_export_preserves_the_previous_file_on_write_failure() {
let dir = temp_dir("keyword-export-failure");
let mut s = Session::with_demo();
let path = dir.join("keywords.txt");
let params = json!({"path": path.to_string_lossy()});
s.execute("keyword.export", &params).unwrap();
let previous = std::fs::read(&path).unwrap();
{
let _fault = lightcraft_catalog::safe_file::fail_writes_after(4);
assert!(s.execute("keyword.export", &params).is_err(), "keyword.export must report the failed write");
}
assert_eq!(std::fs::read(&path).unwrap(), previous, "the prior export is kept");
s.execute("keyword.export", &params).unwrap();
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no temporary files remain");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn preset_exports_preserve_previous_files_on_write_failure() {
let dir = temp_dir("preset-export-failure");
+44 -1
View File
@@ -1,11 +1,28 @@
//! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
//! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are.
use lightcraft_catalog::PhotoId;
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{LibrarySource, Session};
fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc<std::sync::Mutex<String>>, Vec<PhotoId>) {
let mut s = Session::new();
let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string()));
let clock = now.clone();
s.clock = Box::new(move || clock.lock().unwrap().clone());
let mut ids = Vec::new();
for (index, captured) in captures.iter().enumerate() {
let id = PhotoId(index as u64 + 1);
let name = format!("synthetic-{index}.jpg");
let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00");
photo.captured = Some((*captured).to_string());
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
ids.push(id);
}
(s, now, ids)
}
/// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random.
fn unkeyworded() -> Session {
let mut s = Session::with_demo();
@@ -198,3 +215,29 @@ fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() {
assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err());
assert!(s.hold_view, "a refused call changes nothing");
}
#[test]
fn relative_clock_held_view_keeps_expired_photos_admits_new_matches_and_refreshes() {
let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30", "2026-10-22T12:00:30"]);
let rules = json!({
"ruleSet": {
"match": "any",
"rules": [
{"field": "captureDate", "op": "inLast", "value": 1},
{"field": "captureDate", "op": "notInLast", "value": 2}
]
}
});
s.execute("library.filter", &rules).unwrap();
s.execute("library.holdView", &json!({"on": true})).unwrap();
assert_eq!(s.visible_cloned(), vec![ids[0]], "only the soon-to-expire photo matches initially");
*now.lock().unwrap() = "2026-10-24T12:01:00".to_string();
let held = s.visible_cloned();
assert_eq!(held.len(), 2, "the expired photo stays held while the newly matching photo enters: {held:?}");
assert!(held.contains(&ids[0]), "the expired photo remains held");
assert!(held.contains(&ids[1]), "the photo crossing the second relative boundary enters");
s.execute("library.refresh", &json!({})).unwrap();
assert_eq!(s.visible_cloned(), vec![ids[1]], "explicit refresh releases the expired photo");
}
+48
View File
@@ -1068,6 +1068,54 @@ fn assisted_culling_groups_bursts_across_month_end() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Assisted culling: `burstGap` and `similarity` move the burst limits (#2275), and values out of
/// range are refused before anything is measured or stored.
#[test]
fn assisted_culling_burst_limits_are_adjustable() {
let dir = temp_dir("cull-limits");
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
let img = lightcraft_scenes::demo_library()[0].render(w, h);
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let png = lightcraft_codecs::encode_png(
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
&Default::default(),
)
.unwrap();
std::fs::write(dir.join(name), png).unwrap();
}
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap();
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap();
// 15 s apart: past the default 10 s, inside a 20 s gap
assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0);
assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1);
// the look-alike pair is a burst at the default similarity, not when it must be identical
let strict = s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20, "similarity": 1.0})).unwrap();
assert_eq!(strict["groups"], 0, "{strict}");
// bad values change nothing
let undo_before = s.commits();
for bad in
[json!({"burstGap": -1}), json!({"burstGap": 86401}), json!({"burstGap": "10"}), json!({"similarity": 1.5}), json!({"similarity": -0.1})]
{
let mut p = bad.clone();
p["ids"] = json!(ids);
assert!(s.execute("photo.analyze", &p).is_err(), "{bad} must be refused");
}
assert_eq!(s.commits(), undo_before);
let _ = std::fs::remove_dir_all(&dir);
}
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
#[test]
fn dust_spots_are_found_and_healed() {
+78
View File
@@ -314,6 +314,29 @@ fn smart_album_rule_sets() {
assert!(a.to_string().contains("rating is ≥ 4"), "{a}");
}
/// `label` and `labels` set together (only through `library.filter`, or a filter saved with the
/// single label) AND, and the chip says what they let through, not their union.
#[test]
fn filter_label_and_labels_together_chip() {
use lightcraft_catalog::ColorLabel;
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, l) in ids.iter().zip(["red", "yellow", "green"]) {
s.execute("photo.label", &json!({"ids": [id], "label": l})).unwrap();
}
let reds = s.catalog.photos().filter(|p| p.in_library() && p.label == Some(ColorLabel::Red)).count();
let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip");
s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "red"]})).unwrap();
assert_eq!(s.visible_cloned().len(), reds);
assert_eq!(chip(&s).label, "Label: Red");
// nothing passes both: the chip names both constraints
s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "green"]})).unwrap();
assert_eq!(s.visible_cloned().len(), 0);
assert_eq!(chip(&s).label, "Label: Red and (Yellow or Green)");
s.execute("library.filter", &chip(&s).clear).unwrap();
assert_eq!((s.filter.label, s.filter.labels.len()), (None, 0));
}
/// The filter bar's label multi-select: any of the chosen labels.
#[test]
fn filter_any_of_several_labels() {
@@ -514,6 +537,61 @@ fn capture_time_shift_is_bounded() {
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged");
}
/// A capture time that doesn't fully read (a Lightroom catalog's `captureTime` is copied
/// verbatim) is left as it is and counted, not shifted into a clean-looking wrong time, and it
/// isn't mistaken for one that would leave years 0000–9999.
#[test]
fn capture_time_shift_counts_photos_whose_time_does_not_read() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (good, bad) = (ids[0], ids[1]);
for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] {
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
}
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap();
assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}");
assert_eq!(r["changed"], 1, "{r}");
let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone();
assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T11:00:00"));
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T10:00:00 and more"), "left as it was");
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T10:00:00"), "one undo step");
// a shift past year 9999 for the readable photo is still the out-of-range refusal, and the
// unreadable one alone isn't
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "hours": 24 * 365 * 9000})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(0), Some(1)), "{r}");
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 24 * 365 * 9000}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("1 photo(s) outside")), "{r:?}");
}
/// Setting the active photo's time moves the others by its change, so an active photo whose own
/// time doesn't read can't anchor it: refused, nothing changes. Alone, or setting every photo's
/// time, it gets the time, which repairs it.
#[test]
fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (bad, other) = (ids[0], ids[1]);
let broken = "2026-01-01T10:00:00 and more";
for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] {
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
}
s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap();
assert_eq!(s.active(), Some(bad));
let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone();
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00"}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("can't be read")), "{r:?}");
assert_eq!(captured(&s, bad).as_deref(), Some(broken), "refused: unchanged");
assert_eq!(captured(&s, other).as_deref(), Some("2026-01-01T12:00:00"), "refused: the others don't move");
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time");
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap();
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}");
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it");
}
/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is
/// refused with the rule's position, by album.createSmart and library.filter alike.
#[test]
+29 -1
View File
@@ -26,7 +26,13 @@ fn get_set_and_reset() {
assert_eq!(g["pageWidth"], 8.5);
assert_eq!(g["printable"]["x"], 0.25);
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4"));
let papers = g["papers"].as_array().unwrap();
assert!(papers.iter().any(|p| p["name"] == "a4"));
// The Japanese photo and postcard papers are listed with their metric sizes in inches.
let paper = |name: &str| papers.iter().find(|p| p["name"] == name).cloned().unwrap_or_else(|| panic!("{name}"));
let l = paper("l");
assert!((l["width"].as_f64().unwrap() - 89.0 / 25.4).abs() < 1e-9 && (l["height"].as_f64().unwrap() - 127.0 / 25.4).abs() < 1e-9);
assert!(papers.iter().any(|p| p["name"] == "postcard") && papers.iter().any(|p| p["name"] == "6cut"));
assert_eq!(g["gridCells"].as_array().unwrap().len(), 1);
let g = run(
@@ -213,6 +219,28 @@ fn export_pdf_prints_the_current_settings() {
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
}
/// The Japanese photo and postcard papers reach the PDF page at their metric sizes: はがき is
/// 100 × 148 mm (283.46 × 419.53 pt) and L is 89 × 127 mm (252.28 × 359.99 pt). 254 ppi is exactly
/// ten pixels per millimetre, so the page pixels are whole millimetres and the MediaBox is exact.
#[test]
fn export_pdf_uses_the_japanese_paper_sizes() {
let mut s = Session::with_demo();
let id = s.catalog.photos().next().map(|p| p.id.0).unwrap();
for (paper, w_pt, h_pt) in [("postcard", 283.46, 419.53), ("はがき", 283.46, 419.53), ("l", 252.28, 359.99)] {
run(&mut s, "print.set", json!({"layout": {"style": "singleImage", "page": {"paper": paper}}, "job": {"ppi": 254.0}}));
let path = temp_pdf(paper);
run(&mut s, "print.exportPdf", json!({"path": path, "ids": [id]}));
let bytes = std::fs::read(&path).unwrap();
let (w, h) = media_box(&bytes);
assert!((w - w_pt).abs() < 0.1 && (h - h_pt).abs() < 0.1, "{paper}: {w} x {h} pt, wanted {w_pt} x {h_pt}");
}
// The customary names are accepted and the page size follows them; an unknown name is refused.
let g = run(&mut s, "print.set", json!({"layout": {"page": {"paper": "はがき"}}}));
assert!((g["pageWidth"].as_f64().unwrap() - 100.0 / 25.4).abs() < 1e-9);
assert!((g["pageHeight"].as_f64().unwrap() - 148.0 / 25.4).abs() < 1e-9);
fails(&mut s, "print.set", json!({"layout": {"page": {"paper": "poster"}}}));
}
#[test]
fn export_pdf_with_a_printer_profile() {
let mut s = Session::with_demo();
+9 -1
View File
@@ -77,16 +77,23 @@ fn status_and_model_commands_without_the_model() {
#[test]
fn a_failing_download_ends_with_an_error_and_never_blocks() {
use std::io::{BufRead, BufReader, Write};
// a local mirror that has nothing
// a local mirror that has nothing; its first answer waits until the test has made its second
// request, so the download is still running then however the threads are scheduled (a 404 at
// once could end it before, and the second request would rightly start a new one)
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/sam3", l.local_addr().unwrap());
let (release, released) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let mut first = Some(released);
for mut c in l.incoming().flatten() {
let mut r = BufReader::new(c.try_clone().unwrap());
let mut line = String::new();
while r.read_line(&mut line).unwrap_or(0) > 2 {
line.clear();
}
if let Some(released) = first.take() {
let _ = released.recv_timeout(Duration::from_secs(10));
}
let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
}
});
@@ -103,6 +110,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
// a second request while it runs starts nothing
let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
assert!(again["started"] == false || again["downloading"] == false);
let _ = release.send(());
let t = Instant::now();
while s.segmenter.download_status().running {
assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
+141 -1
View File
@@ -1,7 +1,7 @@
//! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to
//! be the list worked out from every photo.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use lightcraft_catalog::{Album, Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use crate::Session;
@@ -113,6 +113,23 @@ fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) {
};
}
fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc<std::sync::Mutex<String>>, Vec<PhotoId>) {
let mut s = Session::new();
let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string()));
let clock = now.clone();
s.clock = Box::new(move || clock.lock().unwrap().clone());
let mut ids = Vec::new();
for (index, captured) in captures.iter().enumerate() {
let id = PhotoId(index as u64 + 1);
let name = format!("synthetic-{index}.jpg");
let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00");
photo.captured = Some((*captured).to_string());
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
ids.push(id);
}
(s, now, ids)
}
#[test]
fn following_an_edit_gives_the_list_worked_out_from_every_photo() {
let (mut s, mut ids, album, smart) = library(240);
@@ -161,6 +178,129 @@ fn following_an_edit_gives_the_list_worked_out_from_every_photo() {
assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out);
}
#[test]
fn relative_clock_filter_expires_without_catalog_or_history_mutation() {
let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]);
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap();
let (before_generation, before) = s.visible_shared();
assert_eq!(before.as_ref(), ids.as_slice());
let revision = s.catalog.revision;
let commits = s.commits;
let undo = s.undo.len();
let redo = s.redo.len();
*now.lock().unwrap() = "2026-10-24T12:01:00".to_string();
let (after_generation, after) = s.visible_shared();
assert!(after.is_empty(), "a relative filter must expire when its minute changes");
assert_ne!(after_generation, before_generation, "the changed list gets a new visible generation");
assert_eq!(s.catalog.revision, revision, "clock expiry is not a catalog edit");
assert_eq!(s.commits, commits, "clock expiry adds no undo step");
assert_eq!(s.undo.len(), undo);
assert_eq!(s.redo.len(), redo);
}
#[test]
fn relative_clock_direct_and_nested_smart_album_sources_expire() {
for nested in [false, true] {
let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]);
let recent = s
.execute(
"album.createSmart",
&json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let source = if nested {
s.execute(
"album.createSmart",
&json!({"name": "In Recent", "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": recent}]}}}),
)
.unwrap()["id"]
.as_u64()
.unwrap()
} else {
recent
};
s.execute("library.source", &json!({"kind": "album", "id": source})).unwrap();
assert_eq!(s.visible_cloned(), ids);
let revision = s.catalog.revision;
*now.lock().unwrap() = "2026-10-24T12:01:00".to_string();
assert!(s.visible_cloned().is_empty(), "nested={nested}");
assert_eq!(s.catalog.revision, revision, "nested={nested}");
}
}
#[test]
fn relative_clock_source_total_expires_before_or_after_visible_refresh() {
for refresh_visible in [false, true] {
let (mut s, now, _) = relative_clock_session(&["2026-10-23T12:00:30"]);
let album = s
.execute(
"album.createSmart",
&json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.filter", &json!({"text": "synthetic"})).unwrap();
assert_eq!(s.source_total(), Some(1), "the initial total is computed without asking for visible photos");
let revision = s.catalog.revision;
*now.lock().unwrap() = "2026-10-24T12:01:00".to_string();
if refresh_visible {
s.execute("library.refresh", &json!({})).unwrap();
assert!(s.visible_cloned().is_empty());
}
assert_eq!(s.source_total(), Some(0), "refresh_visible={refresh_visible}");
assert_eq!(s.catalog.revision, revision, "refresh_visible={refresh_visible}");
}
}
#[test]
fn relative_clock_absolute_and_same_minute_views_keep_their_generations() {
let (mut absolute, absolute_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]);
absolute.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "2026-10-23"}]}})).unwrap();
let (absolute_generation, absolute_ids) = absolute.visible_shared();
*absolute_now.lock().unwrap() = "2026-10-25T12:10:00".to_string();
let (after_absolute_generation, after_absolute_ids) = absolute.visible_shared();
assert_eq!(after_absolute_generation, absolute_generation);
assert_eq!(after_absolute_ids, absolute_ids);
let (mut relative, relative_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]);
relative.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap();
let (relative_generation, relative_ids) = relative.visible_shared();
*relative_now.lock().unwrap() = "2026-10-24T12:00:45".to_string();
let (same_minute_generation, same_minute_ids) = relative.visible_shared();
assert_eq!(same_minute_generation, relative_generation);
assert_eq!(same_minute_ids, relative_ids);
}
#[test]
fn relative_clock_unrelated_smart_album_does_not_expire_album_filter_view() {
let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]);
let manual = s.catalog.alloc_album_id();
s.catalog.apply(Op::AddAlbum { album: Album::new(manual, "Manual") }).unwrap();
s.catalog.apply(Op::SetAlbumPhotos { id: manual, photos: vec![ids[0]] }).unwrap();
s.execute(
"album.createSmart",
&json!({"name": "Unrelated recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}),
)
.unwrap();
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": manual.0}]}})).unwrap();
let (generation, visible) = s.visible_shared();
assert_eq!(visible.as_ref(), ids.as_slice());
*now.lock().unwrap() = "2026-10-24T12:01:00".to_string();
let (after_generation, after_visible) = s.visible_shared();
assert_eq!(after_generation, generation, "an unrelated relative smart album must not expire this view");
assert_eq!(after_visible, visible);
}
#[test]
fn a_view_that_asks_about_the_last_days_follows_the_clock_too() {
let (mut s, ids, _, _) = library(60);
+99 -7
View File
@@ -1,11 +1,12 @@
//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter
//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines
//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines
//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out
//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor
//! it before drawing; drawing hands out per-pixel coverage, and the caller blends.
//!
//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers
//! draw as Inter's missing-glyph box: wrong-looking, never a crash.
//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use
//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters
//! no face covers draw as Inter's missing-glyph box.
use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point};
@@ -16,13 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo
pub const MAX_CHARS: usize = 4000;
/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no
/// UI language, so every CJK script is offered. Other script faces stay out until their layout is
/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text.
pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular")
.into_iter()
.filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s)));
std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
@@ -223,3 +227,91 @@ impl TextLayout {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::{CRAFT_FONTS, CraftFont};
fn assert_script_glyphs(script: &str, text: &str) {
if !CRAFT_FONTS.iter().any(|font| font.covers(script)) {
lightcraft_testkit::skip!(Fonts, "built without the {script} face from craft-fonts");
return;
}
let layout = TextLayout::new(text, 40.0, false, CRAFT_FONTS).unwrap();
assert_eq!(layout.glyphs.len(), text.chars().count());
for (ch, (face, glyph, _)) in text.chars().zip(&layout.glyphs) {
assert_ne!(glyph.id.0, 0, "missing {script} glyph for {ch}");
let font = &layout.fonts[*face];
assert!(CRAFT_FONTS.iter().any(|entry| entry.covers(script) && entry.bytes == font.font_data()));
}
let mut covered = 0;
layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0));
assert!(covered > 0, "{script} outlines are rasterized");
}
#[test]
fn bundled_arabic_stays_out_of_export_fallback() {
if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) {
lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts");
return;
}
// UI names still use their existing Arabic face. Export needs joining and bidi first.
assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab")));
for vertical in [false, true] {
let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap();
assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0));
}
}
#[test]
fn bundled_thai_uses_its_glyphs() {
assert_script_glyphs("Thai", "สวัสดีครับ");
}
#[test]
fn absent_fonts_keep_inter_and_missing_glyph_boxes() {
assert_eq!(faces(&[]).len(), 1);
let layout = TextLayout::new("Aกم", 40.0, false, &[]).unwrap();
assert_ne!(layout.glyphs[0].1.id.0, 0);
assert!(layout.glyphs[1..].iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0));
let mut covered = 0;
layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0));
assert!(covered > 0);
}
#[test]
fn latin_and_cjk_rendering_keeps_the_existing_preference() {
let mut cjk: Vec<_> = crate::fonts::cjk(CRAFT_FONTS).collect();
if cjk.is_empty() {
lightcraft_testkit::skip!(Fonts, "built without CJK faces from craft-fonts");
return;
}
cjk.sort_by_key(|font| !font.is_mincho());
let previous: &'static [CraftFont] = Box::leak(
cjk.into_iter()
.map(|font| CraftFont { family: font.family, style: font.style, scripts: font.scripts, bytes: font.bytes })
.collect::<Vec<_>>()
.into_boxed_slice(),
);
let actual = faces(CRAFT_FONTS);
assert!(actual.len() > previous.len());
assert!(actual[0].font_data() == TEXT_FONT, "Inter stays first");
for (font, expected) in actual.iter().skip(1).zip(previous) {
assert!(font.font_data() == expected.bytes, "existing CJK face order: {} {}", expected.family, expected.style);
}
for vertical in [false, true] {
for text in ["LightCraft AV 123", "日本語、。你好世界한국어"] {
let before = TextLayout::new(text, 40.0, vertical, previous).unwrap();
let after = TextLayout::new(text, 40.0, vertical, CRAFT_FONTS).unwrap();
assert_eq!((before.width, before.height), (after.width, after.height));
let pixels = |layout: &TextLayout| {
let mut pixels = Vec::new();
layout.draw(0.0, 0.0, |x, y, coverage| pixels.push((x, y, coverage)));
pixels
};
assert!(pixels(&before) == pixels(&after), "{text}, vertical={vertical}");
}
}
}
}
+11 -5
View File
@@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
if let Some(fl) = f.flag {
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
}
let mut labels = f.labels.clone();
labels.extend(f.label.filter(|l| !labels.contains(l)));
if !labels.is_empty() {
let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect();
add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []}));
if let Some(shown) = f.labels_shown() {
let or = |ls: &[lightcraft_catalog::ColorLabel]| ls.iter().map(|l| cat.label_name(*l)).collect::<Vec<_>>().join(" or ");
// empty: `label` and `labels` exclude each other, so name both constraints
let names = if shown.is_empty() {
// parenthesised when plural, so "Red and Yellow or Green" cannot read as "(Red and Yellow) or Green"
let plural = if f.labels.len() > 1 { format!("({})", or(&f.labels)) } else { or(&f.labels) };
format!("{} and {plural}", f.label.map(|l| cat.label_name(l)).unwrap_or_default())
} else {
or(&shown)
};
add(format!("Label: {names}"), json!({"label": Null, "labels": []}));
}
if let Some(k) = f.kind {
let n = match k {
+8 -6
View File
@@ -450,11 +450,12 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
}
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do
/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and
/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes
/// its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange
}
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
@@ -473,7 +474,8 @@ pub fn try_render(
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) {
let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some());
if !enabled() || !handles(req.depth, device_proof, req.overlay) {
return Err(Fallback::NotTried);
}
let s = &*lightcraft_pipeline::settings_for(s, req);
+15
View File
@@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[
("OUT_Y", 3),
("OUT_TRC", 1),
("OUT_GAMMA", 1),
// soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings
("PROOF", 1),
("PROOF_M", 9),
("PROOF_Y", 3),
("PROOF_OUT", 9),
("PROOF_DEST_WARN", 1),
("PROOF_DISP_WARN", 1),
// first row of a band dispatch (the kernel runs over rows Y0.., see `render`)
("Y0", 1),
];
@@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
let (trc, gamma) = fp.out_trc.code();
p.u("OUT_TRC", trc);
p.f("OUT_GAMMA", gamma);
if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) {
p.b("PROOF", true);
p.fs("PROOF_M", pp.to_proof.as_flattened());
p.fs("PROOF_Y", &pp.luma);
p.fs("PROOF_OUT", pp.proof_to_out.as_flattened());
p.b("PROOF_DEST_WARN", pp.dest_warning);
p.b("PROOF_DISP_WARN", pp.display_warning);
}
if let Some((amt, cell, rough, seed)) = fp.grain {
p.b("GRAIN", true);
p.f("GRAIN_AMT", amt);
+3 -1
View File
@@ -669,8 +669,10 @@ pub fn render(
// 5. per-pixel stage
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
// soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`)
fp.proof = req.proof.map(|pr| pr.params(req.space));
if let Some(d) = &req.display {
fp.for_display(d, None);
fp.for_display(d, req.proof);
}
let present = Present {
clarity: prep.clarity.is_some(),
+67 -18
View File
@@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 {
return 1.099 * pow(l, 0.45) - 0.099;
}
// The 3×3 matrix stored row by row from field `o`.
fn mat_at(o: u32) -> array<vec3<f32>, 3> {
return array<vec3<f32>, 3>(
vec3<f32>(pf(o), pf(o + 1u), pf(o + 2u)),
vec3<f32>(pf(o + 3u), pf(o + 4u), pf(o + 5u)),
vec3<f32>(pf(o + 6u), pf(o + 7u), pf(o + 8u)),
);
}
// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ...
fn gamut_scale(r: vec3<f32>, luma: vec3<f32>) -> f32 {
let yy = clamp(dot(luma, r), 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
if (cc < 0.0) {
tg = min(tg, yy / max(yy - cc, 1e-9));
} else if (cc > 1.0) {
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
}
}
return tg;
}
// ... and `r` scaled by it.
fn gamut_apply(r: vec3<f32>, luma: vec3<f32>, tg: f32) -> vec3<f32> {
if (tg < 1.0) {
let yy = clamp(dot(luma, r), 0.0, 1.0);
return yy + (r - yy) * tg;
}
return r;
}
// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black.
fn out_of_gamut(r: vec3<f32>, t: f32) -> bool {
let lo = min(r.x, min(r.y, r.z));
let hi = max(r.x, max(r.y, r.z));
return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0));
}
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let w = pu(F_W);
@@ -324,8 +364,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// --- dehaze (scene linear)
let dz = pf(F_DEHAZE) + lt[10];
if (dz != 0.0 && pu(F_HAS_DARK) != 0u) {
let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0);
// the dark channel is a blur, not a patch minimum: never attribute more veil than the
// pixel's own minimum channel, or dark pixels next to bright ones crush to black (#2504)
let air = pf(F_AIR);
let cmin = min(c.x, min(c.y, c.z));
let d = clamp(min(select(0.0, dark[i] / pf(F_AIR_PRE), pf(F_AIR_PRE) > 0.0), select(0.0, cmin / air, air > 0.0)), 0.0, 1.0);
if (dz > 0.0) {
let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12);
c = max((c - air * (1.0 - t)) / t, vec3<f32>(0.0));
@@ -467,26 +510,27 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
// --- gamut map to the output space (desaturate towards luminance until in range)
let om = array<vec3<f32>, 3>(
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
);
var r = mul3(om, d);
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
if (cc < 0.0) {
tg = min(tg, yy / max(yy - cc, 1e-9));
} else if (cc > 1.0) {
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
// --- gamut map to the output space (desaturate towards luminance until in range);
// soft proofing maps into the proof space first and shows that in the output space
// (warn: 0 none, 1 destination gamut, 2 display gamut)
var warn = 0u;
var r: vec3<f32>;
if (pu(F_PROOF) != 0u) {
let q0 = mul3(mat_at(F_PROOF_M), d);
let tq = gamut_scale(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)));
if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) {
warn = 1u;
}
r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq));
} else {
r = mul3(mat_at(F_OUT_M), d);
}
if (tg < 1.0) {
r = yy + (r - yy) * tg;
let oy = vec3<f32>(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u));
let tg = gamut_scale(r, oy);
if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) {
warn = 2u;
}
r = gamut_apply(r, oy, tg);
// --- encode, curves, grain
var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z));
@@ -512,6 +556,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e + k;
}
if (warn == 1u) {
e = vec3<f32>(1.0, 0.0, 0.0);
} else if (warn == 2u) {
e = vec3<f32>(0.0, 0.25, 1.0);
}
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
}
+64
View File
@@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings,
(mean, max)
}
/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a
/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding).
/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds.
fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) {
let cpu = render(src, info, s, req).image;
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
let (mean, _, over) = diff(&cpu, &gpu.image);
let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count();
let share = flips as f64 / cpu.data.len() as f64;
eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0);
assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB");
}
type Edit = fn(&mut DevelopSettings);
fn typical(s: &mut DevelopSettings) {
@@ -877,3 +890,54 @@ fn windows_of_a_big_photo_share_one_uploaded_source() {
lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render");
assert_eq!(lightcraft_gpu::source_uploads() - start, 2);
}
#[test]
fn soft_proofs_match() {
if !gpu() {
return;
}
// Soft proofing against an output space (matrix proofs): into the proof gamut, back to the
// output space, with the destination and display gamut warnings painted alike on both renderers.
// Saturated edits put plenty of colours outside the smaller proof spaces.
use lightcraft_pipeline::{OutputSpace, Proof};
let src = scene(0, 900, 600);
let raw = SourceInfo { raw: true, ..Default::default() };
for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] {
for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] {
for out in [OutputSpace::Srgb, OutputSpace::Rec2020] {
let mut s = DevelopSettings::default();
saturated(&mut s);
let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 });
let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) };
let name = format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}");
if dest_warning || display_warning {
check_warned(&name, &src, &raw, &s, &req);
} else {
check(&name, &src, &raw, &s, &req);
}
}
}
}
// the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones
let mut s = DevelopSettings::default();
saturated(&mut s);
let req = RenderRequest {
space: OutputSpace::Rec2020,
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }),
..RenderRequest::fit(640, 640)
};
let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count();
let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image);
assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu));
let (a, b) = (warned(&cpu), warned(&gpu));
assert!(a.abs_diff(b) as f64 <= 1e-4 * cpu.data.len() as f64, "warned pixels: CPU {a}, GPU {b}");
// an output-device profile (a 3D table) stays on the CPU
let n = 2;
let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap();
let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut);
let req = RenderRequest {
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }),
..RenderRequest::fit(64, 64)
};
assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it");
}
+2 -16
View File
@@ -5,7 +5,6 @@
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::resample::{Filter, fit};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::features::{self, Features};
use crate::ransac::{self, Model};
@@ -129,23 +128,10 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped
/// image and a validity mask (false where `h(p)` falls outside `img`).
pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
if *h == Homography::IDENTITY {
return (img.clone(), vec![true; w * ht]);
return (img.clone(), vec![true; img.width * img.height]);
}
let mut out = Rgb32f::new(w, ht);
let mut valid = vec![false; w * ht];
out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| {
for x in 0..w {
let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let (sx, sy) = (p.x as f32, p.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
});
(out, valid)
crate::flow::warp(img, h, &crate::flow::Flow::default())
}
#[cfg(test)]
+239
View File
@@ -0,0 +1,239 @@
//! Local alignment: what one motion model for the whole frame leaves.
//!
//! A homography is exact only for a camera that turned about its lens. Handheld, the camera also
//! moves, and things at different distances shift differently (parallax): on a real bracket (Sony,
//! 33 MP, gravel a few metres away and a building a kilometre off) the fit, dominated by the
//! foreground's texture, left the building 2.3 px off, and the merge doubled its inscription. Lens
//! distortion and rolling shutter leave local shifts too.
//!
//! After the global fit ([`crate::align`], [`crate::refine`]) the frame is compared with the
//! reference tile by tile:
//!
//! 1. **Measure.** On a grid of about [`GRID`] tiles along the long edge, the reference's patch at
//! the tile's centre is found in the frame, through the global fit, within [`RADIUS`] pixels
//! ([`crate::refine::match_patch`]: masked correlation, sub-pixel peak, ambiguous and textureless
//! patches dropped).
//! 2. **Clean.** A shift more than [`OUTLIER`] px from the median of its measured neighbours is
//! dropped (a person walking, a mismatch), as is one with no measured neighbour to check it by. Tiles without a measurement take the mean of their
//! measured neighbours, spreading outwards until every tile has one.
//! 3. **Smooth** with two 3 × 3 box passes, so the field bends gently.
//! 4. **Warp.** Each output pixel `p` samples the frame at `h(p + flow(p))` (the flow interpolated
//! bilinearly between tile centres): one Lanczos-3 resampling for both corrections.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::refine::{self, PATCH};
/// Tiles along the long edge.
pub const GRID: usize = 96;
/// Largest local shift measured (pixels); farther ones are filled from neighbours.
pub const RADIUS: usize = 5;
/// A tile this far (pixels) from the median of its neighbours is an outlier.
pub const OUTLIER: f32 = 0.75;
/// A smooth field of local shifts in reference pixels: one value per tile, tile `(i, j)`'s at
/// `((i + ½)·step, (j + ½)·step)`, so pixel centres of `dx` / `dy` line up with the tiles'.
#[derive(Clone, Debug, PartialEq)]
pub struct Flow {
pub step: usize,
pub dx: Plane,
pub dy: Plane,
/// Tiles that were measured (before filling).
pub measured: usize,
}
impl Default for Flow {
/// No shift anywhere.
fn default() -> Self {
Flow { step: 0, dx: Plane::new(0, 0), dy: Plane::new(0, 0), measured: 0 }
}
}
impl Flow {
/// The shift at reference coordinates (pixel centres at +0.5), bilinear between tile centres;
/// zero for an empty flow.
pub fn at(&self, x: f64, y: f64) -> (f64, f64) {
let (fx, fy) = ((x / self.step as f64) as f32, (y / self.step as f64) as f32);
if self.dx.data.is_empty() || !fx.is_finite() || !fy.is_finite() {
return (0.0, 0.0);
}
(crate::features::bilinear(&self.dx, fx, fy) as f64, crate::features::bilinear(&self.dy, fx, fy) as f64)
}
/// The largest shift in the field (pixels).
pub fn max(&self) -> f32 {
self.dx.data.iter().zip(&self.dy.data).map(|(x, y)| x.hypot(*y)).fold(0.0, f32::max)
}
}
/// The local shifts between `reference` and `frame` (both from [`refine::luma`]) once `h` (reference
/// → frame pixels) is applied, within `radius` pixels.
pub fn estimate(reference: &Plane, frame: &Plane, h: &Homography, radius: usize) -> Flow {
let (w, ht) = (reference.width, reference.height);
// an even step, so a patch centred on a tile has a whole-pixel corner
let step = ((w.max(ht) / GRID).max(PATCH) + 1) & !1;
let (gw, gh) = (w / step, ht / step);
if gw == 0 || gh == 0 || frame.data.is_empty() {
return Flow::default();
}
let r = radius.max(1);
let raw: Vec<Option<[f32; 2]>> = (0..gw * gh)
.into_par_iter()
.map(|t| {
let (i, j) = (t % gw, t / gw);
let (sx, sy) = (i * step + (step - PATCH) / 2, j * step + (step - PATCH) / 2);
if sx < r + 1 || sy < r + 1 || sx + PATCH + r + 1 > w || sy + PATCH + r + 1 > ht {
return None;
}
refine::match_patch(reference, (sx, sy), frame, h, r).map(|m| [m.offset.x as f32, m.offset.y as f32])
})
.collect();
let measured = raw.iter().filter(|v| v.is_some()).count();
let at = |d: &[Option<[f32; 2]>], k: usize| d.get(k).copied().flatten();
// 2. outliers against the median of their measured neighbours
let mut d: Vec<Option<[f32; 2]>> = (0..gw * gh)
.map(|t| {
let v = at(&raw, t)?;
let n = neighbours(|k| at(&raw, k), gw, gh, t);
let med = [median(n.iter().map(|v| v[0]).collect()), median(n.iter().map(|v| v[1]).collect())];
// a tile with no measured neighbour can't be checked, and the fill would spread it: dropped
(!n.is_empty() && (v[0] - med[0]).hypot(v[1] - med[1]) <= OUTLIER).then_some(v)
})
.collect();
// fill outwards from measured tiles
for _ in 0..gw + gh {
if d.iter().all(Option::is_some) || d.iter().all(Option::is_none) {
break;
}
let prev = d.clone();
for (t, slot) in d.iter_mut().enumerate().filter(|(_, s)| s.is_none()) {
*slot = mean(&neighbours(|k| at(&prev, k), gw, gh, t));
}
}
let mut f: Vec<[f32; 2]> = d.into_iter().map(|v| v.unwrap_or([0.0; 2])).collect();
// 3. smooth (each tile with its neighbours)
for _ in 0..2 {
let prev = f.clone();
for (t, v) in f.iter_mut().enumerate() {
let mut n = neighbours(|k| prev.get(k).copied(), gw, gh, t);
n.push(*v);
*v = mean(&n).unwrap_or(*v);
}
}
let plane = |c: usize| Plane::from_fn(gw, gh, |i, j| f.get(j * gw + i).map_or(0.0, |v| v[c]));
Flow { step, dx: plane(0), dy: plane(1), measured }
}
/// The values `get` has among the 8 neighbours of tile `t`.
fn neighbours(get: impl Fn(usize) -> Option<[f32; 2]>, gw: usize, gh: usize, t: usize) -> Vec<[f32; 2]> {
let (i, j) = (t % gw, t / gw);
let mut out = Vec::with_capacity(8);
for b in j.saturating_sub(1)..=(j + 1).min(gh - 1) {
for a in i.saturating_sub(1)..=(i + 1).min(gw - 1) {
if (a, b) != (i, j)
&& let Some(v) = get(b * gw + a)
{
out.push(v);
}
}
}
out
}
fn mean(n: &[[f32; 2]]) -> Option<[f32; 2]> {
let k = n.len() as f32;
(!n.is_empty()).then(|| [n.iter().map(|v| v[0]).sum::<f32>() / k, n.iter().map(|v| v[1]).sum::<f32>() / k])
}
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.total_cmp(b));
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// `img` resampled into the reference's geometry through `h` after the local shifts:
/// `out(p) = img(h(p + flow(p)))` (Lanczos-3), and where that falls inside `img`.
pub fn warp(img: &Rgb32f, h: &Homography, flow: &Flow) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
let mut out = Rgb32f::new(w, ht);
let mut valid = vec![false; w * ht];
if w == 0 || ht == 0 {
return (out, valid);
}
out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| {
for x in 0..w {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let (dx, dy) = flow.at(px, py);
let q = h.apply(Point::new(px + dx, py + dy));
let (sx, sy) = (q.x as f32, q.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
});
(out, valid)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::refine::tests::tex;
/// Parallax: the top of the frame (far) shifted 2.2 px against the bottom (near), with a
/// smooth band between. The flow finds both, and the warp lines the frame up everywhere.
#[test]
fn finds_and_undoes_a_local_shift() {
let (w, h) = (1200usize, 800usize);
let shift = |y: f64| {
let t = ((y - 300.0) / 200.0).clamp(0.0, 1.0);
let s = t * t * (3.0 - 2.0 * t);
((1.0 - s) * 2.2, (1.0 - s) * -1.1)
};
let reference = Plane::from_fn(w, h, |x, y| tex(x as f64 + 0.5, y as f64 + 0.5));
// the frame's pixel q shows the scene at q + shift(q.y)
let frame = Rgb32f::from_fn(w, h, |x, y| {
let (sx, sy) = shift(y as f64 + 0.5);
[tex(x as f64 + 0.5 + sx, y as f64 + 0.5 + sy); 3]
});
let fl = refine::luma(&frame, 1.0, 10.0);
let flow = estimate(&reference, &fl, &Homography::IDENTITY, RADIUS);
let tiles = flow.dx.width * flow.dx.height;
assert!(flow.measured > tiles / 2, "{} of {tiles}", flow.measured);
// reference p matches frame p + d with d = −shift
let (tx, ty) = flow.at(600.0, 100.0);
assert!((tx + 2.2).abs() < 0.15 && (ty - 1.1).abs() < 0.15, "top {tx} {ty}");
let (bx, by) = flow.at(600.0, 700.0);
assert!(bx.abs() < 0.15 && by.abs() < 0.15, "bottom {bx} {by}");
let (out, valid) = warp(&frame, &Homography::IDENTITY, &flow);
let error = |img: &Rgb32f| {
let (mut e, mut n) = (0f64, 0usize);
for y in (40..h - 40).step_by(3) {
for x in (40..w - 40).step_by(3) {
if valid[y * w + x] {
e += (img.get(x, y)[0] - reference.get(x, y)).abs() as f64;
n += 1;
}
}
}
e / n as f64
};
let (before, after) = (error(&frame), error(&out));
assert!(after < before / 6.0, "mean error {before} unwarped, {after} warped");
}
#[test]
fn empty_and_flat_inputs_give_no_shift() {
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
let f = estimate(&flat, &flat, &Homography::IDENTITY, RADIUS);
assert_eq!(f.measured, 0);
assert_eq!(f.max(), 0.0);
assert_eq!(estimate(&Plane::new(0, 0), &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(estimate(&flat, &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(Flow::default().at(5.0, f64::NAN), (0.0, 0.0));
assert_eq!(f.at(f64::INFINITY, 3.0), (0.0, 0.0));
let (img, v) = warp(&Rgb32f::new(0, 0), &Homography::IDENTITY, &Flow::default());
assert!(img.data.is_empty() && v.is_empty());
}
}
+2
View File
@@ -15,12 +15,14 @@
pub mod align;
pub mod features;
pub mod flow;
pub mod frame;
pub mod hdr;
pub mod linalg;
pub mod output;
pub mod pano;
pub mod ransac;
pub mod refine;
pub mod synth;
pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with};
+359
View File
@@ -0,0 +1,359 @@
//! Full-resolution refinement of an alignment found on small versions of the frames.
//!
//! [`crate::align`] registers frames on versions of at most [`crate::align::WORK_EDGE`] pixels.
//! For a 24–60 MP photo that is 5–8× smaller, and a tenth of a work pixel off is most of a full
//! pixel off: merged frames that far apart blur the result. Here the motion is fitted again at
//! full size:
//!
//! 1. **Sites.** The reference is cut into a grid of cells (≈ [`CELLS`] along the long edge); in
//! each, the [`PATCH`]-pixel square with the most two-dimensional texture (the smaller
//! eigenvalue of its gradient structure tensor, so a straight edge, which only fixes one
//! direction, doesn't count) is a site.
//! 2. **Matching.** Each site's patch is compared with the frame resampled through the current
//! alignment, by normalised cross-correlation (immune to the frames' different brightness and
//! contrast) over integer offsets within a search radius, counting only pixels usable in both
//! (clipped ones aren't); the peak is refined to a fraction of a pixel by a parabola through its
//! neighbours on each axis. Patches less than half usable, with too little texture, a weak peak, a peak on the search border or a second peak almost
//! as strong (repeating texture) are dropped.
//! 3. **Fit.** The matches are fitted with RANSAC at [`THRESH`] pixels, choosing the simplest of
//! translation / similarity / homography that explains (nearly) as many as the most general
//! one, as [`crate::align::align_pair`] does. A second, narrower pass starts from that fit.
//!
//! When too few patches match, or the fit isn't a plausible handheld motion, the alignment found
//! on the small versions stays: refining never makes an alignment worse than not refining.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::align::{Alignment, Models, choose_model, luma as lum};
use crate::ransac::Model;
/// Side of a matched patch (pixels).
pub const PATCH: usize = 48;
/// Cells along the long edge (sites are picked one per cell).
pub const CELLS: usize = 24;
/// RANSAC inlier threshold at full resolution (pixels).
pub const THRESH: f64 = 0.75;
/// Weakest correlation peak a match may have.
const MIN_SCORE: f32 = 0.8;
/// The best peak away from the best one must be at least this much weaker.
const UNIQUE: f32 = 0.03;
/// Least standard deviation (of the gamma-encoded luminance) a patch needs to be matched.
const MIN_STD: f32 = 0.01;
/// Fewest matches a fit is made from.
const MIN_MATCHES: usize = 12;
/// Share of the matches the fit must explain.
const MIN_SHARE: f64 = 0.4;
/// A frame's luminance for matching: `(luminance · gain)^(1/2.2)` at full size, NaN where the
/// frame is clipped (any channel at or above 95 % of `clip`) or `gain` takes it past 1, so such
/// pixels never take part. `gain` brings frames of a bracket to the same brightness.
pub fn luma(img: &Rgb32f, gain: f32, clip: f32) -> Plane {
let g = if gain.is_finite() && gain > 0.0 { gain } else { 1.0 };
img.map(|c| {
if c[0].max(c[1]).max(c[2]) >= 0.95 * clip {
return f32::NAN;
}
let y = lum(c) * g;
if y.is_finite() && y < 1.0 { y.max(0.0).powf(1.0 / 2.2) } else { f32::NAN }
})
}
/// The search radius (full-resolution pixels) for an alignment found at `scale` (work pixels per
/// full pixel): about two work pixels.
pub fn radius_for(scale: f64) -> usize {
if scale.is_finite() && scale > 0.0 { ((2.0 / scale).ceil() as usize + 1).clamp(3, 16) } else { 3 }
}
/// A patch of the reference found in a frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Match {
/// Centre of the patch in the reference (pixel centres at +0.5).
pub reference: Point,
/// Where it is in the frame.
pub frame: Point,
/// The offset from where the alignment put it, in reference pixels.
pub offset: Point,
/// Correlation at the peak (−1..1).
pub score: f32,
}
/// Top-left corners of the patches to match: one per cell, where the reference has the most
/// two-dimensional texture, `margin` pixels clear of the edges.
pub fn sites(reference: &Plane, margin: usize) -> Vec<(usize, usize)> {
let (w, h) = (reference.width, reference.height);
let need = PATCH + 2 * margin;
if w < need || h < need {
return Vec::new();
}
let long = w.max(h);
let cell = long.div_ceil(CELLS).max(PATCH);
let (nx, ny) = ((w - 2 * margin) / cell, (h - 2 * margin) / cell);
let (nx, ny) = (nx.max(1), ny.max(1));
let (cw, chh) = ((w - 2 * margin) / nx, (h - 2 * margin) / ny);
(0..nx * ny)
.into_par_iter()
.filter_map(|c| {
let (cx, cy) = (margin + (c % nx) * cw, margin + (c / nx) * chh);
let span_x = cw.saturating_sub(PATCH);
let span_y = chh.saturating_sub(PATCH);
let mut best: Option<((usize, usize), f32)> = None;
for j in 0..3 {
for i in 0..3 {
let (x, y) = (cx + span_x * i / 2, cy + span_y * j / 2);
if x + PATCH > w - margin || y + PATCH > h - margin {
continue;
}
if let Some(t) = texture(reference, x, y)
&& best.is_none_or(|(_, b)| t > b)
{
best = Some(((x, y), t));
}
}
}
best.filter(|(_, t)| *t > 1e-6).map(|(p, _)| p)
})
.collect()
}
/// The smaller eigenvalue of the patch's mean gradient structure tensor, over its usable pixels;
/// `None` when fewer than half of them are.
fn texture(p: &Plane, x0: usize, y0: usize) -> Option<f32> {
let w = p.width;
let at = |x: usize, y: usize| p.data.get(y * w + x).copied().unwrap_or(f32::NAN);
let (mut sxx, mut syy, mut sxy, mut n) = (0f64, 0f64, 0f64, 0usize);
for y in y0 + 1..y0 + PATCH - 1 {
for x in x0 + 1..x0 + PATCH - 1 {
let gx = (at(x + 1, y) - at(x - 1, y)) as f64;
let gy = (at(x, y + 1) - at(x, y - 1)) as f64;
if gx.is_finite() && gy.is_finite() {
sxx += gx * gx;
syy += gy * gy;
sxy += gx * gy;
n += 1;
}
}
}
if n * 2 < (PATCH - 2) * (PATCH - 2) {
return None;
}
let n = n as f64;
let (a, b, c) = (sxx / n, syy / n, sxy / n);
let tr = a + b;
let det = a * b - c * c;
Some((tr / 2.0 - ((tr * tr / 4.0 - det).max(0.0)).sqrt()) as f32)
}
/// Find the reference patch at `site` (top-left) in `frame`, which `h` maps reference pixels into,
/// searching offsets up to `radius` pixels.
pub fn match_patch(reference: &Plane, site: (usize, usize), frame: &Plane, h: &Homography, radius: usize) -> Option<Match> {
let (sx, sy) = site;
let (rw, rh) = (reference.width, reference.height);
if sx + PATCH > rw || sy + PATCH > rh || frame.width == 0 || frame.height == 0 {
return None;
}
let r = radius.max(1);
let s = PATCH + 2 * r;
let mut pr: Vec<f32> = Vec::with_capacity(PATCH * PATCH);
for y in sy..sy + PATCH {
for x in sx..sx + PATCH {
pr.push(reference.data.get(y * rw + x).copied().unwrap_or(f32::NAN));
}
}
// the frame around the patch, through `h`; NaN where it can't be used
let (fw, fh) = (frame.width as f64, frame.height as f64);
let mut win = vec![f32::NAN; s * s];
for j in 0..s {
for i in 0..s {
let q = h.apply(Point::new((sx + i) as f64 - r as f64 + 0.5, (sy + j) as f64 - r as f64 + 0.5));
if !(q.x >= 0.5 && q.y >= 0.5 && q.x <= fw - 0.5 && q.y <= fh - 0.5) {
return None;
}
win[j * s + i] = crate::features::bilinear(frame, q.x as f32, q.y as f32);
}
}
let d = 2 * r + 1;
// offsets that can't be scored (too few usable pixels) stay at −∞
let mut score = vec![f32::NEG_INFINITY; d * d];
for dy in 0..d {
for dx in 0..d {
if let Some(c) = correlation(&pr, |u, v| win.get((v + dy) * s + dx + u).copied().unwrap_or(f32::NAN)) {
score[dy * d + dx] = c;
}
}
}
let (bi, &best) = score.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?;
let (bx, by) = (bi % d, bi / d);
if best < MIN_SCORE || bx == 0 || by == 0 || bx == d - 1 || by == d - 1 {
return None;
}
// a second peak nearly as strong: repeating texture, the match is ambiguous
let second =
score.iter().enumerate().filter(|(k, _)| (k % d).abs_diff(bx) > 1 || (k / d).abs_diff(by) > 1).map(|(_, v)| *v).fold(-1f32, f32::max);
if second > best - UNIQUE {
return None;
}
let at = |x: usize, y: usize| score[y * d + x];
// the sub-pixel parabola needs the peak's four neighbours: one that couldn't be scored would pull it away
if [at(bx - 1, by), at(bx + 1, by), at(bx, by - 1), at(bx, by + 1)].iter().any(|v| !v.is_finite()) {
return None;
}
let sub = |m: f32, c: f32, p: f32| {
let den = m - 2.0 * c + p;
if den < -1e-9 { (0.5 * (m - p) / den).clamp(-0.5, 0.5) } else { 0.0 }
};
let ox = bx as f64 - r as f64 + sub(at(bx - 1, by), best, at(bx + 1, by)) as f64;
let oy = by as f64 - r as f64 + sub(at(bx, by - 1), best, at(bx, by + 1)) as f64;
let c = Point::new(sx as f64 + PATCH as f64 / 2.0, sy as f64 + PATCH as f64 / 2.0);
Some(Match { reference: c, frame: h.apply(Point::new(c.x + ox, c.y + oy)), offset: Point::new(ox, oy), score: best })
}
/// Correlation of the reference patch `a` (row-major, [`PATCH`] wide) with `b(u, v)` over the
/// pixels usable in both; `None` with fewer than half of the patch usable, or too little texture in
/// either.
fn correlation(a: &[f32], b: impl Fn(usize, usize) -> f32) -> Option<f32> {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0usize, 0f64, 0f64, 0f64, 0f64, 0f64);
for v in 0..PATCH {
for u in 0..PATCH {
let (x, y) = (a.get(v * PATCH + u).copied().unwrap_or(f32::NAN), b(u, v));
if x.is_finite() && y.is_finite() {
let (x, y) = (x as f64, y as f64);
n += 1;
sa += x;
sb += y;
saa += x * x;
sbb += y * y;
sab += x * y;
}
}
}
if n * 2 < PATCH * PATCH {
return None;
}
let nf = n as f64;
let (ma, mb) = (sa / nf, sb / nf);
let (va, vb) = (saa / nf - ma * ma, sbb / nf - mb * mb);
let min = (MIN_STD as f64).powi(2);
if !(va >= min && vb >= min * 0.25) {
return None;
}
let c = (sab / nf - ma * mb) / (va * vb).sqrt();
c.is_finite().then_some(c as f32)
}
/// Translation, similarity and homography: what a handheld frame may need.
pub const ALL_MODELS: [(Model, &str, usize); 3] =
[(Model::Translation, "translation", 6), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 12)];
/// Search radius (pixels) for a frame with no alignment to start from (the small versions found
/// none: a bright frame mostly clipped, too little texture left for features).
pub const RECOVER_RADIUS: usize = 16;
/// Fit `models` (reference → frame, full resolution) to patches of `reference` matched in `frame`
/// around `start` within `radius` pixels, then again within 2 px of that fit; both planes from
/// [`luma`]. `start` is an alignment found on small versions (`radius` from [`radius_for`]), or the
/// identity with [`RECOVER_RADIUS`]. `None` when too few patches match or no fit is a plausible
/// handheld motion: the caller keeps what it had.
pub fn fit(reference: &Plane, frame: &Plane, start: &Homography, radius: usize, seed: u64, models: &Models) -> Option<Alignment> {
let (w, ht) = (reference.width as f64, reference.height as f64);
let r0 = radius.max(2);
// the sites for the widest search fit the narrower one too
let s = sites(reference, r0 + 2);
let mut cur: Option<Alignment> = None;
for r in [r0, 2] {
let h = cur.as_ref().map_or(*start, |a| a.h);
let m: Vec<Match> = s.par_iter().filter_map(|&p| match_patch(reference, p, frame, &h, r)).collect();
if m.len() < MIN_MATCHES {
break;
}
let pa: Vec<Point> = m.iter().map(|m| m.reference).collect();
let pb: Vec<Point> = m.iter().map(|m| m.frame).collect();
let Some((name, f)) = choose_model(models, &pa, &pb, THRESH, MIN_SHARE, (w, ht), seed) else { break };
cur = Some(Alignment { h: f.h, model: name, inliers: f.inliers.len(), rms: f.rms });
}
cur
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A smooth random texture, sampled anywhere.
pub(crate) fn tex(x: f64, y: f64) -> f32 {
let mut v = 0.45;
for k in 0..9 {
let a = k as f64 * 2.399;
let f = 0.02 + 0.025 * k as f64;
v += 0.05 * ((x * a.cos() + y * a.sin()) * f * std::f64::consts::TAU + k as f64).sin();
}
v as f32
}
fn plane(w: usize, h: usize, map: &Homography) -> Plane {
Plane::from_fn(w, h, |x, y| {
let p = map.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
tex(p.x, p.y)
})
}
fn corner_error(a: &Homography, b: &Homography, w: f64, h: f64) -> f64 {
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| a.apply(Point::new(x, y)).dist(b.apply(Point::new(x, y)))).fold(0.0, f64::max)
}
/// A frame rotated 0.2° and moved 1.37 px, given an alignment 2 px off: refined to a tenth of
/// a pixel at the corners.
#[test]
fn refines_a_rough_alignment_to_a_fraction_of_a_pixel() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
// the frame's pixel q shows the scene at s(q); reference → frame is s⁻¹
let (c, sn) = (0.2f64.to_radians().cos(), 0.2f64.to_radians().sin());
let s = Homography([c, -sn, 1.37, sn, c, -0.83, 0.0, 0.0, 1.0]);
let truth = s.inverse().unwrap();
let frame = plane(w, h, &s);
let rough = Homography([truth.0[0], truth.0[1], truth.0[2] + 1.6, truth.0[3], truth.0[4], truth.0[5] - 1.2, 0.0, 0.0, 1.0]);
assert!(corner_error(&rough, &truth, w as f64, h as f64) > 1.5);
let r = fit(&reference, &frame, &rough, 4, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &truth, w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(r.inliers >= MIN_MATCHES);
}
/// A frame the small versions couldn't align, 5 px and 0.1° off: found from nothing.
#[test]
fn recovers_a_frame_with_no_alignment() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
let (c, sn) = (0.1f64.to_radians().cos(), 0.1f64.to_radians().sin());
let s = Homography([c, -sn, -4.6, sn, c, 2.1, 0.0, 0.0, 1.0]);
let r = fit(&reference, &plane(w, h, &s), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &s.inverse().unwrap(), w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(fit(&reference, &Plane::from_fn(w, h, |_, _| 0.5), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).is_none());
}
/// Nothing to match (a flat frame, planes too small for a patch, empty ones): no fit.
#[test]
fn no_fit_when_nothing_matches() {
let h = Homography([1.0, 0.0, 0.7, 0.0, 1.0, 0.2, 0.0, 0.0, 1.0]);
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
assert!(fit(&flat, &flat, &h, 4, 1, &ALL_MODELS).is_none());
let tiny = Plane::from_fn(20, 20, |x, _| x as f32);
assert!(fit(&tiny, &tiny, &h, 4, 1, &ALL_MODELS).is_none());
assert!(fit(&Plane::new(0, 0), &Plane::new(0, 0), &h, 4, 1, &ALL_MODELS).is_none());
}
#[test]
fn clipped_pixels_and_odd_gains_are_left_out() {
let img = Rgb32f::from_fn(4, 1, |x, _| [x as f32 * 0.33; 3]);
let l = luma(&img, 1.0, 1.0);
assert!(l.data[0] == 0.0 && l.data[3].is_nan(), "{:?}", l.data);
let l = luma(&img, 4.0, 1.0);
assert!(l.data[1].is_nan(), "0.33 × 4 is past 1: {:?}", l.data);
assert!(luma(&img, f32::NAN, 1.0).data[1].is_finite());
assert_eq!(radius_for(0.125), 16);
assert_eq!(radius_for(1.0), 3);
assert_eq!(radius_for(f64::NAN), 3);
}
}
+18 -1
View File
@@ -456,6 +456,14 @@ pub(crate) mod tests {
}
}
/// An exact `1/N` rational for a sub-second value whose reciprocal is a whole number (shutter speeds such as
/// 1/12800 s, which [`lightcraft_tiff::writer::rational`]'s decimal denominators cannot represent).
fn reciprocal(v: Option<f64>) -> Option<lightcraft_tiff::Value> {
let v = v.filter(|v| v.is_finite() && *v > 0.0 && *v < 1.0)?;
let inv = 1.0 / v;
((inv - inv.round()).abs() < 1e-6 && inv.round() <= u32::MAX as f64).then(|| lightcraft_tiff::Value::Rational(vec![(1, inv.round() as u32)]))
}
/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no
/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation,
/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD.
@@ -483,7 +491,7 @@ pub fn write_exif(meta: &Metadata) -> Vec<u8> {
let r = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)]));
for (tag, v) in [
(t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
(t::EXPOSURE_TIME, r(meta.exposure_time)),
(t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))),
(t::F_NUMBER, r(meta.f_number)),
(t::FOCAL_LENGTH, r(meta.focal_length)),
(t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))),
@@ -561,4 +569,13 @@ mod write_tests {
assert_eq!(back.iso, Some(iso));
}
}
#[test]
fn fast_shutter_speeds_round_trip_exactly() {
for den in [8000.0, 12_800.0, 16_000.0, 32_000.0] {
let back = read_exif(&write_exif(&Metadata { exposure_time: Some(1.0 / den), ..Default::default() }));
let got = back.exposure_time.unwrap();
assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got);
}
}
}
+1
View File
@@ -19,6 +19,7 @@ rayon = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
dhat = { workspace = true }
lightcraft-scenes = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
+84
View File
@@ -0,0 +1,84 @@
//! Isolated fast-guided-filter benchmark (procedural log luminance; no decoded RGB or tone median).
//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-pipeline --example guided_memory -- new`
//! Replace `new` with `old` for the materialized-coefficient reference. Optional positional arguments:
//! width height sigma iterations (defaults: 6000 4000 120 5). Append `--heap` for one dhat-counted run.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use lightcraft_pipeline::local::{BASE_EPS, guided, guided_fast, guided_fast_step};
use lightcraft_raster::blur::gaussian;
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Plane, par_join, par_rows};
use std::hint::black_box;
use std::time::Instant;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// Previous algorithm; kept here only to make before/after measurements reproducible.
fn materialized(p: &Plane, sigma: f32) -> Plane {
let s = guided_fast_step(sigma);
if s <= 1 {
return guided(p, sigma, BASE_EPS);
}
let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box);
let (a, b) = coefficients(&lo, sigma / s as f32);
let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear));
let mut q = Plane::new(p.width, p.height);
par_rows(&mut q.data, p.width, |y, row| {
for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) {
*v = av * pv + bv;
}
});
q
}
fn coefficients(lo: &Plane, sg: f32) -> (Plane, Plane) {
let (mean, corr) = par_join(|| gaussian(lo, sg), || gaussian(&lo.map(|v| v * v), sg));
let a = corr.zip_map(&mean, |c, m| {
let v = (c - m * m).max(0.0);
v / (v + BASE_EPS)
});
let b = mean.zip_map(&a, |m, a| m - a * m);
par_join(|| gaussian(&a, sg), || gaussian(&b, sg))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let mode = args.first().map(String::as_str).unwrap_or("new");
if !matches!(mode, "old" | "new") {
return Err("mode must be old or new".into());
}
let parse = |i: usize, default: usize| -> Result<usize, Box<dyn std::error::Error>> {
Ok(args.get(i).filter(|v| !v.starts_with("--")).map(|v| v.parse()).transpose()?.unwrap_or(default))
};
let (w, h, sigma, n) = (parse(1, 6000)?, parse(2, 4000)?, parse(3, 120)? as f32, parse(4, 5)?.max(1));
if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > 100_000_000) {
return Err("dimensions must be positive and at most 100 MP".into());
}
let heap = args.iter().any(|a| a == "--heap");
let p = Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 4.0 + if x > w / 2 { 2.0 } else { 0.0 });
let filter = || if mode == "old" { materialized(&p, sigma) } else { guided_fast(&p, sigma, BASE_EPS) };
// Initialize Rayon and warm code paths before counting. The source is excluded from heap stats.
black_box(filter());
let profiler = heap.then(|| dhat::Profiler::builder().testing().build());
let mut best = f64::MAX;
for _ in 0..if heap { 1 } else { n } {
let t = Instant::now();
let q = black_box(filter());
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
if heap {
let stats = dhat::HeapStats::get();
println!(
"peak_bytes={} total_bytes={} total_blocks={} retained_output_bytes={}",
stats.max_bytes,
stats.total_bytes,
stats.total_blocks,
q.data.len() * size_of::<f32>()
);
}
}
drop(profiler);
println!("{mode} {w}x{h} sigma={sigma} step={} heap={heap} min_wall_ms={best:.3}", guided_fast_step(sigma));
Ok(())
}
+238
View File
@@ -0,0 +1,238 @@
//! The parametric tone curve model shared by the pipeline renderer and the UI curve editor.
//!
//! Lightroom-style tone curve parametric model: four regions (shadows, darks, lights, highlights)
//! separated by three split points. Each region contributes a cosine-windowed bump, summed and
//! forced monotone.
use lightcraft_color::spline::Lut1;
use lightcraft_develop::ToneCurve;
/// The four shape controls of the parametric curve, in graph order: region 0 is the darkest.
pub const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"];
/// Resolution the parametric curve is sampled at, matching the renderer's LUT.
pub const PARAM_LUT: usize = 1024;
/// The parametric (region) curve of a [`ToneCurve`]: the four region amounts, each a cosine bump
/// over its window between two splits, summed and then forced monotone.
///
/// This is the renderer's curve, not an approximation of it — both `curve_luts` in the pipeline
/// and the curve editor in the UI use this struct so they can never disagree.
#[derive(Clone, Debug)]
pub struct ParametricCurve {
/// The curve, sampled and already forced monotone.
lut: [f32; PARAM_LUT],
/// The three region boundaries as fractions of the graph (0..1).
pub splits: [f64; 3],
/// The four region amounts, in graph order (0 = shadows, 3 = highlights).
pub amounts: [f64; 4],
}
impl ParametricCurve {
pub fn new(c: &ToneCurve) -> ParametricCurve {
let s0 = if c.split_shadows.is_nan() { 0.25 } else { (c.split_shadows / 100.0).clamp(0.0, 1.0) as f32 };
let s1 = if c.split_mid.is_nan() { 0.50_f32.max(s0) } else { (c.split_mid / 100.0).clamp(s0 as f64, 1.0) as f32 };
let s2 = if c.split_highlights.is_nan() { 0.75_f32.max(s1) } else { (c.split_highlights / 100.0).clamp(s1 as f64, 1.0) as f32 };
let clean_amt = |a: f64| if a.is_nan() { 0.0 } else { a.clamp(-100.0, 100.0) };
let (a0, a1, a2, a3) = (clean_amt(c.shadows), clean_amt(c.darks), clean_amt(c.lights), clean_amt(c.highlights));
let splits = [s0 as f64, s1 as f64, s2 as f64];
let amounts = [a0, a1, a2, a3];
let regions = [(0.0, s0, a0), (s0, s1, a1), (s1, s2, a2), (s2, 1.0, a3)];
let mut lut = [0.0_f32; PARAM_LUT];
let mut prev = 0.0_f32;
for (i, entry) in lut.iter_mut().enumerate() {
let x = i as f32 / (PARAM_LUT - 1) as f32;
let mut d = 0.0_f32;
for (a, b, amt) in regions {
if amt == 0.0 {
continue;
}
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
if half <= f32::EPSILON {
continue;
}
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
d += (amt / 100.0) as f32 * 0.22 * (0.5 + 0.5 * (t * std::f32::consts::PI).cos());
}
let y = (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0);
prev = prev.max(y);
*entry = prev;
}
ParametricCurve { lut, splits, amounts }
}
/// Convert the evaluated parametric curve into a 1024-entry [`Lut1`].
pub fn to_lut(&self) -> Lut1 {
Lut1 { v: self.lut.to_vec() }
}
/// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends).
pub fn at(&self, x: f64) -> f64 {
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) as f32 };
let at = x * (PARAM_LUT - 1) as f32;
let i = at.floor().clamp(0.0, (PARAM_LUT - 1) as f32) as usize;
let cur = self.lut.get(i).copied().unwrap_or(0.0);
let next = self.lut.get(i + 1).copied().unwrap_or(cur);
let f = at - i as f32;
(cur + (next - cur) * f) as f64
}
/// Where region `region`'s handle sits on the input axis: the middle of its own window, so a
/// handle never has to cross a split to be grabbed.
pub fn region_x(&self, region: usize) -> f64 {
let (a, b) = self.region_window(region);
(a + b) / 2.0
}
/// The window (two splits, as fractions of the graph) region `region` owns.
pub fn region_window(&self, region: usize) -> (f64, f64) {
let s0 = self.split_x(0);
let s1 = self.split_x(1);
let s2 = self.split_x(2);
match region {
0 => (0.0, s0),
1 => (s0, s1),
2 => (s1, s2),
_ => (s2, 1.0),
}
}
/// The half-width of region `region`'s own bump, as fraction of the graph.
pub fn region_half(&self, region: usize) -> f64 {
let (a, b) = self.region_window(region);
(b - a) * 0.75 + 0.05
}
/// The cosine window region `region` applies at `x` (1 at its centre, 0 at the window edges).
pub fn region_window_at(&self, region: usize, x: f64) -> f64 {
let ctr = self.region_x(region);
let half = self.region_half(region);
if half <= f64::EPSILON {
return 0.0;
}
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
0.5 + 0.5 * (t * std::f64::consts::PI).cos()
}
/// The region that owns graph position `x`: the one whose own window contains it. The four
/// windows tile 0..1, so this is always defined and a drag anywhere on the curve moves exactly
/// one slider — the region under the pointer.
pub fn region_at(&self, x: f64) -> usize {
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
let s0 = self.split_x(0);
let s1 = self.split_x(1);
let s2 = self.split_x(2);
if x <= s0 {
0
} else if x <= s1 {
1
} else if x <= s2 {
2
} else {
3
}
}
/// How much output level region `region`'s bump gains at `x` per unit of its slider: the bump is
/// linear in the amount, so this is a constant for a given `x`. A drag converts the pointer's
/// vertical motion into a slider change through it.
pub fn level_per_unit(&self, region: usize, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
0.22 / 100.0 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x)
}
/// The baseline level at `x` from the identity diagonal plus the contributions of all regions
/// *except* `region`. Used when inverting the model for `region` while holding the other three
/// regions fixed.
pub fn base_level_without(&self, region: usize, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
let mut d = 0.0;
for i in 0..4 {
if i == region {
continue;
}
let amt = self.amounts.get(i).copied().unwrap_or(0.0);
if amt == 0.0 {
continue;
}
d += (amt / 100.0) * 0.22 * self.region_window_at(i, x);
}
x + d * 4.0 * x * (1.0 - x)
}
/// The curve level region `region` sits at when its slider holds `amount`, at graph position `x`,
/// holding the other regions fixed at their current settings.
pub fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 {
(self.base_level_without(region, x) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0)
}
/// The amount that puts region `region`'s curve at `level` at `x`, with the other three
/// regions held fixed — the inverse of [`Self::handle_level_at`]. A level the region cannot
/// reach saturates rather than jumping.
pub fn amount_at_level(&self, region: usize, level: f64, x: f64) -> f64 {
let per_unit = self.level_per_unit(region, x);
if per_unit.abs() <= f64::EPSILON {
return self.amounts.get(region).copied().unwrap_or(0.0);
}
let base = self.base_level_without(region, x);
((level.clamp(0.0, 1.0) - base) / per_unit).clamp(-100.0, 100.0).round()
}
/// The graph position split `split` (0..=2) sits at, 0..1.
pub fn split_x(&self, split: usize) -> f64 {
self.splits.get(split).copied().unwrap_or(0.5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_finite_or_inverted_splits_do_not_panic_or_poison_curve() {
let c = ToneCurve {
split_shadows: 80.0,
split_mid: 20.0,
split_highlights: f64::NAN,
shadows: f64::NAN,
lights: f64::INFINITY,
..Default::default()
};
let model = ParametricCurve::new(&c);
assert!(model.at(0.5).is_finite());
assert!(model.at(f64::NAN).is_finite());
assert!(model.region_at(f64::NAN) < 4);
assert!((model.splits[0] - 0.8).abs() < 1e-6);
assert!((model.splits[1] - 0.8).abs() < 1e-6);
assert!((model.splits[2] - 0.8).abs() < 1e-6);
}
#[test]
fn horizontal_inversion_at_x_0_51_with_darks_60_preserves_zero_lights() {
let c = ToneCurve { darks: 60.0, lights: 0.0, ..Default::default() };
let model = ParametricCurve::new(&c);
assert_eq!(model.region_at(0.51), 2, "x=0.51 is inside the Lights region");
let level = model.at(0.51);
assert!((level - 0.5619).abs() < 0.001, "extracted model at 0.51 is ~0.5619: {level}");
// With other regions held fixed, inverting at level ~0.5619 must return Lights = 0, NOT 45!
let lights = model.amount_at_level(2, level, 0.51);
assert_eq!(lights, 0.0, "zero vertical delta preserves Lights=0: {lights}");
}
#[test]
fn a_curve_level_and_its_amount_are_inverses_with_other_regions_present() {
let c = ToneCurve { shadows: 20.0, darks: -30.0, lights: 40.0, highlights: -10.0, ..Default::default() };
let model = ParametricCurve::new(&c);
for region in 0..4 {
let centre = model.region_x(region);
let (_, upper) = model.region_window(region);
for x in [centre, centre + (upper - centre) * 0.5] {
for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] {
let level = model.handle_level_at(region, amount, x);
let back = model.amount_at_level(region, level, x);
assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} -> {level} -> {back}");
}
}
}
}
}
+15 -21
View File
@@ -29,31 +29,20 @@ fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
return None;
}
const N: usize = 1024;
let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32);
let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)];
let mut base = Lut1::from_fn(N, |x| {
let mut d = 0.0;
for (a, b, amt) in regions {
if amt == 0.0 {
continue;
}
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos();
d += (amt / 100.0) as f32 * 0.22 * win;
}
(x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0)
});
// keep monotone
for i in 1..N {
base.v[i] = base.v[i].max(base.v[i - 1]);
}
let mut base = crate::curve::ParametricCurve::new(c).to_lut();
let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>();
if master {
base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base);
}
let per = [&c.red, &c.green, &c.blue];
Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() }))
Some(std::array::from_fn(|i| {
if chans.get(i).copied().unwrap_or(false) {
let pts = per.get(i).map(|&p| to_pts(p)).unwrap_or_default();
MonotoneCurve::new(&pts).to_lut(N).compose(&base)
} else {
base.clone()
}
}))
}
/// Vignette (post-crop) parameters.
@@ -450,7 +439,12 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
if dz != 0.0
&& let Some(dark) = &p.dark
{
let d = (dark.data[i] / air_pre).clamp(0.0, 1.0);
// The dark channel is a blur, not a patch minimum: near bright neighbours it credits
// dark pixels with veil they don't hold (and a windowed render borrows the whole
// frame's airlight), so the subtraction went past zero and crushed them to black
// (#2504). Never attribute more veil than the pixel's own minimum channel.
let c_min = c[0].min(c[1]).min(c[2]);
let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0);
if dz > 0.0 {
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
+3 -1
View File
@@ -27,6 +27,7 @@ pub mod auto;
pub mod blemish;
pub mod colorops;
pub mod cull;
pub mod curve;
pub mod dust;
pub mod finish;
pub mod geometry;
@@ -44,6 +45,7 @@ pub mod transform;
pub mod upright;
pub mod visualize;
pub use curve::{PARAM_AMOUNTS, PARAM_LUT, ParametricCurve};
pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile};
pub use visualize::{MaskView, Overlay};
@@ -139,7 +141,7 @@ pub struct RenderRequest {
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
/// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]).
pub proof: Option<Proof>,
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
+102 -4
View File
@@ -178,10 +178,55 @@ fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) {
par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma))
}
/// `q = a·p + b` per pixel.
/// `q = a·p + b` per pixel. Reduced coefficients are sampled with the same separable
/// weights and accumulation order as a bilinear resize, without full-size coefficient planes.
fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane {
let mut q = Plane::new(p.width, p.height);
let w = p.width;
if (a.width, a.height) != (p.width, p.height) {
use lightcraft_raster::resample::{Filter, weights};
let wx = weights(a.width, w, Filter::Bilinear);
let wy = weights(a.height, p.height, Filter::Bilinear);
// Bilinear upsampling has at most four taps (including zero-weight boundary taps).
// Keep only those horizontally sampled rows per band, reusing them for neighbouring
// output rows. Scratch scales with width and active bands, not image area.
const BAND: usize = 32;
for_rows(&mut q.data, w.saturating_mul(BAND), |band, rows| {
let mut cache: [(usize, Vec<f32>, Vec<f32>); 4] = std::array::from_fn(|_| (usize::MAX, vec![0.0; w], vec![0.0; w]));
let mut bias = vec![0.0; w];
for (dy, row) in rows.chunks_mut(w).enumerate() {
let y = band * BAND + dy;
let (top, ys) = &wy[y];
bias.fill(0.0);
for (dy, yw) in ys.iter().enumerate() {
let cy = top + dy;
let (cached_y, aa, bb) = &mut cache[cy % 4];
if *cached_y != cy {
let (ar, br) = (a.row(cy), b.row(cy));
for ((av, bv), (left, xs)) in aa.iter_mut().zip(bb.iter_mut()).zip(&wx) {
*av = 0.0;
*bv = 0.0;
for (dx, xw) in xs.iter().enumerate() {
*av += ar[left + dx] * xw;
*bv += br[left + dx] * xw;
}
}
*cached_y = cy;
}
for (av, ax) in row.iter_mut().zip(aa) {
*av += *ax * yw;
}
for (bv, bx) in bias.iter_mut().zip(bb) {
*bv += *bx * yw;
}
}
for ((v, pv), bv) in row.iter_mut().zip(p.row(y)).zip(&bias) {
*v = *v * pv + bv;
}
}
});
return q;
}
for_rows(&mut q.data, w, |y, row| {
for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) {
*v = av * pv + bv;
@@ -191,7 +236,7 @@ fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane {
}
/// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and
/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for
/// sampled bilinearly at full resolution; the output keeps full-resolution edges. Equivalent to [`guided`] for
/// large windows at a fraction of the cost.
pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
@@ -202,8 +247,7 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane {
let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1));
let lo = resize(p, lw, lh, Filter::Box);
let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps);
let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear);
guided_apply(p, &up(&ma), &up(&mb))
guided_apply(p, &ma, &mb)
}
/// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the
@@ -758,6 +802,60 @@ mod tests {
mod fast_tests {
use super::*;
/// The previous fast path, including its two materialized coefficient resizes.
fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
let s = guided_fast_step(sigma);
if s <= 1 {
return guided(p, sigma, eps);
}
let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box);
let (a, b) = guided_coeffs(&lo, sigma / s as f32, eps);
let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear));
Plane::from_fn(p.width, p.height, |x, y| a.get(x, y) * p.get(x, y) + b.get(x, y))
}
#[test]
fn sampled_coefficients_match_materialized_resizes() {
for (w, h) in [(0, 0), (1, 1), (1, 19), (23, 1), (2, 3), (17, 13), (64, 48), (161, 119)] {
let planes = [
Plane::filled(w, h, -3.25),
Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 7.0 - 8.0),
Plane::from_fn(w, h, |x, y| {
if x == w / 2 && y == h / 2 {
9.0
} else if x < w / 2 {
-5.0
} else {
2.0
}
}),
];
// Step 1 fallback, every small step, Draft's cap, and the maximum step (16).
for sigma in [0.0, 2.99, 5.99, 6.0, 9.0, 12.0, 24.0, 48.0, 200.0] {
for eps in [BASE_EPS, CLARITY_EPS, 0.0001] {
for p in &planes {
let expected = resized_reference(p, sigma, eps);
let actual = guided_fast(p, sigma, eps);
assert_eq!((actual.width, actual.height), (w, h));
assert_eq!(actual.data, expected.data, "{w}x{h}, sigma {sigma}, eps {eps}");
}
}
}
}
}
#[test]
fn sampled_tone_base_keeps_the_median_before_the_guided_filter() {
let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 });
for sigma in [6.0, 24.0, 120.0] {
for detail in [0, 1, 3, BASE_DETAIL_MAX] {
let median = median_hv(&p, detail);
assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}");
}
}
}
#[test]
fn fast_guided_close_to_reference() {
let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 });
+32
View File
@@ -75,6 +75,38 @@ fn directional_sliders() {
assert!(at("effects.dehaze", -100.0) != base);
}
/// Dehaze crushed dark regions next to bright ones to black: the dark channel is a blur, not a
/// patch minimum, so near the boundary it credited the dark pixels with the bright side's veil and
/// the subtraction went past zero (#2504). The veil credited to a pixel is now bounded by its own
/// minimum channel, so a uniform dark region renders uniformly under dehaze.
#[test]
fn dehaze_does_not_crush_dark_regions_next_to_bright_ones() {
// a dark field against a bright hazy sky; the blur bleeds the sky's dark channel ~10 px into
// the field, which is where the blotches formed
let src = Rgb32f::from_fn(240, 160, |x, _| if x < 120 { [0.02, 0.03, 0.012] } else { [0.45, 0.5, 0.55] });
let mut s = DevelopSettings::default();
s.effects.dehaze = 80.0;
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 160)).image;
for ch in 0..3 {
let mut v: Vec<u8> = Vec::with_capacity(160 * 100);
for y in 0..160 {
for x in 0..100 {
v.push(r.get(x, y)[ch]);
}
}
v.sort_unstable();
let mid = v[v.len() / 2] as i32;
// a field rendered all black would pass the comparison below vacuously
assert!(mid > 2, "the dark field itself is crushed in ch {ch}: level {mid}");
for y in 0..160 {
for x in 0..120 {
let p = r.get(x, y)[ch] as i32;
assert!(p >= mid - 2, "crushed at {x},{y} ch {ch}: {p} vs field level {mid}");
}
}
}
}
#[test]
fn resolution_independence_of_local_contrast() {
// Clarity at two preview sizes should give similar results after downscaling.
+39
View File
@@ -407,3 +407,42 @@ fn auto_mask_strokes_match_the_whole_render() {
};
check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1);
}
/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`:
/// 4.5 % of the long edge).
const OVERLAY_INSET: usize = W * 45 / 1000;
/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the
/// loupe's margin in from the window's edges.
fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let info = SourceInfo { raw: true, ..Default::default() };
let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) };
let f = render(&scene(), &info, s, &full_req).image;
let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image;
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something");
let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows
// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window
#[test]
fn diagnostic_overlays_in_a_window_match_the_full_render() {
use crate::Overlay;
let mut spots = DevelopSettings::default();
spots.detail.sharpen_amount = 60.0;
// a binary threshold: a pixel at the threshold may flip on a one-level difference underneath
check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05);
for (x, y) in [(0, 0), (W - 400, H - 300)] {
check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05);
}
let mut pc = DevelopSettings::default();
pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() });
check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1);
let mut hdr = DevelopSettings::default();
hdr.hdr.enabled = true;
hdr.hdr.max_ev = 3.0;
hdr.light.exposure = 1.5;
check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1);
}
+253 -13
View File
@@ -8,8 +8,9 @@
//! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with
//! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193).
//! Strips labelled 34713 without a `0x0096`
//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]); Nikon small raw
//! (sRAW) and the D1X's genuinely Huffman-coded strip stay refused.
//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]) or, when the strip
//! is exactly three bytes per pixel, Nikon small raw ([`small_raw`]); the D1X's genuinely Huffman-coded strip stays
//! refused.
use super::{nefc, white_from_data};
use crate::tiffraw::{Packing, check_image, read_image};
@@ -233,6 +234,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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));
if let Some(strip) = small_raw_strip(&info, bytes.len(), mn.as_ref()) {
return small_raw(bytes, &tiff, &info, strip, mode);
}
// the header probe reads only the rows the masked-column test needs
let rows = match mode {
Mode::Full => h,
@@ -494,14 +498,7 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result<RawData> {
_ => None,
};
let (Some(layout), [c]) = (layout, chunks.as_slice()) else {
let small = bits == 12
&& chunks.len() == 1
&& w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len));
return Err(RawError::Unsupported(if small {
"Nikon small raw (sRAW) not decoded yet".into()
} else {
"Nikon compressed NEF without a linearization table (maker note 0x96)".into()
}));
return Err(RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into()));
};
let src = lightcraft_tiff::image::chunk_bytes(bytes, c)
.filter(|src| u64::try_from(src.len()).ok() == Some(c.len))
@@ -512,6 +509,128 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result<RawData> {
Ok(RawData::U16(unpack_packed(src, w, rows.min(h), bits, layout)))
}
/// Nikon small raw (sRAW), measured clean-room from the CC0 raw.pixls.us D810 (2036) and D4S (2227) samples. The raw SubIFD is labelled compression 34713,
/// 12 bits, one CFA sample per pixel, but it carries no linearization table (maker note `0x0096`) and its single strip
/// is exactly `3 * width * height` bytes. It holds a YCbCr 4:2:2 image of the IFD size, not a CFA mosaic (the CFA
/// tags are dummies):
///
/// - the strip is an LSB-first 12-bit stream (`s0 = b0 | (b1 & 0x0f) << 8`, `s1 = b1 >> 4 | b2 << 4`), a row is `3 * width`
/// bytes with no padding, i.e. `2 * width` samples;
/// - the samples come in groups of four, `[Y_left, Y_right, Cb, Cr]`, for the pixel pair `(2k, 2k + 1)`: chroma is
/// halved horizontally and not vertically;
/// - luma is linear and already black-subtracted (the darkest samples are near 0) and never uses the top two bits
/// (maxima 873 and 1004 of 4095), so the white level is 1023; chroma is centred on 2048 (fitted neutral 2045-2049).
///
/// The chroma is a camera colour-difference scale, not JFIF Cb/Cr. In linear terms the stored values behave as
/// `R - Y = R_GAIN * (Cr - 2048)` and `B - Y = B_GAIN * (Cb - 2048)` on the luma scale: regressing the camera JPEG's
/// linear `(R - Y) / Y` and `(B - Y) / Y` on `(Cr - 2048) / Y` and `(Cb - 2048) / Y` over mid-tone pixels gave slopes
/// 4.92 / 5.09 (D810, correlation 0.94 / 0.88) and 3.52 / 3.73 (D4S, 0.98 / 0.97); the gains below are the rounded
/// means. They are approximate (the two bodies differ by about 30 %), the luma is not tone-mapped, and the data is
/// treated as already white balanced (neutral D4S areas have `Cb`, `Cr` within 2 of 2048 and equal JPEG R, G, B). G
/// follows from the luma equation with the BT.601 weights. The chroma is upsampled horizontally with linear
/// interpolation, centred between the two pixels of a pair. Output is linear RGB scaled by 16 (to a 14-bit range),
/// like Sony's downsized ARWs.
const SMALL_RAW_WHITE: f32 = 1023.0;
const SMALL_RAW_SCALE: f32 = 16.0;
const SMALL_RAW_CHROMA_ZERO: f32 = 2048.0;
const SMALL_RAW_R_GAIN: f32 = 4.2;
const SMALL_RAW_B_GAIN: f32 = 4.4;
/// The strip of a small raw: compression 34713, 12 bits, no maker note `0x0096`, one strip of exactly
/// `3 * width * height` bytes (even width).
fn small_raw_strip(info: &ImageInfo, file_len: usize, mn: Option<&makernote::MakerNote>) -> Option<lightcraft_tiff::image::Chunk> {
if info.compression != t::compression::NIKON || info.bits() != 12 || info.samples_per_pixel != 1 {
return None;
}
if mn.is_some_and(|m| m.ifd.bytes(LINEARIZATION_TABLE).is_some()) {
return None;
}
let (w, h) = (info.width as u64, info.height as u64);
let chunks = info.chunks(file_len as u64);
match chunks.as_slice() {
[c] if w.is_multiple_of(2) && w > 0 && h > 0 && w.checked_mul(h).and_then(|n| n.checked_mul(3)) == Some(c.len) => Some(*c),
_ => None,
}
}
/// Decode one row of a small raw (`3 * width` bytes) into `3 * width` linear RGB values on the 14-bit scale.
/// Short input reads as zero bytes.
fn small_raw_row(src: &[u8], w: usize, out: &mut [u16]) {
let Some(n) = w.checked_mul(3).filter(|n| *n <= out.len() && *n <= crate::MAX_SAMPLES) else { return };
if w == 0 || !w.is_multiple_of(2) {
return;
}
let mut s = vec![0u16; 2 * w];
crate::unpack::unpack_lsb(&src[..src.len().min(n)], 12, &mut s);
let pairs = w / 2;
let chroma = |k: usize| {
let g = &s[4 * k + 2..4 * k + 4];
(f32::from(g[0]) - SMALL_RAW_CHROMA_ZERO, f32::from(g[1]) - SMALL_RAW_CHROMA_ZERO)
};
for k in 0..pairs {
let (cb, cr) = chroma(k);
for side in 0..2 {
let (nb, nr) = if side == 0 { chroma(k.saturating_sub(1)) } else { chroma((k + 1).min(pairs - 1)) };
let (u, v) = (0.75 * cb + 0.25 * nb, 0.75 * cr + 0.25 * nr);
let y = f32::from(s[4 * k + side]);
let r = y + SMALL_RAW_R_GAIN * v;
let b = y + SMALL_RAW_B_GAIN * u;
let g = (y - 0.299 * r - 0.114 * b) / 0.587;
let dst = (2 * k + side) * 3;
for (i, value) in [r, g, b].into_iter().enumerate() {
out[dst + i] = (value * SMALL_RAW_SCALE).round().clamp(0.0, 65535.0) as u16;
}
}
}
}
fn small_raw(bytes: &[u8], tiff: &Tiff, info: &ImageInfo, strip: lightcraft_tiff::image::Chunk, mode: Mode) -> Result<RawImage> {
use rayon::prelude::*;
let (w, h) = (info.width as usize, info.height as usize);
let stride = w.checked_mul(3).ok_or(RawError::Limit("image too large"))?;
let count = stride.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let src = lightcraft_tiff::image::chunk_bytes(bytes, &strip)
.filter(|src| u64::try_from(src.len()).ok() == Some(strip.len))
.ok_or_else(|| RawError::Corrupt("NEF: incomplete image strip".into()))?;
let data = match mode {
Mode::Full => {
let mut data = vec![0u16; count];
data.par_chunks_mut(stride).enumerate().for_each(|(y, out)| {
small_raw_row(src.get(y * stride..).unwrap_or(&[]), w, out);
});
data
}
Mode::Header => Vec::new(),
};
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::Nef,
width: w,
height: h,
cpp: 3,
data: RawData::U16(data),
cfa: None,
bits: 14,
black: BlackLevel::uniform(0.0),
white: vec![SMALL_RAW_WHITE * SMALL_RAW_SCALE],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::from_exif(tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1)),
color: ColorData::default(),
// the stored colour is already white balanced (see above)
wb_multipliers: Some([1.0; 3]),
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1089,9 +1208,119 @@ mod tests {
// 14-bit rows must be padded to 16 bytes
let d14 = samples(40 * 6, 14);
assert!(matches!(decoded(strip(&d14, 40, 0, |r| pack_lsb(r, 14)), 14, 40, 6), Err(RawError::Unsupported(_))));
// 3 bytes per IFD-width pixel is a Nikon small raw: still refused, with its own reason
let Err(RawError::Unsupported(why)) = decoded(vec![0; 3 * 40 * 6], 12, 40, 6) else { panic!("small raw decoded") };
assert!(why.contains("sRAW"), "{why}");
}
/// A small raw strip of `[Y0 Y1 Cb Cr]` groups, rows of `3 * w` bytes, LSB-first 12-bit.
fn small_strip(groups: &[[u16; 4]], w: usize) -> Vec<u8> {
let flat: Vec<u16> = groups.iter().flatten().copied().collect();
assert_eq!(flat.len() % (2 * w), 0);
flat.chunks(2 * w).flat_map(|r| pack_lsb(r, 12)).collect()
}
fn small(strip: Vec<u8>, bits: u16, w: u32, h: u32) -> Result<RawImage> {
crate::decode(&nef(34713, bits, vec![strip], w, h, h))
}
#[test]
fn small_raw_422_round_trips() {
let (w, h) = (8usize, 3usize);
// flat chroma per row so the interpolation is exact; distinct luma everywhere
let mut groups = Vec::new();
for y in 0..h {
for k in 0..w / 2 {
let (cb, cr) = (2048 + 10 * y as u16, 2048 - 5 * y as u16);
groups.push([100 + (y * w + 2 * k) as u16, 100 + (y * w + 2 * k + 1) as u16, cb, cr]);
}
}
let raw = small(small_strip(&groups, w), 12, w as u32, h as u32).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true));
assert_eq!(raw.wb_multipliers, Some([1.0; 3]));
let RawData::U16(d) = &raw.data else { panic!() };
for y in 0..h {
for x in 0..w {
let luma = (100 + y * w + x) as f32;
let (u, v) = (10.0 * y as f32, -5.0 * y as f32);
let (r, b) = (luma + SMALL_RAW_R_GAIN * v, luma + SMALL_RAW_B_GAIN * u);
let g = (luma - 0.299 * r - 0.114 * b) / 0.587;
for (i, want) in [r, g, b].into_iter().enumerate() {
let got = f32::from(d[(y * w + x) * 3 + i]) / SMALL_RAW_SCALE;
assert!((got - want).abs() < 0.1, "({x},{y}) channel {i}: {got} vs {want}");
}
}
}
}
#[test]
fn small_raw_neutral_chroma_is_grey_and_chroma_is_interpolated() {
let w = 8usize;
let groups: Vec<[u16; 4]> = (0..w / 2).map(|k| [200 + 40 * k as u16, 200 + 40 * k as u16, 2048, 2048]).collect();
let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap();
let RawData::U16(d) = &raw.data else { panic!() };
for x in 0..w {
let px = &d[x * 3..x * 3 + 3];
assert!(px.iter().all(|&v| v == px[0]), "{px:?}");
assert_eq!(f32::from(px[0]), (200 + 40 * (x / 2)) as f32 * SMALL_RAW_SCALE);
}
// a step in Cb between the first two pairs is shared 3:1 by the pixels next to the boundary
let mut groups = groups;
groups[1][2] = 2048 + 100;
let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap();
let RawData::U16(d) = &raw.data else { panic!() };
let blue_minus_luma = |x: usize, luma: f32| f32::from(d[x * 3 + 2]) / SMALL_RAW_SCALE - luma;
assert!((blue_minus_luma(1, 200.0) - SMALL_RAW_B_GAIN * 25.0).abs() < 0.1);
assert!((blue_minus_luma(2, 240.0) - SMALL_RAW_B_GAIN * 75.0).abs() < 0.1);
}
#[test]
fn small_raw_wrong_sizes_and_depths_are_refused() {
let (w, h) = (8usize, 3usize);
let good = small_strip(&vec![[100, 100, 2048, 2048]; w / 2 * h], w);
assert!(small(good.clone(), 12, w as u32, h as u32).is_ok());
for len in [good.len() - 1, good.len() + 1] {
let mut s = good.clone();
s.resize(len, 0);
assert!(matches!(small(s, 12, w as u32, h as u32), Err(RawError::Unsupported(_))), "len {len}");
}
assert!(matches!(small(good.clone(), 14, w as u32, h as u32), Err(RawError::Unsupported(_))));
// odd width: 3 bytes per pixel does not split into pixel pairs
assert!(matches!(small(vec![0; 3 * 7 * 3], 12, 7, 3), Err(RawError::Unsupported(_))));
}
#[test]
fn small_raw_short_input_does_not_panic() {
for len in [0, 1, 2, 3, 5, 11, 12, 13] {
let mut out = vec![0u16; 12];
small_raw_row(&vec![0xFF; len], 4, &mut out);
}
let mut bytes = nef(34713, 12, vec![vec![0x55; 3 * 8 * 3]], 8, 3, 3);
bytes.truncate(bytes.len() - 40);
assert!(crate::decode(&bytes).is_err(), "a truncated small-raw strip is corrupt");
}
#[test]
fn small_raw_header_has_full_geometry_without_allocating_pixels() {
let (w, h) = (8usize, 3usize);
let bytes = nef(34713, 12, vec![small_strip(&vec![[100, 110, 2048, 2048]; w / 2 * h], w)], w as u32, h as u32, h as u32);
let header = decode(&bytes, Mode::Header).unwrap();
let full = decode(&bytes, Mode::Full).unwrap();
assert_eq!(header.info(), full.info());
assert!(matches!(header.data, RawData::U16(ref samples) if samples.is_empty()));
assert!(matches!(full.data, RawData::U16(ref samples) if samples.len() == 3 * w * h));
}
#[test]
fn small_raw_rejects_excessive_dimensions_before_allocation() {
let bytes = nef(34713, 12, vec![vec![0; 72]], 8, 3, 3);
let tiff = Tiff::parse(&bytes).unwrap();
let mut info = raw_ifd(&tiff).unwrap().image().unwrap();
let strip = info.chunks(bytes.len() as u64)[0];
info.width = u32::MAX - 1;
info.height = u32::MAX;
assert!(matches!(small_raw(&bytes, &tiff, &info, strip, Mode::Header), Err(RawError::Limit(_))));
let mut out = [0; 12];
small_raw_row(&[], usize::MAX, &mut out);
small_raw_row(&[], 3, &mut out);
assert_eq!(out, [0; 12]);
}
/// Big-endian 16-bit words holding the 12-bit samples in their upper bits.
@@ -1186,4 +1415,15 @@ mod tests {
}
}
}
#[test]
fn small_raw_rejects_tail_truncation_with_intact_directories() {
let bytes = nef(34713, 12, vec![vec![0x55; 72]], 8, 3, 3);
for keep in [0, 24, 71] {
let short = truncated_tail_strip(bytes.clone(), keep);
for mode in [Mode::Header, Mode::Full] {
assert!(matches!(decode(&short, mode), Err(RawError::Corrupt(_))));
}
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
name = "lightcraft-ui-egui"
version.workspace = true
edition.workspace = true
rust-version = "1.95"
rust-version.workspace = true
license.workspace = true
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
+9 -1
View File
@@ -253,6 +253,7 @@
"new, inside {parent}": "nové, pod {parent}",
"Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.",
"Inside: {names}": "Uvnitř: {names}",
" in {g} group{}": " ve skupinách: {g}{:.0}",
" · kept, not applied: {}": " · zachováno, nepoužito: {}",
" — kept but not applied: {}": " — zachováno, ale nepoužito: {}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.",
"Drop to review {n} item{} for import": "Pusťte pro kontrolu před importem (položek: {n}){:.0}",
"Drop to import {n} item{}": "Pusťte pro import (položek: {n}){:.0}",
"Drop to import {n} preset{}": "Pusťte pro import přednastavení: {n}{:.0}",
"Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}",
"Added {n} photo{}": "Přidáno fotografií: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"
}
+20 -9
View File
@@ -374,7 +374,6 @@
"Clear Filters": "Zrušit filtry",
"Clear Guides": "Vymazat vodítka",
"Clear History": "Vymazat historii",
"Clear Preview Cache": "Vymazat mezipaměť náhledů",
"Clear Quick Collection": "Vyprázdnit rychlou kolekci",
"Clear Recent Libraries": "Vymazat nedávné knihovny",
"Clear Reference": "Zrušit referenční fotografii",
@@ -425,7 +424,6 @@
"Company": "Společnost",
"Compare": "Porovnat",
"Compare (Shift+C)": "Porovnat (Shift+C)",
"Compare Before and After": "Porovnat před a po",
"Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie",
"Component options": "Možnosti součásti",
"Compression": "Komprese",
@@ -1585,7 +1583,6 @@
"Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii",
"LightCraft stopped": "LightCraft se neočekávaně ukončil",
"Show Focus Point": "Zobrazit zaostřovací bod",
"Zoom to Focus Point": "Přiblížit na zaostřovací bod",
"Focus": "Zaostření",
"Focus point": "Zaostřovací bod",
@@ -1754,18 +1751,13 @@
"Short Edge": "Kratší hrana",
"Show": "Zobrazit",
"Show All": "Zobrazit vše",
"Show Clipping": "Zobrazit ořezání tónů",
"Show Deghost Overlay": "Zobrazit překrytí odstranění duchů",
"Show Hidden Local Locations": "Zobrazit skrytá místní umístění",
"Show Luminance Map": "Zobrazit mapu jasu",
"Show Mask": "Zobrazit masku",
"Show Mask Overlay": "Zobrazit překrytí masky",
"Show All Mask Overlays": "Zobrazit všechna překrytí masek",
"Show all overlays": "Zobrazit všechna překrytí",
"Show Mask Pins": "Zobrazit body masek",
"Show Original": "Zobrazit originál",
"Show Person": "Zobrazit osobu",
"Show Photo Counts": "Zobrazit počty fotografií",
"Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem",
"Show Pins": "Zobrazit body",
"Show Sidebar Section": "Zobrazit sekci postranního panelu",
@@ -2394,5 +2386,24 @@
"End Slideshow": "Ukončit prezentaci",
"Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…",
"Manage Effect Presets…": "Spravovat přednastavení efektů…",
"Export Effect Presets…": "Exportovat přednastavení efektů…"
"Export Effect Presets…": "Exportovat přednastavení efektů…",
"Before/After Side by Side": "Před/Po vedle sebe",
"Clipping": "Ořezání tónů",
"Mask Overlay": "Překrytí masky",
"All Mask Overlays": "Všechna překrytí masek",
"Mask Pins": "Body masek",
"Photo Counts": "Počty fotografií",
"Focus Point": "Bod zaostření",
"Layout Image": "Obrázek rozvržení",
"Detail View": "Zobrazení detailu",
"Edit Panel": "Panel Úpravy",
"Close the open dialog, then drop the files again": "Zavřete otevřené okno, pak soubory pusťte znovu",
"Close the open dialog to drop files here": "Zavřete otevřené okno, aby sem šlo soubory pustit",
"Drop to open the face model": "Pusťte pro otevření modelu obličejů",
"Drop to import": "Pusťte pro import",
"Dropped Files": "Přetažené soubory",
"Import dropped files without review": "Importovat přetažené soubory bez kontroly",
"An import is running": "Import už běží",
"An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",
"Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"
}
+38 -30
View File
@@ -10,7 +10,7 @@
" · {} not readable": " · {} nicht lesbar",
" · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}",
" · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt",
" — not carried over: {}{}": " — nicht übernommen: {}{}",
" — not carried over: {}{}": " – nicht übernommen: {}{}",
"+{} more": "+{} weitere",
", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}",
"; {kept} already had a location": "; bereits verortet: {kept}",
@@ -54,7 +54,7 @@
"Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",
"Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",
"Exporting {} of {total}": "{} von {total} wird exportiert",
"Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}",
"Filter bar — {active} active filter{}": "Filterleiste – aktive Filter: {active}{:.0}",
"Focus {:.0}": "Schärfe {:.0}",
"Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}",
@@ -63,11 +63,11 @@
"Import failed: {e}": "Import fehlgeschlagen: {e}",
"Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil „{profile}“ wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Guided-Upright-Transformation wird nicht unterstützt.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Upright-Transformation wird nicht unterstützt.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil die Perspektivkorrektur aus ist.",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur mit Hilfslinien wird nicht unterstützt.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur wird nicht unterstützt.",
"XMP adjustments were not imported for {path}: {fields}.": "XMP-Anpassungen wurden für {path} nicht importiert: {fields}.",
"Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}",
"Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}",
@@ -90,10 +90,10 @@
"Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?",
"Named ({named_n})": "Benannt ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet",
"Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt",
"Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen – nur kopierte Einstellungen werden eingefügt",
"Preset: {name}": "Vorgabe: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})",
"Preview only — {}": "Nur Vorschau — {}",
"Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau – das eingebettete Kamera-JPEG wird bearbeitet ({})",
"Preview only — {}": "Nur Vorschau – {}",
"Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Softproof-Kopie ({})",
@@ -101,7 +101,7 @@
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
"Quick Develop: {name}": "Schnellentwicklung: {name}",
"Rated {}": "Bewertet mit {}",
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}",
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen… {done} von {total}",
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
"Redo {}": "Wiederholen: {}",
@@ -129,7 +129,7 @@
"Undo {}": "Rückgängig: {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)",
"Updated “{name}”": "„{name}“ aktualisiert",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} – eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.",
"Version: {}": "Version: {}",
"Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}",
"Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…",
@@ -153,16 +153,16 @@
"{name} Off": "{name} aus",
"{name} On": "{name} ein",
"{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt",
"{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut",
"{name} — double-click to reset it": "{name} – zum Zurücksetzen doppelklicken",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} Änderung(en) konnten nicht auf den Datenträger geschrieben werden{0:.0}: {e}\nWenn du jetzt beendest, gehen sie verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} Änderung(en) nur im Arbeitsspeicher, nicht auf dem Datenträger gespeichert{0:.0}: {e}\nLightCraft versucht es automatisch erneut; wenn du jetzt beendest, gehen sie verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} Änderung(en) nur im Arbeitsspeicher gespeichert{:.0}: {e} – LightCraft versucht es erneut",
"{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt",
"{n} photos": "Fotos: {n}",
"{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)",
"{n} photos in {} are hidden by the filters above": "{n} Fotos in {} werden durch die obigen Filter ausgeblendet",
"{n} photo{}": "{n} Foto{}",
"{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} photo{} now · updates automatically as photos change": "Derzeit {n} Foto{} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten",
"{pid} (missing)": "{pid} (fehlt)",
"{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}",
@@ -180,7 +180,7 @@
"{} files": "{} Dateien",
"{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}",
"{} item{}": "Elemente: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien – entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.",
"{} photos": "Fotos: {}",
"{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",
@@ -188,7 +188,7 @@
"{} sample{}": "Farbproben: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",
"{} — show their photos": "{} — Fotos dieser Person anzeigen",
"{} — 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“",
@@ -196,17 +196,17 @@
"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}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos",
"Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft",
"{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)",
"Moved; {} photo(s) relinked": "Verschoben; {} Foto(s) neu verknüpft",
"{} label (click more labels to show any of them)": "Farbmarkierung {} (weitere Farbmarkierungen auswählen, um alle passenden Fotos zu zeigen)",
", {} star{}": ", Sterne: {}{:.0}",
", {} label": ", Markierung: {}",
", {} label": ", Farbmarkierung: {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Lädt von {host} herunter. Sobald es angekommen und geprüft ist, installiert LightCraft es und {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Läuft auf dem Prozessor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Im Ordner „denoise“ der Bibliothek gespeichert; bei Erreichen der Grenze werden zuerst die ältesten entfernt, die kein Foto verwendet.",
"Denoising {name}: {done} of {total} tiles{}": "{name} wird entrauscht: {done} von {total} Kacheln{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} von {} von {host}",
"{} of {} from {host}": "{} von {} (Quelle: {host})",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Entrauschtes Bild wird berechnet… {}%",
@@ -223,17 +223,18 @@
"Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.",
"Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…",
"Inside: {names}": "Darin: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?",
"There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos",
"On {have} of {of} selected photos": "Bei {have} von {of} ausgewählten Fotos",
"Remove “{name}”": "„{name}“ entfernen",
"There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“",
"Added {n} keyword{}": "Stichwörter hinzugefügt: {n}{:.0}",
"; {n} gained synonyms": "; mit neuen Synonymen: {n}",
"Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}",
" — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " – Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}",
" — left out, as a keyword list can't hold them: {names}": " – ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}",
"{done} of {total}": "{done} von {total}",
"{done} of {total} MB": "{done} von {total} MB",
"{pct} %": "{pct} %",
@@ -254,7 +255,7 @@
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt",
" in {g} group{}": " in {g} Gruppe(n){:.0}",
" · kept, not applied: {}": " · behalten, nicht angewendet: {}",
" — kept but not applied: {}": " — behalten, aber nicht angewendet: {}",
" — kept but not applied: {}": " – behalten, aber nicht angewendet: {}",
"Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden",
"Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?",
"Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?",
@@ -280,7 +281,7 @@
"Skipped {problem}": "Übersprungen: {problem}",
"…and {more} more": "…und {more} weitere",
"Template preview: {name}": "Vorlagenvorschau: {name}",
"{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}",
"{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Dias · {time}",
"{n} pages": "{n} Seiten",
"{n} JPEG files": "{n} JPEG-Dateien",
"{s} s of AV1 video": "{s} s AV1-Video",
@@ -289,7 +290,7 @@
"{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}",
"Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesicht(er) in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Drop to review {n} item{} for import": "Ablegen, um vor dem Import zu prüfen (Objekte: {n}){:.0}",
"Drop to import {n} item{}": "Ablegen zum Importieren (Objekte: {n}){:.0}",
"Drop to import {n} preset{}": "Ablegen zum Importieren von Vorgaben: {n}{:.0}",
"Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}",
"Added {n} photo{}": "{n} Foto{} hinzugefügt",
"Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"
}
+101 -87
View File
@@ -14,7 +14,7 @@
"0: nothing is rejected.": "0: Nichts wird abgelehnt.",
"1 day": "1 Tag",
"123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die",
"150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt",
"150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Zuschnitt",
"1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten",
"2026-04 or 2026-04-12": "2026-04 oder 2026-04-12",
"23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.",
@@ -64,7 +64,7 @@
"Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden",
"Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.",
"Adding": "Wird hinzugefügt",
"Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …",
"Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt…",
"Advanced search…": "Erweiterte Suche…",
"After": "Nachher",
"After 30 days": "Nach 30 Tagen",
@@ -99,9 +99,9 @@
"Aqua Hue": "Aquamarin – Farbton",
"Aqua Luminance": "Aquamarin – Luminanz",
"Aqua Saturation": "Aquamarin – Sättigung",
"Arrange Panel": "Bereich anordnen",
"Arrange Panel": "Bedienfeld anordnen",
"ArtCraft Website": "ArtCraft-Website",
"ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps",
"ArtCraft — more creative apps": "ArtCraft – weitere Kreativ-Apps",
"As Shot": "Wie Aufnahme",
"As shot": "Wie Aufnahme",
"Ascending": "Aufsteigend",
@@ -235,7 +235,6 @@
"Clear Filters": "Filter löschen",
"Clear Guides": "Hilfslinien löschen",
"Clear History": "Verlauf löschen",
"Clear Preview Cache": "Vorschau-Cache leeren",
"Clear Quick Collection": "Schnellsammlung leeren",
"Clear Reference": "Referenz entfernen",
"Clear all": "Alles löschen",
@@ -258,7 +257,7 @@
"Close Window": "Fenster schließen",
"Close all": "Alle schließen",
"Close file": "Datei schließen",
"Close panel": "Bereich schließen",
"Close panel": "Bedienfeld schließen",
"Cloudy": "Bewölkt",
"Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.",
"Collapse": "Einklappen",
@@ -289,7 +288,6 @@
"Company": "Unternehmen",
"Compare": "Vergleichen",
"Compare (Shift+C)": "Vergleichen (Umschalt+C)",
"Compare Before and After": "Vorher und Nachher vergleichen",
"Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich",
"Component options": "Komponentenoptionen",
"Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,",
@@ -298,10 +296,10 @@
"Confirm before deleting": "Vor dem Löschen bestätigen",
"Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen",
"Move Photos": "Fotos verschieben",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Die Dateien der Fotos und ihre XMP-Begleitdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Don't ask again": "Nicht mehr fragen",
"Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet die Abfrage wieder ein",
"Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen",
"Constrain Crop": "Zuschnitt beschränken",
"Contact Sheet PDF": "Kontaktabzug-PDF",
@@ -465,7 +463,7 @@
"Edit Date": "Bearbeitungsdatum",
"Edit Info": "Informationen bearbeiten",
"Edit Label Names": "Namen der Farbmarkierungen bearbeiten",
"Edit Label Names…": "Markierungsnamen bearbeiten…",
"Edit Label Names…": "Namen der Farbmarkierungen bearbeiten…",
"Edit Mask": "Maske bearbeiten",
"Edit Mask Component": "Maskenkomponente bearbeiten",
"Edit Sections": "Bearbeitungsbereiche",
@@ -507,7 +505,7 @@
"Export Presets…": "Vorgaben exportieren…",
"Export User Presets…": "Benutzervorgaben exportieren…",
"Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportdateinamen verwenden die Platzhalter der Fotoumbenennung ({title}, {seq:2}, {date:%Y-%m-%d}…) sowie {num}, {folder}, {lens}, {iso}, {rating}, {creator}.",
"Export stopped unexpectedly": "Export unerwartet angehalten",
"Export stopped unexpectedly": "Export unerwartet abgebrochen",
"Export with Preset": "Mit Vorgabe exportieren",
"Export with Previous": "Mit vorherigen Einstellungen exportieren",
"Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exportdateien werden zunächst temporär geschrieben und anschließend umbenannt. Ein voller Datenträger oder getrenntes Laufwerk",
@@ -577,7 +575,7 @@
"Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)",
"Flip V": "V spiegeln",
"Flip Vertical": "Vertikal spiegeln",
"Float panel": "Bereich frei platzieren",
"Float panel": "Bedienfeld frei platzieren",
"Flow": "Fluss",
"Fluorescent": "Leuchtstofflicht",
"Focal Length": "Brennweite",
@@ -593,7 +591,7 @@
"Form fields panel": "Formularfeldbereich",
"Format": "Format",
"Forward": "Vorwärts",
"Frame time": "Bilddauer",
"Frame time": "Frame-Zeit",
"Frame update": "Bildaktualisierung",
"Free": "Frei",
"Fresh & Bright": "Frisch und hell",
@@ -698,7 +696,7 @@
"Import": "Importieren",
"Import Date": "Importdatum",
"Import Lightroom Catalog": "Lightroom-Katalog importieren",
"Import Lightroom Catalog…": "Lightroom-Katalog importieren …",
"Import Lightroom Catalog…": "Lightroom-Katalog importieren…",
"Import Photos": "Fotos importieren",
"Import Photos…": "Fotos importieren…",
"Import Point Curve Presets": "Punktkurvenvorgaben importieren",
@@ -724,7 +722,7 @@
"In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen",
"In Recently Deleted": "In „Zuletzt gelöscht“",
"In library": "In Bibliothek",
"In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert",
"In memory — nothing is saved": "Im Arbeitsspeicher – nichts wird gespeichert",
"In use": "In Verwendung",
"In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.",
"Include selected photos": "Ausgewählte Fotos einschließen",
@@ -805,7 +803,7 @@
"LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).",
"LightCraft on GitHub": "LightCraft auf GitHub",
"LightCraft website": "LightCraft-Website",
"LightCraft — Second Window": "LightCraft — zweites Fenster",
"LightCraft — Second Window": "LightCraft – zweites Fenster",
"Lightroom Classic Catalog": "Lightroom Classic-Katalog",
"Lightroom import cancelled": "Lightroom-Import abgebrochen",
"Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen",
@@ -826,7 +824,7 @@
"Loading preview…": "Vorschau wird geladen…",
"Local": "Lokal",
"Local folders": "Lokale Ordner",
"Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich",
"Local library — no cloud account needed": "Lokale Bibliothek – kein Cloud-Konto erforderlich",
"Locate Missing File…": "Fehlende Datei suchen…",
"Locate Missing Files…": "Fehlende Dateien suchen…",
"Locate Photo": "Foto suchen",
@@ -882,7 +880,7 @@
"Midtones Hue": "Mitteltöne – Farbton",
"Midtones Luminance": "Mitteltöne – Luminanz",
"Midtones Saturation": "Mitteltöne – Sättigung",
"Midtones split": "Mitteltontrennung",
"Midtones split": "Mitteltongrenze",
"Minimize": "Minimieren",
"Missing Photos": "Fehlende Fotos",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Fehlende Metadaten ({camera}, {title}…) hinterlassen eine Lücke; bei einem leeren Ergebnis bleibt der Originalname erhalten.",
@@ -924,7 +922,7 @@
"Muted": "Gedämpft",
"Muted Film": "Gedämpfter Film",
"My Photos": "Meine Fotos",
"My Photos Panel": "Bereich „Meine Fotos“",
"My Photos Panel": "Bedienfeld „Meine Fotos“",
"Name": "Name",
"Name (A–Z)": "Name (A–Z)",
"Name of the folder the original is in": "Name des Ordners mit dem Original",
@@ -981,7 +979,7 @@
"No photo selected": "Kein Foto ausgewählt",
"No photos": "Keine Fotos",
"Your library is empty": "Deine Bibliothek ist leer",
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.",
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Kopiert oder verschoben wird nur, wenn du dich dafür entscheidest.",
"or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster",
"Import photos to get started": "Importiere Fotos, um loszulegen",
"or drop photos into this window": "oder ziehe Fotos in dieses Fenster",
@@ -995,7 +993,7 @@
"No versions yet. Create one to keep this look.": "Noch keine Versionen. Erstelle eine, um diesen Look zu bewahren.",
"Noise": "Rauschen",
"Noise Reduction": "Rauschreduzierung",
"Non-raw photos": "Andere Fotos als RAW",
"Non-raw photos": "Nicht-RAW-Fotos",
"None": "Keine",
"Not Now": "Nicht jetzt",
"Nothing to compare": "Nichts zum Vergleichen",
@@ -1039,11 +1037,11 @@
"Overwrite": "Überschreiben",
"Pages": "Seiten",
"Paint": "Auftragen",
"Paint only areas like the one under the brush": "Nur Bereiche wie den unter dem Pinsel bearbeiten",
"Paint only areas like the one under the brush": "Nur Bereiche bemalen, die dem Bereich unter dem Pinsel ähneln",
"Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.",
"Paint overlay": "Überlagerung auftragen",
"Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in",
"Panels": "Bereiche",
"Panels": "Bedienfelder",
"Panorama": "Panorama",
"Panorama Merge": "Zu Panorama zusammenfügen",
"Panorama Merge Preview": "Panoramavorschau",
@@ -1107,7 +1105,7 @@
"Presets (Shift+P)": "Vorgaben (Umschalt+P)",
"Presets and profiles": "Vorgaben und Profile",
"Presets ▾": "Vorgaben ⌄",
"Press keys…": "Tasten drücken …",
"Press keys…": "Tasten drücken…",
"Preview": "Vorschau",
"Preview Build Progress": "Fortschritt der Vorschauerstellung",
"Preview only": "Nur Vorschau",
@@ -1176,7 +1174,7 @@
"Read mode": "Lesemodus",
"Read the SAM License": "SAM-Lizenz lesen",
"Reading": "Wird gelesen",
"Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …",
"Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen…",
"Reading folder…": "Ordner wird gelesen…",
"Real name": "Echter Name",
"Recent": "Zuletzt verwendet",
@@ -1207,6 +1205,9 @@
"Refresh View": "Ansicht aktualisieren",
"Reject": "Ablehnen",
"Reject below focus": "Unter Schärfegrenzwert ablehnen",
"Burst: shots at most this far apart": "Serie: Aufnahmen höchstens so weit auseinander",
"Burst: how alike (loose … strict)": "Serie: wie ähnlich (locker … streng)",
"Lower it when a panning or moving burst splits into several groups.": "Senken, wenn eine Mitzieher- oder Bewegungsserie in mehrere Gruppen zerfällt.",
"Rejected": "Abgelehnt",
"Rejects": "Abgelehnte Fotos",
"Reliability": "Zuverlässigkeit",
@@ -1218,7 +1219,7 @@
"Remove CA": "Chromatische Aberration entfernen",
"Remove Chromatic Aberration": "Chromatische Aberration entfernen",
"Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der RAW-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert",
"Remove Disk from Library": "Datenträger aus Bibliothek entfernen",
"Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…",
"Remove Face Box": "Gesichtsrahmen entfernen",
@@ -1268,7 +1269,7 @@
"Reset Control": "Regler zurücksetzen",
"Reset Crop": "Zuschnitt zurücksetzen",
"Reset Edits": "Bearbeitungen zurücksetzen",
"Reset Panel Layout": "Bereichsanordnung zurücksetzen",
"Reset Panel Layout": "Bedienfeldanordnung zurücksetzen",
"Reset Parametric Curve": "Parametrische Kurve zurücksetzen",
"Reset Section": "Bereich zurücksetzen",
"Reset Tone Curve": "Gradationskurve zurücksetzen",
@@ -1276,8 +1277,8 @@
"Reset all edits": "Alle Bearbeitungen zurücksetzen",
"Reshuffle": "Neu mischen",
"Resize Face Box": "Gesichtsrahmengröße ändern",
"Resize panel window": "Größe des Bereichsfensters ändern",
"Resize panels": "Bereichsgrößen ändern",
"Resize panel window": "Größe des Bedienfeldfensters ändern",
"Resize panels": "Bedienfeldgrößen ändern",
"Resize to": "Größe ändern auf",
"Resolution (ppi)": "Auflösung (ppi)",
"Restore": "Wiederherstellen",
@@ -1328,7 +1329,7 @@
"Save as set": "Als Satz speichern",
"Save as…": "Speichern unter…",
"Save preset": "Vorgabe speichern",
"Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …",
"Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert…",
"Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine",
"Scale": "Skalierung",
"Scan failed": "Durchsuchen fehlgeschlagen",
@@ -1383,7 +1384,7 @@
"Set as Target Album (B adds to it)": "Als Zielalbum festlegen (B fügt Fotos hinzu)",
"Set as Top of Stack": "Als oberstes Foto im Stapel festlegen",
"Set date & time": "Datum und Uhrzeit festlegen",
"Set name": "Satzname",
"Set name": "Name des Satzes",
"Set the mix from the photo's colours": "Mischung anhand der Farben des Fotos festlegen",
"Settings": "Einstellungen",
"Settings file problem": "Problem mit Einstellungsdatei",
@@ -1398,7 +1399,7 @@
"Shadows Hue": "Tiefen – Farbton",
"Shadows Luminance": "Tiefen – Luminanz",
"Shadows Saturation": "Tiefen – Sättigung",
"Shadows Tint": "Tiefen – Tonung",
"Shadows Tint": "Tiefen – Tönung",
"Shadows split": "Tiefengrenze",
"Sharpen": "Schärfen",
"Sharpening": "Schärfen",
@@ -1408,17 +1409,12 @@
"Short Edge": "Kurze Kante",
"Show": "Anzeigen",
"Show All": "Alle einblenden",
"Show Clipping": "Beschnitt anzeigen",
"Show Deghost Overlay": "Geisterbildüberlagerung anzeigen",
"Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen",
"Show Luminance Map": "Luminanzkarte anzeigen",
"Show Mask": "Maske anzeigen",
"Show Mask Overlay": "Maskenüberlagerung anzeigen",
"Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen",
"Show all overlays": "Alle Überlagerungen anzeigen",
"Show Mask Pins": "Maskenpins anzeigen",
"Show Original": "Original anzeigen",
"Show Photo Counts": "Fotoanzahl anzeigen",
"Show Photos with Keyword": "Fotos mit Stichwort anzeigen",
"Show Pins": "Pins anzeigen",
"Show Source": "Quelle anzeigen",
@@ -1438,7 +1434,7 @@
"Show the import review when a camera or card is connected": "Importfenster öffnen, wenn eine Kamera oder Karte angeschlossen wird",
"Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken",
"Show/Hide Mask": "Maske ein-/ausblenden",
"Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden",
"Show/hide My Photos panel": "Bedienfeld „Meine Fotos“ ein-/ausblenden",
"Shutter speed": "Verschlusszeit",
"Side panels": "Seitliche Bedienfelder",
"Sidecar names": "Namen der Begleitdateien",
@@ -1532,13 +1528,13 @@
"Temp": "Temp.",
"Temperature": "Temperatur",
"Template": "Vorlage",
"Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung — deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.",
"Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung – deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.",
"Text": "Text",
"Text direction": "Textrichtung",
"Texture": "Struktur",
"That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.",
"That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden",
"The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.",
"The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmasken sind verfügbar.",
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek",
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält",
"The download didn't work:": "Der Download ist fehlgeschlagen:",
@@ -1558,7 +1554,7 @@
"Thumbnail cache": "Miniaturbild-Cache",
"Thursday": "Donnerstag",
"Time zone": "Zeitzone",
"Tint": "Tonung",
"Tint": "Tönung",
"Title": "Titel",
"Title metadata": "Titel aus Metadaten",
"Toggle Keyword from Set": "Stichwort aus Satz umschalten",
@@ -1587,7 +1583,7 @@
"Unflagged": "Unmarkiert",
"Ungroup Stack": "Stapel auflösen",
"Unknown Date": "Unbekanntes Datum",
"Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert — prüfe die Vorschau auf einen {typo}.",
"Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert – prüfe die Vorschau auf einen {typo}.",
"Unrated": "Unbewertet",
"Unreadable": "Nicht lesbar",
"Update": "Aktualisieren",
@@ -1639,7 +1635,7 @@
"Watermark": "Wasserzeichen",
"Wednesday": "Mittwoch",
"What's New": "Neuigkeiten",
"When rated or hovered": "Bei Bewertung oder Mauszeigerkontakt",
"When rated or hovered": "Bei Bewertung oder unter dem Mauszeiger",
"White Balance": "Weißabgleich",
"White Balance Selector": "Weißabgleich-Pipette",
"White Balance Selector (W)": "Weißabgleich-Pipette (W)",
@@ -1647,7 +1643,7 @@
"Width": "Breite",
"Width (% of photo)": "Breite (% des Fotos)",
"Width (px)": "Breite (px)",
"Width & Height": "Breite & Höhe",
"Width & Height": "Breite und Höhe",
"Will be replaced": "Wird ersetzt",
"Will be restored": "Wird wiederhergestellt",
"Window": "Fenster",
@@ -1675,7 +1671,7 @@
"`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`, zusammen mit ihren XMP-Begleitdateien. Jedes Original verlässt die Karte erst nach",
"`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`. Auf Laufwerken ohne diese Unterscheidung wird die Schreibweise weiterhin geändert.",
"a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.",
"a literal %": "ein wörtliches %",
"a literal %": "ein %-Zeichen",
"a removed photo": "einem entfernten Foto",
"afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.",
"again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.",
@@ -1753,7 +1749,7 @@
"isn't in the last": "liegt nicht in den letzten",
"it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "statt sie der anderen Datei wegzunehmen (Issue #92). Die Suche fehlender Fotos findet umbenannte Dateien anhand ihres Inhalts und bevorzugt passende Inhalte",
"its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "mit Fotos und XMP-Begleitdateien zurückverschoben; die Fotos verweisen wieder darauf. Ist der alte Speicherort inzwischen belegt,",
"keep names — or e.g. {date}_{seq:3}": "Namen beibehalten — oder z. B. {date}_{seq:3}",
"keep names — or e.g. {date}_{seq:3}": "Namen beibehalten – oder z. B. {date}_{seq:3}",
"latitude, longitude": "Breitengrad, Längengrad",
"layout is read from each file instead of assumed.": "wird aus jeder Datei gelesen, statt vorausgesetzt zu werden.",
"leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "hinterlässt kein DNG und behält die RAW-Datei. Smart-Vorschauen verwenden denselben Schreibweg; eine beschädigte Vorschau nach einem Absturz",
@@ -1843,7 +1839,7 @@
"Any camera": "Jede Kamera",
"Any lens": "Jedes Objektiv",
"Any keyword": "Jedes Stichwort",
"Label Set": "Markierungssatz",
"Label Set": "Farbmarkierungssatz",
", picked": ", ausgewählt",
", rejected": ", abgelehnt",
"Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera",
@@ -1886,7 +1882,7 @@
"Make the denoised picture of the photos I am looking at": "Entrauschtes Bild der angezeigten Fotos berechnen",
"Next up for AI Denoise…": "Als Nächstes beim KI-Entrauschen…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nichts wird geladen, bevor du zustimmst. Das Modell bleibt nur auf diesem Computer; LightCraft lädt es nie hoch und gibt es nie weiter.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Exporte berechnen ihres immer, wenn es fehlt.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Beim Export wird ein fehlendes Bild immer berechnet.",
"Open page": "Seite öffnen",
"Open the model's page": "Seite des Modells öffnen",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Öffnet die Seite des Modells im Browser, um mehr darüber zu lesen oder die Datei selbst herunterzuladen.",
@@ -1910,7 +1906,7 @@
"Unknown licence": "Unbekannte Lizenz",
"Use": "Verwenden",
"Waiting to start…": "Wartet auf Start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit Raw-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit RAW-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.",
"cached picture": "zwischengespeichertes Bild",
"cached pictures": "zwischengespeicherte Bilder",
"commercial use allowed": "kommerzielle Nutzung erlaubt",
@@ -1992,20 +1988,20 @@
"Sa": "Sa",
"Su": "So",
"Pick a date": "Datum wählen",
"would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen",
"would make this album include itself through other smart albums": "würde dazu führen, dass dieses Album sich über andere Smart-Alben selbst enthält",
"Search albums": "Alben suchen",
"No albums match": "Kein Album passt",
"This is the album you're editing": "Das ist das Album, das du bearbeitest",
"It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis",
"Copy Name": "Kopiename",
"It tests this album, so testing it back would loop": "Es prüft bereits dieses Album; eine Prüfung in Gegenrichtung ergäbe eine Endlosschleife",
"Copy Name": "Name der Kopie",
"In a Stack": "In einem Stapel",
"File Extension": "Dateiendung",
"Video Duration": "Videodauer",
"Keyword Count": "Anzahl Stichwörter",
"People Count": "Anzahl Personen",
"Shutter Speed": "Belichtungszeit",
"Shutter Speed": "Verschlusszeit",
"Cropped": "Zugeschnitten",
"Keywords & People": "Stichwörter & Personen",
"Keywords & People": "Stichwörter und Personen",
"Description": "Beschreibung",
"Camera Info": "Kamera-Info",
"Develop": "Bearbeitung",
@@ -2016,7 +2012,7 @@
"Whole names": "Vollständige Namen",
"Grouped by parent": "Nach übergeordnetem Stichwort gruppiert",
"Keywords of the selection": "Stichwörter der Auswahl",
"Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.",
"Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter mit demselben übergeordneten Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.",
"Show every keyword of the selected photos": "Alle Stichwörter der ausgewählten Fotos anzeigen",
"new keyword": "neues Stichwort",
"Return creates this keyword and gives it to the selected photos": "Die Eingabetaste erstellt dieses Stichwort und weist es den ausgewählten Fotos zu",
@@ -2032,14 +2028,14 @@
"Create Keyword Tag": "Stichwort erstellen",
"Edit Keyword Tag": "Stichwort bearbeiten",
"Create": "Erstellen",
"No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.",
"No photo has it; the keywords below it go too.": "Kein Foto verwendet es; die Stichwörter darunter werden ebenfalls gelöscht.",
"Edit Keyword Tag…": "Stichwort bearbeiten…",
"Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen",
"Purge Unused Keywords": "Nicht verwendete Stichwörter löschen",
"Delete Keyword…": "Stichwort löschen…",
"Drop here for the top level": "Hier ablegen für die oberste Ebene",
"Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.",
"Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zu geben.",
"Their photos and the keywords below them come together under one keyword.": "Die Fotos und die untergeordneten Stichwörter werden unter einem Stichwort zusammengeführt.",
"Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zuzuweisen.",
"Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen",
"Will Export": "Wird exportiert",
"No keywords are exported": "Es werden keine Stichwörter exportiert",
@@ -2059,14 +2055,14 @@
"Synchronize Folder": "Ordner synchronisieren",
"Synchronize": "Synchronisieren",
"Scanning…": "Wird durchsucht…",
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien",
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Begleitdateien",
"Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:",
"Import new photos": "Neue Fotos importieren",
"Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen",
"Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen",
"Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Begleitdateien lesen",
"The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.",
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.",
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Begleitdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.",
"Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind",
"Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.",
@@ -2105,7 +2101,7 @@
"Discarding smart previews": "Smart-Vorschauen werden verworfen",
"Importing Lightroom catalog": "Lightroom-Katalog wird importiert",
"Reading Lightroom catalog": "Lightroom-Katalog wird gelesen",
"Merging": "Zusammenfügen",
"Merging": "Wird zusammengefügt",
"Downloading model": "Modell wird heruntergeladen",
"Finding faces": "Gesichter werden gesucht",
"Synchronizing folder": "Ordner wird synchronisiert",
@@ -2153,7 +2149,7 @@
"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",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Begleitdateien in diesen Ordner; nichts wird überschrieben",
"Appearance Settings…": "Erscheinungsbild-Einstellungen…",
"Add to favorites": "Zu Favoriten hinzufügen",
"Remove from favorites": "Aus Favoriten entfernen",
@@ -2170,7 +2166,7 @@
"Detect Faces": "Gesichter erkennen",
"Detect Faces (Photo menu) needs it: a small one-time download.": "„Gesichter erkennen“ (Menü „Foto“) benötigt es: ein kleiner, einmaliger Download.",
"Download Face Model": "Gesichtsmodell herunterladen",
"Drag Crop Handle": "Freistellungsgriff ziehen",
"Drag Crop Handle": "Zuschnittgriff ziehen",
"Evaluate Face Recognition": "Gesichtserkennung bewerten",
"Face Model Downloads": "Downloads von Gesichtsmodellen",
"Face Models": "Gesichtsmodelle",
@@ -2219,7 +2215,7 @@
"Right": "Rechts",
"Top": "Oben",
"Bottom": "Unten",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite des Dias, damit Dias auf jedem Bildschirm gleich aussehen.",
"Show guides": "Hilfslinien anzeigen",
"Aspect preview": "Seitenverhältnis-Vorschau",
"Zoom to fill frame": "Zoomen, um Rahmen zu füllen",
@@ -2233,15 +2229,15 @@
"Background image": "Hintergrundbild",
"(not in the library)": "(nicht in der Bibliothek)",
"Use selected photo": "Ausgewähltes Foto verwenden",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um die Folie hinter jedem Foto damit zu füllen.",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um das Dia hinter jedem Foto damit zu füllen.",
"Reset layout": "Layout zurücksetzen",
"Select a photo to preview its slide": "Wähle ein Foto, um seine Folie in der Vorschau zu sehen",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.",
"Select a photo to preview its slide": "Wähle ein Foto, um sein Dia in der Vorschau zu sehen",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Dia. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.",
"Overlays": "Überlagerungen",
"Identity plate": "Erkennungstafel",
"Your name or studio": "Dein Name oder Studio",
"Anchored to": "Verankert an",
"Slide": "Folie",
"Slide": "Dia",
"Render behind image": "Hinter dem Bild rendern",
"Rating stars": "Sternebewertung",
"Text overlays": "Textüberlagerungen",
@@ -2286,7 +2282,7 @@
"Shooting Data": "Aufnahmedaten",
"Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jedes Foto so groß wie möglich auf Schwarz, mit kurzer Überblendung. Nichts darüber gezeichnet.",
"A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.",
"Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.",
"Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm zugeschnitten, mit langsamen Überblendungen.",
"A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.",
"A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.",
"Exporting slideshow": "Diashow wird exportiert",
@@ -2299,11 +2295,11 @@
"Export JPEG…": "JPEG exportieren…",
"Export Video…": "Video exportieren…",
"Automatically show full screen": "Automatisch im Vollbild anzeigen",
"One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.",
"One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Dia (lange Seite in Pixeln = Punkte), mit Dia-Dauer und Überblendung für den Präsentationsmodus des Betrachters.",
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jedes Dia für seine Dauer mit seinen Überblendungen. Kein Ton: Musik wird noch nicht exportiert.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Dias verwenden Layout, Überlagerungen sowie Intro- und Abschlussbildschirm der Diashow, in der Reihenfolge der Ansicht.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Dias, mit den Einstellungen oben. Der Fortschritt erscheint im Aktivitätsstapel.",
"Checking raw files": "RAW-Dateien werden geprüft",
"1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden",
"This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt",
@@ -2314,9 +2310,8 @@
"Same folder as original photo": "Im selben Ordner wie das Originalfoto",
"Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt",
"Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Smart-Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen",
"LightCraft stopped": "LightCraft wurde beendet",
"Show Focus Point": "Fokuspunkt anzeigen",
"Zoom to Focus Point": "Auf Fokuspunkt zoomen",
"Focus": "Fokus",
"Focus point": "Fokuspunkt",
@@ -2341,7 +2336,7 @@
"Detection": "Erkennung",
"Recognition": "Wiedererkennung",
"Not loaded yet": "Noch nicht geladen",
"Checking the GPU…": "GPU wird geprüft …",
"Checking the GPU…": "GPU wird geprüft…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.",
"Checking the GPU for faces": "GPU für Gesichter wird geprüft",
@@ -2467,8 +2462,8 @@
"Edit in HDR: highlights above SDR white, for HDR exports and displays": "In HDR bearbeiten: Lichter über SDR-Weiß, für HDR-Exporte und -Bildschirme",
"SDR": "SDR",
"Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "Links: der Standardbereich (SDR) bis SDR-Weiß. Rechts: der HDR-Bereich, ein Band pro Blendenstufe über SDR-Weiß (die orange Linie: der HDR-Spielraum). Mit eingeschaltetem „HDR visualisieren“ übernehmen die Bänder dessen Farben.",
"Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter auszublenden.",
"Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter einzublenden.",
"Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter auszublenden.",
"Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter einzublenden.",
"Curve channel": "Kurvenkanal",
"Targeted adjustment on: drag up or down on the photo · Esc to stop": "Gezielte Anpassung aktiv: auf dem Foto nach oben oder unten ziehen · Esc beendet",
"Color wheel": "Farbrad",
@@ -2509,7 +2504,7 @@
"Show mask": "Maske anzeigen",
"Overlay color": "Überlagerungsfarbe",
"Rename Person…": "Person umbenennen…",
"Their photos in the grid": "Ihre Fotos im Raster",
"Their photos in the grid": "Fotos dieser Person im Raster",
"Tool Overlay": "Werkzeugüberlagerung",
"When pins show on the photo (Shift+H cycles)": "Wann Pins auf dem Foto erscheinen (Umschalt+H wechselt)",
"External Editing": "Externe Bearbeitung",
@@ -2531,7 +2526,7 @@
"Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Die in den Farbeinstellungen deines Desktops zugewiesenen Profile liegen meist in ~/.local/share/icc.",
"Slide duration": "Dia-Dauer",
"Fade duration": "Überblendungsdauer",
"A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Eine Überblendung von 0 schneidet hart.",
"A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Bei 0 wird hart geschnitten.",
"Fade": "Überblendung",
"Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).": "Berechnet die nächsten Dias, während das aktuelle zu sehen ist, sodass Überblendungen nie warten. Aus: Jedes Dia wird berechnet, wenn es an der Reihe ist (weniger Speicher und Rechenarbeit).",
"Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "„Diashow“ im Menü „Ansicht“ spielt die Fotos der Ansicht ab dem ausgewählten ab. Diese Einstellungen werden mit der Bibliothek gespeichert.",
@@ -2833,7 +2828,7 @@
"\"LightCraft stopped unexpectedly\" and why, instead of \"could not open its window\", and the next start renders": "„LightCraft wurde unerwartet beendet“ und den Grund statt „konnte sein Fenster nicht öffnen“, und der nächste Start rendert",
"on the CPU and explains how to turn the GPU back on.": "auf der CPU und erklärt, wie man die GPU wieder einschaltet.",
"Resetting a slider": "Einen Regler zurücksetzen",
"Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung — zum Zurücksetzen",
"Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung – zum Zurücksetzen",
"to reset it\"), in every language (issue #1436).": "doppelklicken“), in jeder Sprache (Issue #1436).",
"A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to": "Ein Doppelklick auf den Griff ist ein einziger Schritt in Verlauf und Rückgängig. Sein erster Klick verschiebt den Wert nicht mehr dorthin,",
"where the pointer landed, and a single click on the thumb leaves the value alone.": "wo der Zeiger landete, und ein einfacher Klick auf den Griff lässt den Wert unverändert.",
@@ -2983,7 +2978,7 @@
"Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Die Wahl eines Lightroom-Classic-Katalogordners unter Datei → Bibliothek öffnen… (oder eines seiner Ordner `Previews.lrdata` /",
"`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` oder der `.lrcat`) legt darin keine leere LightCraft-Bibliothek mehr an (Issue #891). Die",
"library open now stays, and LightCraft says \"This is a Lightroom catalog, not a LightCraft library\" and offers": "geöffnete Bibliothek bleibt jetzt, und LightCraft meldet „Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek“ und bietet",
"**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren …** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.",
"**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren…** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.",
"Canon EOS R5 Mark II": "Canon EOS R5 Mark II",
"Switching libraries": "Bibliotheken wechseln",
"out. Clear Recent Libraries empties the list.": "ausgegraut. „Liste leeren“ leert die Liste.",
@@ -3306,8 +3301,8 @@
"the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages": "dem Container, statt es zu dekodieren; kleine Vorschauen verwenden die eingebettete Miniatur der Datei. Die Release-Pakete",
"(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) werden jetzt wirklich mit HEIC-Unterstützung gebaut; ein Build ohne sie meldet",
"\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "„HEIC/HEIF support isn't included in this build“ für jede `.heic`/`.heif`-Datei beim Import.",
"The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tonungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala",
"shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tonungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tonung,",
"The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tönungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala",
"shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tönungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tönung,",
"saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "die in einer früheren Version gespeichert wurde, verschiebt jetzt in die andere Richtung; stelle sie neu ein (oder führe Auto / die Weißabgleichpipette erneut aus).",
"White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "Der Weißabgleich bei DNGs (und anderen RAW-Dateien mit Farbmatrix) entwickelt das Foto für das neue Weiß über die",
"camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey": "eigenen Farbmatrizen der Kamera neu, wie Lightroom es tut, statt die Farben des Renderings „Wie Aufnahme“ zu verschieben: Ein Grau,",
@@ -3379,7 +3374,7 @@
"Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "RAW-Dateien der Sony A7R II (und anderer), deren Kamera-JPEG objektivkorrigiert ist, öffnen nicht mehr grau und zu dunkel (Issue #232): Der",
"starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.": "Ausgangslook wird abseits der Ränder an das Kamera-JPEG angepasst, wenn die verschobenen Ränder die Anpassung über alle Pixel verderben.",
"Lightroom Classic catalogs": "Lightroom-Classic-Kataloge",
"File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren … öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.",
"File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren… öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.",
"Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "Bewertungen, Markierungen, Farbmarkierungen, Stichwörter, Sammlungen/Sätze, virtuelle Kopien und unterstützte Bearbeitungen werden übernommen;",
"existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported": "vorhandene LightCraft-Bearbeitungen bleiben standardmäßig erhalten. Quelleinstellungen und -verlauf werden archiviert, nicht unterstützte",
"fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.": "Felder werden gemeldet, und die ursprüngliche Lightroom-Datenbank bleibt schreibgeschützt. Das Rendering ist eine Annäherung.",
@@ -3423,5 +3418,24 @@
"favourites across restarts (issue #328).": "über Neustarts hinweg Favoriten (Issue #328).",
"Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-",
"inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.",
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference)."
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).",
"Before/After Side by Side": "Vorher/Nachher nebeneinander",
"Clipping": "Beschneidung",
"Mask Overlay": "Maskenüberlagerung",
"All Mask Overlays": "Alle Maskenüberlagerungen",
"Mask Pins": "Maskenpins",
"Photo Counts": "Fotoanzahl",
"Focus Point": "Fokuspunkt",
"Layout Image": "Layoutbild",
"Detail View": "Detailansicht",
"Edit Panel": "Bedienfeld „Bearbeiten“",
"Close the open dialog, then drop the files again": "Schließe den geöffneten Dialog und lege die Dateien dann erneut ab",
"Close the open dialog to drop files here": "Schließe den geöffneten Dialog, um hier Dateien abzulegen",
"Drop to open the face model": "Ablegen, um das Gesichtsmodell zu öffnen",
"Drop to import": "Ablegen zum Importieren",
"Dropped Files": "Abgelegte Dateien",
"Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren",
"An import is running": "Ein Import läuft",
"An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",
"Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"
}
+9 -1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.",
"Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?",
"There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?",
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.",
"Drop to review {n} item{} for import": "Suelta para revisar {n} elemento{} antes de importar",
"Drop to import {n} item{}": "Suelta para importar {n} elemento{}",
"Drop to import {n} preset{}": "Suelta para importar {n} ajuste{0} preestablecido{0}",
"Could not read {name}: {e}": "No se pudo leer {name}: {e}",
"Added {n} photo{}": "{n} foto{0} añadida{0}",
"Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"
}
+22 -9
View File
@@ -228,7 +228,6 @@
"Clear Filters": "Borrar filtros",
"Clear Guides": "Borrar guías",
"Clear History": "Borrar historial",
"Clear Preview Cache": "Vaciar caché de vistas previas",
"Clear Quick Collection": "Borrar colección rápida",
"Clear Reference": "Borrar referencia",
"Clear all": "Borrar todo",
@@ -279,7 +278,7 @@
"Company": "Empresa",
"Compare": "Comparar",
"Compare (Shift+C)": "Comparar (Mayús+C)",
"Compare Before and After": "Comparar antes y después",
"Before/After Side by Side": "Antes/Después lado a lado",
"Compare needs at least two photos": "Comparar requiere al menos dos fotos",
"Component options": "Opciones del componente",
"Compression": "Compresión",
@@ -292,6 +291,8 @@
"Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar",
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco",
"Dialogs": "Diálogos",
"Close a dialog by clicking outside it": "Cerrar un diálogo al hacer clic fuera de él",
"Constrain Crop": "Restringir recorte",
"Contact Sheet PDF": "PDF de hoja de contactos",
"Continue": "Continuar",
@@ -1413,15 +1414,15 @@
"Short Edge": "Lado corto",
"Show": "Mostrar",
"Show All": "Mostrar todo",
"Show Clipping": "Mostrar recorte de tonos",
"Clipping": "Recorte de tonos",
"Show Deghost Overlay": "Mostrar superposición de eliminación de fantasmas",
"Show Hidden Local Locations": "Mostrar ubicaciones ocultas de Local",
"Show Luminance Map": "Mostrar mapa de luminancia",
"Show Mask": "Mostrar máscara",
"Show Mask Overlay": "Mostrar superposición de máscara",
"Show All Mask Overlays": "Mostrar todas las superposiciones de máscara",
"Mask Overlay": "Superposición de máscara",
"All Mask Overlays": "Todas las superposiciones de máscara",
"Show all overlays": "Mostrar todas las superposiciones",
"Show Mask Pins": "Mostrar chinchetas de máscara",
"Mask Pins": "Chinchetas de máscara",
"Show Original": "Mostrar original",
"Show Person": "Mostrar persona",
"Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.",
@@ -1430,7 +1431,7 @@
"Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus",
"Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,",
"Sort Albums A–Z": "Ordenar álbumes A–Z",
"Show Photo Counts": "Mostrar recuento de fotos",
"Photo Counts": "Recuento de fotos",
"Show Photos with Keyword": "Mostrar fotos con la palabra clave",
"Show Pins": "Mostrar chinchetas",
"Show Source": "Mostrar origen",
@@ -2314,7 +2315,7 @@
"Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto",
"LightCraft stopped": "LightCraft se detuvo",
"Show Focus Point": "Mostrar punto de enfoque",
"Focus Point": "Punto de enfoque",
"Zoom to Focus Point": "Zoom al punto de enfoque",
"Focus": "Enfoque",
"Focus point": "Punto de enfoque",
@@ -2342,5 +2343,17 @@
"Checking the GPU…": "Comprobando la GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.",
"Checking the GPU for faces": "Comprobando la GPU para las caras"
"Checking the GPU for faces": "Comprobando la GPU para las caras",
"Layout Image": "Imagen de diseño",
"Detail View": "Vista de detalle",
"Edit Panel": "Panel Editar",
"Close the open dialog, then drop the files again": "Cierra el cuadro de diálogo abierto y vuelve a soltar los archivos",
"Close the open dialog to drop files here": "Cierra el cuadro de diálogo abierto para soltar archivos aquí",
"Drop to open the face model": "Suelta para abrir el modelo de caras",
"Drop to import": "Suelta para importar",
"Dropped Files": "Archivos soltados",
"Import dropped files without review": "Importar los archivos soltados sin revisión",
"An import is running": "Hay una importación en curso",
"An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",
"Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"
}
+9 -1
View File
@@ -102,6 +102,7 @@
"Imported {} photo{}": "Tuotuja kuvia: {}{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…",
"Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})",
"Inside: {names}": "Sisällä: {names}",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.",
"Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat",
"Label Set: {name}": "Merkintäjoukko: {name}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… ja {more} lisää",
"…and {more} more": "…ja {more} lisää"
"…and {more} more": "…ja {more} lisää",
"Added {n} photo{}": "Lisättyjä kuvia: {n}{:.0}",
"Could not read {name}: {e}": "Tiedostoa {name} ei voitu lukea: {e}",
"Drop to import {n} item{}": "Pudota tuodaksesi (kohteita: {n}){:.0}",
"Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",
"Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",
"Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"
}
+22 -9
View File
@@ -306,7 +306,6 @@
"Clear Filters": "Tyhjennä suodattimet",
"Clear Guides": "Tyhjennä apuviivat",
"Clear History": "Tyhjennä historia",
"Clear Preview Cache": "Tyhjennä esikatselun välimuisti",
"Clear Quick Collection": "Tyhjennä pikakokoelma",
"Clear Recent Libraries": "Tyhjennä viimeisimmät kirjastot",
"Clear Reference": "Tyhjennä vertailukuva",
@@ -372,7 +371,6 @@
"Company": "Yritys",
"Compare": "Vertaa",
"Compare (Shift+C)": "Vertaa (Shift+C)",
"Compare Before and After": "Vertaa ennen ja jälkeen",
"Compare needs at least two photos": "Vertailu vaatii vähintään kaksi kuvaa",
"Component options": "Komponentin asetukset",
"Compression": "Pakkaus",
@@ -1822,19 +1820,13 @@
"Show": "Näytä",
"Show / hide": "Näytä / piilota",
"Show All": "Näytä kaikki",
"Show All Mask Overlays": "Näytä kaikki maskien peittokuvat",
"Show Clipping": "Näytä leikkautuminen",
"Show Deghost Overlay": "Näytä haamukuvien poiston peittokuva",
"Show Focus Point": "Näytä tarkennuspiste",
"Show Hidden Local Locations": "Näytä piilotetut paikalliset sijainnit",
"Show Layout Image": "Näytä asettelukuva",
"Show Luminance Map": "Näytä kirkkauskartta",
"Show Mask": "Näytä maski",
"Show Mask Overlay": "Näytä maskin peittokuva",
"Show Mask Pins": "Näytä maskin nastat",
"Show Original": "Näytä alkuperäinen",
"Show Person": "Näytä henkilö",
"Show Photo Counts": "Näytä kuvamäärät",
"Show Photos with Keyword": "Näytä kuvat, joilla on avainsana",
"Show Pins": "Näytä nastat",
"Show Sidebar Section": "Näytä sivupalkin osio",
@@ -2284,10 +2276,12 @@
"or drop a .onnx file on the window": "tai pudota .onnx-tiedosto ikkunaan",
"or drop photos and folders into this window": "tai pudota kuvia ja kansioita tähän ikkunaan",
"or drop photos into this window": "tai pudota kuvia tähän ikkunaan",
"pick": "valittu kuva",
"photo has its denoised picture.": "kuvalla on kohinanpoistettu kuvansa.",
"photo waiting": "kuva odottaa",
"photos have their denoised picture.": "kuvilla on kohinanpoistetut kuvansa.",
"photos waiting": "kuvaa odottaa",
"reject": "hylätty",
"saved in": "tallennettu kohteeseen",
"second": "sekunti",
"starts using it for AI Denoise": "alkaa käyttää sitä tekoälykohinanpoistoon",
@@ -2311,5 +2305,24 @@
"− harder border · + softer border": "− terävämpi reuna · + pehmeämpi reuna",
"≤ at most": "≤ enintään",
"≥ at least": "≥ vähintään",
"★ Favorites": "★ Suosikit"
"★ Favorites": "★ Suosikit",
"All Mask Overlays": "Kaikki maskien peittokuvat",
"Before/After Side by Side": "Ennen/jälkeen vierekkäin",
"Clipping": "Leikkautuminen",
"Detail View": "Tarkastelunäkymä",
"Edit Panel": "Muokkauspaneeli",
"Focus Point": "Tarkennuspiste",
"Layout Image": "Asettelukuva",
"Mask Overlay": "Maskin peittokuva",
"Mask Pins": "Maskin nastat",
"Photo Counts": "Kuvamäärät",
"An import is running": "Tuonti on käynnissä",
"An import is running. Drop photos when it has finished": "Tuonti on käynnissä. Pudota kuvat, kun se on valmis",
"Close the open dialog to drop files here": "Sulje avoin valintaikkuna pudottaaksesi tiedostot tähän",
"Close the open dialog, then drop the files again": "Sulje avoin valintaikkuna ja pudota tiedostot sitten uudelleen",
"Drop to import": "Pudota tuodaksesi",
"Drop to open the face model": "Pudota avataksesi kasvomallin",
"Dropped Files": "Pudotetut tiedostot",
"Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",
"Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"
}
+11 -3
View File
@@ -51,7 +51,7 @@
"Example: {example}": "Exemple : {example}",
"Exported {n} photo{}": "{n} photo{} exportée(s)",
"Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}",
"Exported {} point curve preset{}": "{} préréglage{} de courbe de points exporté(s)",
"Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)",
"Exported {} preset{}": "{} préréglage{} exporté(s)",
"Exporting {} of {total}": "Exportation : {} sur {total}",
"Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)",
@@ -62,7 +62,7 @@
"Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)",
"Import failed: {e}": "Échec de l’importation : {e}",
"Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}",
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)",
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)",
"Imported {n} preset{}": "{n} préréglage{} importé(s)",
"Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)",
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.",
"Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…",
"Inside: {names}": "À l’intérieur : {names}",
"“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?",
"There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?",
"Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{0} déplacée{0} vers « {1} ». Modifier → Annuler les remet en place.",
"Drop to review {n} item{} for import": "Déposez pour vérifier {n} élément{} avant l'import",
"Drop to import {n} item{}": "Déposez pour importer {n} élément{}",
"Drop to import {n} preset{}": "Déposez pour importer {n} préréglage{}",
"Could not read {name}: {e}": "Impossible de lire {name} : {e}",
"Added {n} photo{}": "{n} photo{} ajoutée(s)",
"Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",
"Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"
}
+88 -41
View File
@@ -26,7 +26,7 @@
"Add Folder to Local": "Ajouter le dossier à Local",
"Add Mask": "Ajouter un masque",
"Add Object Click": "Ajouter un clic sur l'objet",
"Add Point Color Sample": "Ajouter un échantillon de couleur ponctuelle",
"Add Point Color Sample": "Ajouter un échantillon de point de couleur",
"Add Red Eye Correction": "Ajouter une correction des yeux rouges",
"Add Remove Spot": "Ajouter une suppression de tache",
"Add Selected Photos": "Ajouter les photos sélectionnées",
@@ -76,21 +76,21 @@
"Apply Color Label Set": "Appliquer un jeu d'étiquettes de couleur",
"Apply Filter Preset": "Appliquer un préréglage de filtre",
"Apply Metadata Preset": "Appliquer un préréglage de métadonnées",
"Apply Point Curve Preset": "Appliquer un préréglage de courbe de points",
"Apply Point Curve Preset": "Appliquer un préréglage de courbe à points",
"Apply Preset": "Appliquer un préréglage",
"Apply Settings JSON": "Appliquer des réglages JSON",
"Approved": "Approuvée",
"Aqua": "Aigue-marine",
"Aqua Hue": "Teinte aigue-marine",
"Aqua Luminance": "Luminance aigue-marine",
"Aqua Saturation": "Saturation aigue-marine",
"Aqua": "Turquoise",
"Aqua Hue": "Teinte turquoise",
"Aqua Luminance": "Luminance turquoise",
"Aqua Saturation": "Saturation turquoise",
"ArtCraft Website": "Site web d'ArtCraft",
"ArtCraft — more creative apps": "ArtCraft : d'autres applications créatives",
"As Shot": "Tel quel",
"As shot": "Tel quel",
"Ascending": "Croissant",
"Aspect": "Format",
"Aspect Ratio": "Rapport d'aspect",
"Aspect Ratio": "Ratio de format",
"Assisted Culling": "Tri assisté",
"Assisted Culling…": "Tri assisté…",
"Auto": "Auto",
@@ -142,7 +142,7 @@
"Best of Similar Shots": "Meilleure des prises similaires",
"Binary assets added": "Ressources binaires ajoutées",
"Binary assets removed": "Ressources binaires supprimées",
"Bit depth": "Profondeur de bits",
"Bit depth": "Profondeur de couleur",
"Black & White": "Noir et blanc",
"Blacks": "Noirs",
"Bleach Bypass": "Bleach bypass",
@@ -214,7 +214,6 @@
"Clear Filters": "Effacer les filtres",
"Clear Guides": "Effacer les repères",
"Clear History": "Effacer l'historique",
"Clear Preview Cache": "Vider le cache des aperçus",
"Clear Quick Collection": "Vider la collection rapide",
"Clear Reference": "Effacer la référence",
"Clear all": "Tout effacer",
@@ -264,7 +263,7 @@
"Company": "Société",
"Compare": "Comparer",
"Compare (Shift+C)": "Comparer (Maj+C)",
"Compare Before and After": "Comparer avant et après",
"Before/After Side by Side": "Avant/Après côte à côte",
"Compare needs at least two photos": "Comparer nécessite au moins deux photos",
"Component options": "Options du composant",
"Compression": "Compression",
@@ -374,9 +373,9 @@
"Delete Permanently": "Supprimer définitivement",
"Delete Photo": "Supprimer la photo",
"Delete Photos": "Supprimer les photos",
"Delete Point Color": "Supprimer la couleur ponctuelle",
"Delete Point Color Sample": "Supprimer l'échantillon de couleur ponctuelle",
"Delete Point Curve Preset": "Supprimer le préréglage de courbe de points",
"Delete Point Color": "Supprimer le point de couleur",
"Delete Point Color Sample": "Supprimer l'échantillon de point de couleur",
"Delete Point Curve Preset": "Supprimer le préréglage de courbe à points",
"Delete Preset": "Supprimer le préréglage",
"Delete Red Eye": "Supprimer les yeux rouges",
"Delete Red Eye Correction": "Supprimer la correction des yeux rouges",
@@ -470,8 +469,8 @@
"Export As…": "Exporter sous…",
"Export Group…": "Exporter le groupe…",
"Export Now": "Exporter maintenant",
"Export Point Curve Presets": "Exporter les préréglages de courbe de points",
"Export Point Curve Presets…": "Exporter les préréglages de courbe de points…",
"Export Point Curve Presets": "Exporter les préréglages de courbe à points",
"Export Point Curve Presets…": "Exporter les préréglages de courbe à points…",
"Export Presets": "Exporter les préréglages",
"Export Presets…": "Exporter les préréglages…",
"Export User Presets…": "Exporter les préréglages utilisateur…",
@@ -602,12 +601,12 @@
"Grid Info": "Informations de la grille",
"Gritty Street": "Rue brute",
"Group": "Groupe",
"Group by Date: Automatic": "Regrouper par date : automatique",
"Group by Day": "Regrouper par jour",
"Group by Month": "Regrouper par mois",
"Group by Year": "Regrouper par année",
"Group by date": "Regrouper par date",
"Group into Stack": "Regrouper en pile",
"Group by Date: Automatic": "Grouper par date : automatique",
"Group by Day": "Grouper par jour",
"Group by Month": "Grouper par mois",
"Group by Year": "Grouper par année",
"Group by date": "Grouper par date",
"Group into Stack": "Grouper en pile",
"Group name": "Nom du groupe",
"Guided": "Guidé",
"Guided Upright": "Upright guidé",
@@ -664,8 +663,8 @@
"Import Lightroom Catalog…": "Importer un catalogue Lightroom…",
"Import Photos": "Importer des photos",
"Import Photos…": "Importer des photos…",
"Import Point Curve Presets": "Importer des préréglages de courbe de points",
"Import Point Curve Presets…": "Importer des préréglages de courbe de points…",
"Import Point Curve Presets": "Importer des préréglages de courbe à points",
"Import Point Curve Presets…": "Importer des préréglages de courbe à points…",
"Import Presets": "Importer des préréglages",
"Import Presets…": "Importer des préréglages…",
"Import Profiles": "Importer des profils",
@@ -1037,10 +1036,10 @@
"Picks": "Sélectionnées",
"Pictures": "Images",
"Pixels": "Pixels",
"Point Color": "Couleur ponctuelle",
"Point Curve": "Courbe de points",
"Point Curve Presets": "Préréglages de courbe de points",
"Point curve": "Courbe de points",
"Point Color": "Point de couleur",
"Point Curve": "Courbe à points",
"Point Curve Presets": "Préréglages de courbe à points",
"Point curve": "Courbe à points",
"Portrait": "Portrait",
"Position": "Position",
"Preferences": "Préférences",
@@ -1257,8 +1256,8 @@
"Save Metadata Preset": "Enregistrer le préréglage de métadonnées",
"Save Metadata Preset…": "Enregistrer le préréglage de métadonnées…",
"Save Metadata to File": "Enregistrer les métadonnées dans le fichier",
"Save Point Curve Preset": "Enregistrer le préréglage de courbe de points",
"Save Point Curve…": "Enregistrer la courbe de points…",
"Save Point Curve Preset": "Enregistrer le préréglage de courbe à points",
"Save Point Curve…": "Enregistrer la courbe à points…",
"Save as Preset": "Enregistrer comme préréglage",
"Save as Set…": "Enregistrer comme jeu…",
"Save as Smart Album…": "Enregistrer comme album intelligent…",
@@ -1308,7 +1307,7 @@
"Set Image Zoom and Pan": "Définir le zoom et le déplacement de l'image",
"Set Keyboard Shortcut": "Définir le raccourci clavier",
"Set Mask Adjustments": "Définir les réglages du masque",
"Set Point Curve": "Définir la courbe de points",
"Set Point Curve": "Définir la courbe à points",
"Set Profile": "Définir le profil",
"Set Rating": "Définir la note",
"Set Top of Stack": "Définir le haut de la pile",
@@ -1343,17 +1342,17 @@
"Short Edge": "Petit côté",
"Show": "Afficher",
"Show All": "Tout afficher",
"Show Clipping": "Afficher l'écrêtage",
"Clipping": "Écrêtage",
"Show Deghost Overlay": "Afficher l'incrustation de suppression des images fantômes",
"Show Hidden Local Locations": "Afficher les emplacements Local masqués",
"Show Luminance Map": "Afficher la carte de luminance",
"Show Mask": "Afficher le masque",
"Show Mask Overlay": "Afficher l'incrustation du masque",
"Show All Mask Overlays": "Afficher toutes les superpositions de masque",
"Mask Overlay": "Incrustation du masque",
"All Mask Overlays": "Toutes les incrustations de masque",
"Show all overlays": "Afficher toutes les superpositions",
"Show Mask Pins": "Afficher les épingles de masque",
"Mask Pins": "Épingles de masque",
"Show Original": "Afficher l'original",
"Show Photo Counts": "Afficher le nombre de photos",
"Photo Counts": "Nombre de photos",
"Show Photos with Keyword": "Afficher les photos avec le mot-clé",
"Show Pins": "Afficher les épingles",
"Show Source": "Afficher la source",
@@ -1458,7 +1457,7 @@
"Targeted adjustment: drag up/down on the photo to adjust the curve there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster la courbe à cet endroit",
"Teal & Amber": "Bleu-vert et ambre",
"Teal & Orange": "Bleu-vert et orange",
"Temp": "Temp.",
"Temp": "Température",
"Temperature": "Température",
"Template": "Modèle",
"Temporary session — your library isn't open, and nothing you do here is saved.": "Session temporaire : votre bibliothèque n'est pas ouverte et rien de ce que vous faites ici n'est enregistré.",
@@ -1930,7 +1929,7 @@
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications",
"Arrange Panel": "Disposer le panneau",
"Arrange Panel": "Modifier le panneau",
"Close panel": "Fermer le panneau",
"Float panel": "Panneau flottant",
"Move to group": "Déplacer vers le groupe",
@@ -1956,7 +1955,7 @@
"Perceptual": "Perceptif",
"Relative": "Relatif",
"Simulate Paper & Ink": "Simuler le papier et l'encre",
"Content-Aware": "Contenu pris en compte",
"Content-Aware": "Selon le contenu",
"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",
@@ -2249,7 +2248,7 @@
"Intro screen": "Écran d'introduction",
"Ending screen": "Écran de fin",
"Shows": "Affiche",
"Colour only": "Couleur seule",
"Colour only": "Couleur unie",
"Override colour": "Remplacer la couleur",
"Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)",
"Duration": "Durée",
@@ -2312,7 +2311,7 @@
"Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo",
"LightCraft stopped": "LightCraft s’est arrêté",
"Show Focus Point": "Afficher le point de mise au point",
"Focus Point": "Point de mise au point",
"Zoom to Focus Point": "Zoomer sur le point de mise au point",
"Focus": "Mise au point",
"Focus point": "Point de mise au point",
@@ -2340,5 +2339,53 @@
"Checking the GPU…": "Vérification du GPU…",
"CPU": "Processeur",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
"Checking the GPU for faces": "Vérification du GPU pour les visages"
"Checking the GPU for faces": "Vérification du GPU pour les visages",
"Layout Image": "Image de mise en page",
"Detail View": "Vue de détail",
"Edit Panel": "Panneau Modifier",
"Close the open dialog, then drop the files again": "Fermez la boîte de dialogue ouverte, puis déposez à nouveau les fichiers",
"Close the open dialog to drop files here": "Fermez la boîte de dialogue ouverte pour déposer des fichiers ici",
"Drop to open the face model": "Déposez pour ouvrir le modèle de visages",
"Drop to import": "Déposez pour importer",
"Dropped Files": "Fichiers déposés",
"Import dropped files without review": "Importer les fichiers déposés sans vérification",
"An import is running": "Une importation est en cours",
"An import is running. Drop photos when it has finished": "Une importation est en cours. Déposez les photos quand elle sera terminée",
"Finish Lightroom catalog import before adding photos": "Terminez l'importation du catalogue Lightroom avant d'ajouter des photos",
"The library is empty: the catalog's photos, collections and edits become its first.": "La bibliothèque est vide : les photos, collections et retouches du catalogue seront les premières.",
"The catalog's photos, collections and edits join the ones already in this library.": "Les photos, collections et retouches du catalogue rejoignent celles déjà présentes dans cette bibliothèque.",
"Create a new library": "Créer une nouvelle bibliothèque",
"LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.": "LightCraft ouvre la nouvelle bibliothèque, puis y importe le catalogue. Son dossier doit être nouveau ou vide.",
"Don't ask again (always merge into the open library)": "Ne plus demander (toujours fusionner avec la bibliothèque ouverte)",
"To be asked again: Settings → General → When importing a Lightroom catalog.": "Pour que la question revienne : Réglages → Général → À l’importation d’un catalogue Lightroom.",
"Create Library and Import": "Créer la bibliothèque et importer",
"Another library was opened since this window appeared: nothing was imported": "Une autre bibliothèque a été ouverte depuis l’apparition de cette fenêtre : rien n’a été importé",
"When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom",
"Ask where to import it": "Demander où l’importer",
"Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte",
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque.",
"At launch": "Au lancement",
"Open the last library": "Ouvrir la dernière bibliothèque",
"Ask me": "Me demander",
"Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.": "« Me demander » affiche vos bibliothèques récentes au lancement. Maintenir Alt (Option sur Mac) pendant que la fenêtre apparaît le demande une fois.",
"Open a Library": "Ouvrir une bibliothèque",
"Choose the library to open.": "Choisissez la bibliothèque à ouvrir.",
"No recent libraries. Open one from a folder, or make a new one.": "Aucune bibliothèque récente. Ouvrez-en une depuis un dossier, ou créez-en une nouvelle.",
"Remove from List": "Retirer de la liste",
"The library itself stays where it is.": "La bibliothèque elle-même reste où elle est.",
"Open": "Ouvrir",
"Open Another…": "Ouvrir une autre…",
"Open now": "Ouverte en ce moment",
"Last opened": "Ouverte le",
"Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté",
"There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft",
"This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque",
"This folder can't be read:": "Ce dossier ne peut pas être lu :",
"The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :",
"Not answering yet: the drive may be asleep or offline": "Pas encore de réponse : le disque est peut-être en veille ou hors ligne",
"This library couldn't be checked": "Impossible de vérifier cette bibliothèque",
"Checking recent libraries": "Vérification des bibliothèques récentes",
"The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte",
"Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",
"This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"
}
+9 -1
View File
@@ -297,5 +297,13 @@
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Added {n} photo{}": "{n} foto ditambahkan{:.0}",
"Could not read {name}: {e}": "Tidak dapat membaca {name}: {e}",
"Drop to import {n} item{}": "Jatuhkan untuk mengimpor {n} item{:.0}",
"Drop to import {n} preset{}": "Jatuhkan untuk mengimpor {n} preset{:.0}",
"Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",
"Inside: {names}": "Di dalam: {names}",
"Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",
"Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"
}
+20 -9
View File
@@ -308,7 +308,6 @@
"Clear Filters": "Hapus filter",
"Clear Guides": "Hapus garis panduan",
"Clear History": "Hapus riwayat",
"Clear Preview Cache": "Kosongkan cache pratinjau",
"Clear Print Layout": "Kosongkan tata letak cetak",
"Clear Quick Collection": "Kosongkan koleksi cepat",
"Clear Recent Libraries": "Hapus daftar pustaka terbaru",
@@ -372,7 +371,6 @@
"Company": "Perusahaan",
"Compare": "Bandingkan",
"Compare (Shift+C)": "Bandingkan (Shift+C)",
"Compare Before and After": "Bandingkan Sebelum dan Sesudah",
"Compare needs at least two photos": "Perlu minimal dua foto untuk dibandingkan",
"Component options": "Opsi komponen",
"Compression": "Kompresi",
@@ -1817,19 +1815,13 @@
"Short Edge": "Sisi pendek",
"Show": "Tampilkan",
"Show All": "Tampilkan semua",
"Show All Mask Overlays": "Tampilkan semua overlay masker",
"Show Clipping": "Tampilkan clipping",
"Show Deghost Overlay": "Tampilkan overlay deghost",
"Show Focus Point": "Tampilkan titik fokus",
"Show Hidden Local Locations": "Tampilkan lokasi Lokal yang disembunyikan",
"Show Layout Image": "Tampilkan gambar tata letak",
"Show Luminance Map": "Tampilkan peta luminans",
"Show Mask": "Tampilkan masker",
"Show Mask Overlay": "Tampilkan overlay masker",
"Show Mask Pins": "Tampilkan pin masker",
"Show Original": "Tampilkan yang asli",
"Show Person": "Tampilkan orang",
"Show Photo Counts": "Tampilkan jumlah foto",
"Show Photos with Keyword": "Tampilkan foto dengan kata kunci ini",
"Show Pins": "Tampilkan pin",
"Show Sidebar Section": "Tampilkan bagian bilah samping",
@@ -2325,5 +2317,24 @@
"≤ at most": "≤ maksimal",
"≥ at least": "≥ minimal",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"All Mask Overlays": "Semua overlay masker",
"Before/After Side by Side": "Sebelum/Sesudah berdampingan",
"Clipping": "Clipping",
"Detail View": "Tampilan Detail",
"Edit Panel": "Panel Edit",
"Focus Point": "Titik fokus",
"Layout Image": "Gambar tata letak",
"Mask Overlay": "Overlay masker",
"Mask Pins": "Pin masker",
"Photo Counts": "Jumlah foto",
"An import is running": "Impor sedang berjalan",
"An import is running. Drop photos when it has finished": "Impor sedang berjalan. Jatuhkan foto setelah selesai",
"Close the open dialog to drop files here": "Tutup dialog yang terbuka untuk menjatuhkan file di sini",
"Close the open dialog, then drop the files again": "Tutup dialog yang terbuka, lalu jatuhkan file lagi",
"Drop to import": "Jatuhkan untuk mengimpor",
"Drop to open the face model": "Jatuhkan untuk membuka model wajah",
"Dropped Files": "File yang dijatuhkan",
"Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",
"Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"
}
+9 -1
View File
@@ -283,6 +283,7 @@
"Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?",
"{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Inside: {names}": "Al suo interno: {names}",
"Template preview: {name}": "Anteprima del modello: {name}",
"{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}",
"Applied {name}": "{name} applicato",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.",
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}"
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}",
"Drop to review {n} item{} for import": "Rilascia per rivedere prima dell'importazione (elementi: {n}){:.0}",
"Drop to import {n} item{}": "Rilascia per importare (elementi: {n}){:.0}",
"Drop to import {n} preset{}": "Rilascia per importare predefiniti: {n}{:.0}",
"Could not read {name}: {e}": "Impossibile leggere {name}: {e}",
"Added {n} photo{}": "Foto aggiunte: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"
}
+20 -9
View File
@@ -215,7 +215,6 @@
"Clear Filters": "Cancella filtri",
"Clear Guides": "Cancella guide",
"Clear History": "Cancella cronologia",
"Clear Preview Cache": "Svuota cache delle anteprime",
"Clear Quick Collection": "Svuota la raccolta rapida",
"Clear Reference": "Cancella riferimento",
"Clear all": "Cancella tutto",
@@ -266,7 +265,6 @@
"Company": "Azienda",
"Compare": "Confronta",
"Compare (Shift+C)": "Confronta (Maiusc+C)",
"Compare Before and After": "Confronta prima e dopo",
"Compare needs at least two photos": "Per confrontare servono almeno due foto",
"Component options": "Opzioni del componente",
"Compression": "Compressione",
@@ -1229,7 +1227,6 @@
"Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto",
"LightCraft stopped": "LightCraft si è chiuso",
"Show Focus Point": "Mostra punto di messa a fuoco",
"Zoom to Focus Point": "Zoom sul punto di messa a fuoco",
"Focus": "Messa a fuoco",
"Focus point": "Punto di messa a fuoco",
@@ -1381,17 +1378,12 @@
"Short Edge": "Lato corto",
"Show": "Mostra",
"Show All": "Mostra tutto",
"Show Clipping": "Mostra tagli",
"Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi",
"Show Hidden Local Locations": "Mostra le posizioni locali nascoste",
"Show Luminance Map": "Mostra mappa di luminanza",
"Show Mask": "Mostra maschera",
"Show Mask Overlay": "Mostra sovrapposizione maschera",
"Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera",
"Show all overlays": "Mostra tutte le sovrapposizioni",
"Show Mask Pins": "Mostra i punti delle maschere",
"Show Original": "Mostra originale",
"Show Photo Counts": "Mostra il numero di foto",
"Show Photos with Keyword": "Mostra le foto con la parola chiave",
"Show Pins": "Mostra i punti",
"Show Source": "Mostra origine",
@@ -2345,5 +2337,24 @@
"Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.",
"Before/After Side by Side": "Prima/Dopo affiancati",
"Clipping": "Tagli tonali",
"Mask Overlay": "Sovrapposizione maschera",
"Mask Pins": "Punti delle maschere",
"Photo Counts": "Numero di foto",
"Focus Point": "Punto di messa a fuoco",
"All Mask Overlays": "Tutte le sovrapposizioni maschera",
"Layout Image": "Immagine di layout",
"Detail View": "Vista dettaglio",
"Edit Panel": "Pannello Modifica",
"Close the open dialog, then drop the files again": "Chiudi la finestra di dialogo aperta, poi rilascia di nuovo i file",
"Close the open dialog to drop files here": "Chiudi la finestra di dialogo aperta per rilasciare qui i file",
"Drop to open the face model": "Rilascia per aprire il modello dei volti",
"Drop to import": "Rilascia per importare",
"Dropped Files": "File rilasciati",
"Import dropped files without review": "Importa i file rilasciati senza revisione",
"An import is running": "Un'importazione è in corso",
"An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",
"Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}",
"Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…",
"Inside: {names}": "この中: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?",
"There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?",
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
"Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})",
"Drop to review {n} item{} for import": "ドロップして {n} 件を確認してから読み込み{:.0}",
"Drop to import {n} item{}": "ドロップして {n} 件を読み込み{:.0}",
"Drop to import {n} preset{}": "ドロップして {n} 件のプリセットを読み込み{:.0}",
"Could not read {name}: {e}": "{name} を読み取れませんでした: {e}",
"Added {n} photo{}": "{n}枚を追加しました{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?",
"Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}"
}
+20 -9
View File
@@ -215,7 +215,6 @@
"Clear Filters": "フィルターを解除",
"Clear Guides": "ガイドを消去",
"Clear History": "履歴を消去",
"Clear Preview Cache": "プレビューキャッシュを消去",
"Clear Quick Collection": "クイックコレクションを空にする",
"Clear Reference": "参照写真を解除",
"Clear all": "すべて解除",
@@ -260,7 +259,7 @@
"Company": "企業",
"Compare": "比較",
"Compare (Shift+C)": "比較(⇧C)",
"Compare Before and After": "編集前と編集後を比較",
"Before/After Side by Side": "編集前/編集後の左右表示",
"Compare needs at least two photos": "比較には2枚以上の写真が必要です",
"Component options": "構成要素の設定",
"Compression": "圧縮",
@@ -1289,17 +1288,17 @@
"Short Edge": "短辺",
"Show": "表示",
"Show All": "すべて表示",
"Show Clipping": "白飛び・黒つぶれを表示",
"Clipping": "白飛び・黒つぶれ",
"Show Deghost Overlay": "ゴースト除去範囲を表示",
"Show Hidden Local Locations": "非表示にしたローカルの場所を表示",
"Show Luminance Map": "輝度マップを表示",
"Show Mask": "マスクを表示",
"Show Mask Overlay": "マスクの重ね表示",
"Show All Mask Overlays": "すべてのマスクオーバーレイを表示",
"Mask Overlay": "マスクの重ね表示",
"All Mask Overlays": "すべてのマスクの重ね表示",
"Show all overlays": "すべてのオーバーレイを表示",
"Show Mask Pins": "マスクのピンを表示",
"Mask Pins": "マスクのピン",
"Show Original": "元の写真を表示",
"Show Photo Counts": "写真の枚数を表示",
"Photo Counts": "写真の枚数",
"Show Photos with Keyword": "このキーワードの写真を表示",
"Show Pins": "ピンを表示",
"Show Source": "コピー元を表示",
@@ -2314,7 +2313,7 @@
"Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください",
"LightCraft stopped": "LightCraftが終了しました",
"Show Focus Point": "フォーカスポイントを表示",
"Focus Point": "フォーカスポイント",
"Zoom to Focus Point": "フォーカスポイントにズーム",
"Focus": "フォーカス",
"Focus point": "フォーカスポイント",
@@ -2342,5 +2341,17 @@
"Checking the GPU…": "GPU を確認中…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。",
"Checking the GPU for faces": "顔用の GPU を確認中"
"Checking the GPU for faces": "顔用の GPU を確認中",
"Layout Image": "レイアウト画像",
"Detail View": "詳細表示",
"Edit Panel": "編集パネル",
"Close the open dialog, then drop the files again": "開いているダイアログを閉じてから、もう一度ファイルをドロップしてください",
"Close the open dialog to drop files here": "ファイルをここにドロップするには、開いているダイアログを閉じてください",
"Drop to open the face model": "ドロップして顔モデルを開く",
"Drop to import": "ドロップして読み込み",
"Dropped Files": "ドロップしたファイル",
"Import dropped files without review": "ドロップしたファイルを確認せずに読み込む",
"An import is running": "読み込み中です",
"An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",
"Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"
}
+9 -1
View File
@@ -268,6 +268,7 @@
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Inside: {names}": "შიგნით: {names}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"Saved template {name}": "Saved template {name}",
"Skipped {problem}": "Skipped {problem}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"Drop to review {n} item{} for import": "ჩააგდეთ იმპორტამდე გადასახედად (ელემენტები: {n}){:.0}",
"Drop to import {n} item{}": "ჩააგდეთ იმპორტისთვის (ელემენტები: {n}){:.0}",
"Drop to import {n} preset{}": "ჩააგდეთ პრესეტების იმპორტისთვის: {n}{:.0}",
"Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}",
"Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"
}
+20 -8
View File
@@ -373,7 +373,6 @@
"Clear Filters": "ფილტრების გასუფთავება",
"Clear Guides": "დამხმარე ხაზების გასუფთავება",
"Clear History": "ისტორიის გასუფთავება",
"Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება",
"Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება",
"Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება",
"Clear Reference": "ეტალონის გასუფთავება",
@@ -429,7 +428,7 @@
"Company": "კომპანია",
"Compare": "შედარება",
"Compare (Shift+C)": "შედარება (Shift+C)",
"Compare Before and After": "მანამდე და შემდეგ შედარება",
"Before/After Side by Side": "მანამდე/შემდეგ გვერდიგვერდ",
"Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ",
"Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება",
"Component options": "კომპონენტის პარამეტრები",
@@ -1902,17 +1901,17 @@
"Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და",
"Show": "ჩვენება",
"Show All": "ყველას ჩვენება",
"Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება",
"Show Clipping": "ჩამოჭრილი ტონების ჩვენება",
"All Mask Overlays": "ნიღბების ყველა ზედდება",
"Clipping": "ჩამოჭრილი ტონები",
"Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება",
"Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება",
"Show Luminance Map": "სიკაშკაშის რუკის ჩვენება",
"Show Mask": "ნიღბის ჩვენება",
"Show Mask Overlay": "ნიღბის ზედდების ჩვენება",
"Show Mask Pins": "ნიღბის მარკერების ჩვენება",
"Mask Overlay": "ნიღბის ზედდება",
"Mask Pins": "ნიღბის მარკერები",
"Show Original": "ორიგინალის ჩვენება",
"Show Person": "ადამიანის ჩვენება",
"Show Photo Counts": "ფოტოების რაოდენობის ჩვენება",
"Photo Counts": "ფოტოების რაოდენობა",
"Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება",
"Show Pins": "მარკერების ჩვენება",
"Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება",
@@ -2719,5 +2718,18 @@
"≥ at least": "≥ არანაკლებ",
"⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"Focus Point": "ფოკუსის წერტილი",
"Layout Image": "განლაგების სურათი",
"Detail View": "დეტალური ხედი",
"Edit Panel": "რედაქტირების პანელი",
"Close the open dialog, then drop the files again": "დახურეთ გახსნილი დიალოგი და ფაილები ხელახლა ჩააგდეთ",
"Close the open dialog to drop files here": "ფაილების აქ ჩასაგდებად დახურეთ გახსნილი დიალოგი",
"Drop to open the face model": "ჩააგდეთ სახეების მოდელის გასახსნელად",
"Drop to import": "ჩააგდეთ იმპორტისთვის",
"Dropped Files": "ჩაგდებული ფაილები",
"Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე",
"An import is running": "იმპორტი მიმდინარეობს",
"An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",
"Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"
}
+58 -50
View File
@@ -5,17 +5,17 @@
" · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}",
" · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)",
" · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}",
" · {} failed: {}": " · {} nieudanych: {}",
" · {} moved": " · {} przeniesionych",
" · {} failed: {}": " · nieudane ({}): {}",
" · {} moved": " · przeniesione: {}",
" · {} not readable": " · {} nie można odczytać",
" · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}",
" · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych",
" · {} restored from Recently Deleted": " · przywrócone z Ostatnio usuniętych: {}",
" — not carried over: {}{}": " — nie przeniesiono: {}{}",
"+{} more": "+{} więcej",
", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}",
"; {kept} already had a location": "; {kept} miało już przypisaną lokalizację",
"; {outside} outside its time range": "; {outside} poza zakresem dat",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, zatwierdzenia: {}, wiersze: +{} / −{} (Δ {}), zasoby binarne: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć",
"Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}",
"Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}",
@@ -29,12 +29,12 @@
"Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}",
"Auto ({})": "Automatycznie ({})",
"Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć",
"Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany obejmują zdjęcia ({n})",
"Automatic ({} MB)": "Automatycznie ({} MB)",
"Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})",
"Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})",
"Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})",
"Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…",
"Building previews for {} photos…": "Tworzenie podglądów (zdjęcia: {})…",
"Click {}:1": "Kliknij {}:1",
"Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}",
"Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych",
@@ -42,7 +42,7 @@
"Decoder: {reason}": "Dekoder: {reason}",
"Delete \"{label}\"": "Usuń „{label}”",
"Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)",
"Delete {n} Photos": "Usuń {n} zdjęć",
"Delete {n} Photos": "Usuń zdjęcia ({n})",
"Delete “{current}”": "Usuń „{current}”",
"Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})",
"Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}",
@@ -69,10 +69,10 @@
"Label Set: {name}": "Zestaw etykiet: {name}",
"LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących",
"Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć",
"Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom — zdjęcia: {photos}, kolekcje: {collections}, brakujące: {missing}",
"Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony — dodane zdjęcia: {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony — dodane zdjęcia: {imported}, błędy: {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony — dodane zdjęcia: {imported}; ostrzeżenie indeksu: {warning}",
"Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.",
@@ -89,7 +89,7 @@
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Kopia próbna ({})",
"Proof Preview · {}": "Podgląd próbny · {}",
"Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć",
"Quick Develop · {n} photos": "Szybka obróbka · zdjęcia: {n}",
"Quick Develop: {name}": "Szybka obróbka: {name}",
"Rated {}": "Ocena: {}",
"Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}",
@@ -100,7 +100,7 @@
"Remove “{}” from Favorites": "Usuń „{}” z ulubionych",
"Removed from {}": "Usunięto z {}",
"Removed {what} from {name}": "Usunięto {what} z {name}",
"Rename {n} Photos…": "Zmień nazwy {n} zdjęć…",
"Rename {n} Photos…": "Zmień nazwy zdjęć ({n})…",
"Rename “{name}”": "Zmień nazwę „{name}”",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.",
"Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}",
@@ -122,7 +122,7 @@
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.",
"Version: {}": "Wersja: {}",
"Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}",
"Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…",
"Working on {what} for {} photos…": "Przetwarzanie: {what} (zdjęcia: {})…",
"none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}",
"the {n} selected photos": "{n} zaznaczonych zdjęć",
"the {} photos in view": "{} zdjęć w bieżącym widoku",
@@ -132,12 +132,12 @@
"{label} Grading": "Gradacja: {label}",
"{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu",
"{label} {index} of {n}": "{label} {index} z {n}",
"{label}, {n} photos": "{label}, {n} zdjęć",
"{label}, {n} photos": "{label}, zdjęcia: {n}",
"{matching} of {t} photos": "{matching} z {t} zdjęć",
"{matching} photos": "{matching} pasujących zdjęć",
"{matching} photos": "Pasujące zdjęcia: {matching}",
"{mode} spot {} of {}": "{mode}: plamka {} z {}",
"{month}": "{month}",
"{month} {year}": "{month} {year}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (nieudanych: {failed})",
"{name} Label": "Etykieta {name}",
"{name} Off": "{name}: wyłączone",
@@ -148,8 +148,8 @@
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę",
"{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}",
"{n} photos": "{n} zdjęć",
"{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry",
"{n} photos": "zdjęcia ({n})",
"{n} photos in {} are hidden by the filters above": "Zdjęcia w {} ukryte przez powyższe filtry: {n}",
"{n} photo{}": "Zdjęcia: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć",
"{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć",
@@ -158,25 +158,25 @@
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}",
"{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}",
"{what} merge added": "Dodano po scaleniu: {what}",
"{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}",
"{} and higher": "{} i nowsze",
"{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń",
"{} failed": "{} nieudanych",
"{} files": "{} plików",
"{} contributors · {} commits": "Współtwórcy: {} · zatwierdzenia: {}",
"{} failed": "{}: niepowodzenie",
"{} files": "Pliki: {}",
"{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}",
"{} item{}": "Elementy: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.",
"{} photos": "{} zdjęć",
"{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°",
"{} photos": "Zdjęcia: {}",
"{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}",
"{} sample{}": "Próbki: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} plamek na tym zdjęciu",
"{} spot(s) on this photo": "Plamki na tym zdjęciu: {}",
"{} — show their photos": "{} — pokaż ich zdjęcia",
"· {dups} already in the library": "· {dups} już w bibliotece",
"· {n} photo{}": "· zdjęcia: {n}{:.0}",
@@ -184,7 +184,7 @@
"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 nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.",
"Filtered: {label}": "Filtrowanie: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie",
"Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć",
"Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązane zdjęcia: {}",
"{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)",
", {} star{}": ", gwiazdki: {}{:.0}",
", {} label": ", {} etykieta",
@@ -199,7 +199,7 @@
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"{n} {noun}": "{noun}: {n}",
"Trained on: {provenance}": "Wytrenowano na: {provenance}",
" · {queued} waiting": " · {queued} oczekuje",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.",
@@ -252,50 +252,58 @@
" · kept, not applied: {}": " · zachowane, niezastosowane: {}",
" — kept but not applied: {}": " — zachowane, ale niezastosowane: {}",
"Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach",
"Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?",
"Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?",
"Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.",
"Delete the effect preset “{name}”?": "Usunąć ustawienie efektu „{name}”?",
"Delete these {n} effect presets?": "Usunąć zaznaczone ustawienia efektów ({n})?",
"Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia efektów ({n})?{:.0} Tej operacji nie można cofnąć.",
"Effect: {name}": "Efekt: {name}",
"Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}",
"Exported {} effect preset{}": "Wyeksportowane ustawienia efektów: {}{:.0}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)",
"Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami",
"{n} preset{}": "Ustawienia wstępne: {n}{:.0}",
"Update “{name}” with Current Settings": "Zaktualizuj „{name}” bieżącymi ustawieniami",
"{n} preset{}": "Ustawienia: {n}{:.0}",
"{count} more warnings.": "Pozostałe ostrzeżenia: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.",
"{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.",
"Applied {name}": "Zastosowano: {name}",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia Znajdź brakujące zdjęcia, aby je ponownie powiązać.",
"Applied {name}": "Zastosowano {name}",
"Delete “{name}”?": "Usunąć „{name}”?",
"Updated {name}": "Zaktualizowano: {name}",
"Updated {name}": "Zaktualizowano {name}",
"Exported to {path}": "Wyeksportowano do {path}",
"Saved template {name}": "Zapisano szablon {name}",
"Imported {name}": "Zaimportowano: {name}",
"Imported {name}": "Zaimportowano {name}",
"Skipped {problem}": "Pominięto: {problem}",
"…and {more} more": "…i jeszcze {more}",
"Template preview: {name}": "Podgląd szablonu: {name}",
"{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}",
"{n} pages": "Strony: {n}",
"{n} JPEG files": "Pliki JPEG: {n}",
"{s} s of AV1 video": "{s} s wideo AV1",
"{s} s of AV1 video": "Wideo AV1: {s} s",
"Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}",
" · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}",
"{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.",
"{n} raws can now be developed from their raw data": "Pliki RAW, które można już wywołać z danych surowych: {n}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików XMP dla zdjęć ({n}) — czy ich folder jest niedostępny? LightCraft ponawia próby.",
"Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”",
"new, inside {parent}": "nowe, wewnątrz {parent}",
"new, inside {parent}": "nowe, w {parent}",
"Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” ze zdjęć ({n}). Edytuj → Cofnij przywraca.",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (twarze: {n}).{:.0} Pliki nie są zmieniane.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij rozdzieli je z powrotem.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano scalenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano scalenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar: {full})",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}",
"Already in “{}”": "Już w „{}”",
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.",
"Drop to review {n} item{} for import": "Upuść, aby przejrzeć przed importem (elementy: {n}){:.0}",
"Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}",
"Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}",
"Could not read {name}: {e}": "Nie można odczytać {name}: {e}",
"Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}",
"Inside: {names}": "Wewnątrz: {names}",
"Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"
}
+26 -15
View File
@@ -210,7 +210,6 @@
"Clear Filters": "Wyczyść filtry",
"Clear Guides": "Wyczyść prowadnice",
"Clear History": "Wyczyść historię",
"Clear Preview Cache": "Wyczyść pamięć podręczną podglądów",
"Clear Quick Collection": "Wyczyść szybką kolekcję",
"Clear Reference": "Wyczyść zdjęcie referencyjne",
"Clear all": "Wyczyść wszystko",
@@ -260,7 +259,6 @@
"Company": "Firma",
"Compare": "Porównaj",
"Compare (Shift+C)": "Porównaj (Shift+C)",
"Compare Before and After": "Porównaj przed i po",
"Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć",
"Component options": "Opcje składnika",
"Compression": "Kompresja",
@@ -1319,15 +1317,11 @@
"Short Edge": "Krótsza krawędź",
"Show": "Pokaż",
"Show All": "Pokaż wszystko",
"Show Clipping": "Pokaż prześwietlenia i niedoświetlenia",
"Show Deghost Overlay": "Pokaż nakładkę usuwania duchów",
"Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne",
"Show Luminance Map": "Pokaż mapę luminancji",
"Show Mask": "Pokaż maskę",
"Show Mask Overlay": "Pokaż nakładkę maski",
"Show Mask Pins": "Pokaż znaczniki masek",
"Show Original": "Pokaż oryginał",
"Show Photo Counts": "Pokaż liczbę zdjęć",
"Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym",
"Show Pins": "Pokaż znaczniki",
"Show Source": "Pokaż źródło",
@@ -1711,17 +1705,17 @@
"Use": "Użyj",
"Waiting to start…": "Oczekiwanie na uruchomienie…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.",
"cached picture": "obraz w pamięci podręcznej",
"cached pictures": "obrazy w pamięci podręcznej",
"cached picture": "Obrazy w pamięci podręcznej",
"cached pictures": "Obrazy w pamięci podręcznej",
"commercial use allowed": "dozwolony użytek komercyjny",
"in use": "w użyciu",
"installed": "zainstalowano",
"makes AI Denoise available": "włącza odszumianie AI",
"non-commercial use only": "wyłącznie do użytku niekomercyjnego",
"photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.",
"photo waiting": "zdjęcie oczekuje",
"photos have their denoised picture.": "zdjęcia mają już odszumione wersje.",
"photos waiting": "zdjęć oczekuje",
"photo has its denoised picture.": "Zdjęcia z odszumioną wersją",
"photo waiting": "Oczekujące zdjęcia",
"photos have their denoised picture.": "Zdjęcia z odszumioną wersją",
"photos waiting": "Oczekujące zdjęcia",
"starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI",
"terms unclear": "niejasne warunki",
"the graphics card": "karta graficzna",
@@ -2138,7 +2132,6 @@
"Remove from favorites": "Usuń z ulubionych",
"Reorder Album": "Zmień kolejność w albumie",
"Reset Sidebar Sections": "Przywróć sekcje paska bocznego",
"Show All Mask Overlays": "Pokaż wszystkie nakładki masek",
"Show Person": "Pokaż osobę",
"Show Sidebar Section": "Pokaż sekcję paska bocznego",
"Show all overlays": "Pokaż wszystkie nakładki",
@@ -2236,7 +2229,6 @@
"Background Tasks": "Zadania w tle",
"Cancel Background Task": "Anuluj zadanie w tle",
"Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie",
"Show Focus Point": "Pokaż punkt ostrości",
"Zoom to Focus Point": "Powiększ do punktu ostrości",
"Show in Compare": "Pokaż w porównaniu",
"Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce",
@@ -2269,5 +2261,24 @@
"SDR Shadows": "Cienie SDR",
"SDR Whites": "Biele SDR",
"SDR Clarity": "Przejrzystość SDR",
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU"
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU",
"Before/After Side by Side": "Przed/Po obok siebie",
"Clipping": "Prześwietlenia i niedoświetlenia",
"Mask Overlay": "Nakładka maski",
"Mask Pins": "Znaczniki masek",
"Photo Counts": "Liczba zdjęć",
"All Mask Overlays": "Wszystkie nakładki masek",
"Focus Point": "Punkt ostrości",
"Layout Image": "Obraz układu",
"Detail View": "Widok szczegółów",
"Edit Panel": "Panel Edytuj",
"Close the open dialog, then drop the files again": "Zamknij otwarte okno dialogowe, a potem upuść pliki ponownie",
"Close the open dialog to drop files here": "Zamknij otwarte okno dialogowe, aby upuścić tu pliki",
"Drop to open the face model": "Upuść, aby otworzyć model twarzy",
"Drop to import": "Upuść, aby zaimportować",
"Dropped Files": "Upuszczone pliki",
"Import dropped files without review": "Importuj upuszczone pliki bez przeglądu",
"An import is running": "Import jest w toku",
"An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",
"Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.",
"Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?",
"There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
"Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})",
"Drop to review {n} item{} for import": "Solte para revisar antes de importar (itens: {n}){:.0}",
"Drop to import {n} item{}": "Solte para importar (itens: {n}){:.0}",
"Drop to import {n} preset{}": "Solte para importar predefinições: {n}{:.0}",
"Could not read {name}: {e}": "Não foi possível ler {name}: {e}",
"Added {n} photo{}": "{n} foto{0} adicionada{0}",
"Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"
}

Some files were not shown because too many files have changed in this diff Show More