diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d2fef73a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + timeout-minutes: 90 + # xtask ci runs cargo test: the demo scene cache is per process, so nextest is slower here. + tools: "" + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev libdbus-1-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 5fc2092a9..19470cf69 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,23 +1,18 @@ # FreeBSD build and package check in a QEMU VM. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: - branches: [main] - paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/freebsd.yml + - packaging/freebsd/** + schedule: + - cron: "7 4 * * *" workflow_dispatch: concurrency: @@ -33,4 +28,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d992f7433..2fe7cd3ac 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,11 +18,44 @@ concurrency: cancel-in-progress: true jobs: - eval: + git-sources: + name: Git dependencies come from storytold runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: nix flake check --no-build --all-systems + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e24229356..7356f66fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,54 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: app-name: lightcraft app-title: LightCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..e78fe1ea3 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 0033346dc..332193998 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -5,23 +5,17 @@ # on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "3 5 * * *" push: - branches: [main] - paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/windows-arm64.yml + - packaging/windows/** workflow_dispatch: concurrency: diff --git a/AGENTS.md b/AGENTS.md index c9f54b71d..959d2808f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd. - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. diff --git a/CLAUDE.md b/CLAUDE.md index 00a2314aa..d2d538d65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. diff --git a/Cargo.lock b/Cargo.lock index 5d7f5111a..8b3985fc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,6 +936,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -3078,8 +3087,9 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.9.1" +version = "0.13.0" dependencies = [ + "chrono", "eframe", "egui", "lightcraft-codecs", @@ -3103,8 +3113,9 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.9.1" +version = "0.13.0" dependencies = [ + "chrono", "crc32fast", "lightcraft-develop", "lightcraft-geom", @@ -3120,7 +3131,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.9.1" +version = "0.13.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3136,7 +3147,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.9.1" +version = "0.13.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3167,7 +3178,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3176,7 +3187,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.9.1" +version = "0.13.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3193,7 +3204,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3208,7 +3219,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3220,9 +3231,10 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.9.1" +version = "0.13.0" dependencies = [ "ab_glyph", + "chrono", "craft-docpath", "harfrust", "jxl-oxide", @@ -3263,7 +3275,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3278,7 +3290,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.9.1" +version = "0.13.0" dependencies = [ "log", "rustls", @@ -3289,7 +3301,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.9.1" +version = "0.13.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3297,7 +3309,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.9.1" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -3305,7 +3317,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.9.1" +version = "0.13.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3325,7 +3337,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.9.1" +version = "0.13.0" dependencies = [ "heic-rs", "rayon", @@ -3333,14 +3345,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.9.1" +version = "0.13.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3349,7 +3361,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3360,7 +3372,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3376,8 +3388,9 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.9.1" +version = "0.13.0" dependencies = [ + "chrono", "lightcraft-geom", "lightcraft-tiff", "proptest", @@ -3388,7 +3401,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.9.1" +version = "0.13.0" dependencies = [ "image", "lightcraft-color", @@ -3406,7 +3419,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.9.1" +version = "0.13.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3418,7 +3431,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.9.1" +version = "0.13.0" dependencies = [ "libc", "lightcraft-color", @@ -3430,7 +3443,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.9.1" +version = "0.13.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3454,7 +3467,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.9.1" +version = "0.13.0" dependencies = [ "image", "lightcraft-color", @@ -3465,7 +3478,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.9.1" +version = "0.13.0" dependencies = [ "candle-core", "candle-nn", @@ -3479,7 +3492,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.9.1" +version = "0.13.0" dependencies = [ "lightcraft-raster", "serde", @@ -3488,15 +3501,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.9.1" +version = "0.13.0" [[package]] name = "lightcraft-testkit" -version = "0.9.1" +version = "0.13.0" [[package]] name = "lightcraft-tiff" -version = "0.9.1" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -3505,7 +3518,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.9.1" +version = "0.13.0" dependencies = [ "craft-ui", "egui", @@ -3534,7 +3547,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.9.1" +version = "0.13.0" dependencies = [ "crc32fast", "eframe", @@ -7004,7 +7017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.1" +version = "0.13.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index cf21739d8..22956f31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.9.1" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -49,6 +49,8 @@ sha2 = "0.10" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" bytemuck = { version = "1", features = ["derive"] } @@ -123,9 +125,11 @@ excessive_precision = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] debug = "line-tables-only" diff --git a/README.md b/README.md index 38ff85898..dd7c538f7 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,18 @@

+

+ LightCraft icon: an engraved lynx on an amber tile +

+

LightCraft

Your photos. Your pixels. Your machine.

English · - 简体中文 + 简体中文 · + 繁體中文

@@ -28,6 +33,9 @@ MCP server included License: MIT OR Apache-2.0 Status: young and moving fast +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -209,7 +217,8 @@ Then tone it with Color Grading for selenium, sepia or split-tone prints. A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in **Folders**, and **Recently Deleted**. Rate with 0–5, flag with P / X / U, colour-label with 6–9. Search understands fields: -`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date, +`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains person:"Jane Doe"`; plain words also match +the people named on faces, and quotes keep several words together. Every view sorts by capture date, import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. @@ -401,10 +410,12 @@ cargo xtask ci # fmt, clippy, HEIF, tes 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 -builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one -per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit -`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings -override these defaults. Ordinary development commands keep their own settings. +builds with fewer. Test threads follow, capped at 4. +Full-debuginfo linkers, one per CPU, otherwise exhaust RAM before any test runs. GPU coverage is +unchanged. Explicit `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS` and +`NEXTEST_TEST_THREADS` settings override these defaults. The tests run under +`cargo test`; `LIGHTCRAFT_NEXTEST=1` runs them under [cargo-nextest](https://nexte.st) instead (one +process per test, slower here because each regenerates the demo scenes). Ordinary development commands keep their own settings. **Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196) reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently @@ -448,7 +459,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული, Svenska, Suomi, Bahasa Indonesia, Tiếng Việt) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder diff --git a/README.zh-hans.md b/README.zh-hans.md index 58e1d627f..bc2f2830e 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -14,7 +14,8 @@

English · - 简体中文 + 简体中文 · + 繁體中文

@@ -356,7 +357,7 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一 (每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建, 而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的 完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置 -`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。 +`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 和 `NEXTEST_TEST_THREADS` 会覆盖这些默认值。 日常开发命令保持它们各自的设置。 **内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196) diff --git a/README.zh-hant.md b/README.zh-hant.md new file mode 100644 index 000000000..12fcfb11b --- /dev/null +++ b/README.zh-hant.md @@ -0,0 +1,629 @@ +

+ + + + ArtCraft + + +

+ + +

LightCraft

+ +

Your photos. Your pixels. Your machine.

+ +

+ English · + 简体中文 · + 繁體中文 +

+ +

+ 照片庫與 raw 顯影;以淨室(clean-room)方式、用純 Rust 重新實作的開源 Adobe Lightroom。
+ 在 macOS、Windows 與 Linux 上原生執行,也能透過 WebAssembly 在瀏覽器中使用,AI 代理也能透過 MCP 全程操作。 +

+ +

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

+ +

+ Join the ArtCraft community on Discord +

+ +

+ getartcraft.com 上的 LightCraft · + ArtCraft · + 所有 Crafting Apps +

+ +
+ +

+ LightCraft 的編輯檢視畫面:放大檢視中是安瑟·亞當斯的《提頓山脈與蛇河》,右側開啟著「光線」與「效果」面板,底片顯示窗格中是四張展示照片 +
+ 安瑟·亞當斯,《提頓山脈與蛇河》(1942)。公眾領域,美國國家檔案館。於 LightCraft 中顯影。 +

+ +> [!NOTE] +> **ArtCraft 是一個由各行各業的創作者組成的社群。** 數位藝術、生成藝術、音樂、 +> 遊戲 — 只要你在創作,你就是我們的一員。**[來 Discord 打聲招呼吧](https://discord.gg/artcraft)。** + +

+ 編輯 · + 色彩分級 · + 編輯前/後對比 · + 遮色片 · + 預設集 · + 照片庫 · + 代理與 MCP · + 效能 · + 功能狀態 · + 快速開始 · + 路線圖 · + 下載 · + Crafting Apps +

+ +
+ +## Edit like you mean it + +LightCraft 是裝在單一原生應用程式裡的完整暗房。每一項調整都是**非破壞性**的,永遠不會動到原始檔案。 +每個滑桿都經過一條**場景參考、寬色域、32 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈, +色彩從拍攝到匯出始終保持乾淨。 + + + + + + +
+ +### ☀️ 光線 +**曝光、對比、亮部、陰影、白色色階、黑色色階**,搭配邊緣感知的局部色調映射(對對數亮度做引導式濾波)。 +把亮部拉到 −100,就能救回一片過曝的天空,而且不會出現用簡單曲線時常見的灰色光暈。 + +### 🎨 色彩 +依**色溫**與**色調**調整**白平衡**(raw 用 K 值,JPEG 用相對值),附帶預設、自動,以及點一下就能中和的**滴管**。 +保護膚色的**自然飽和度**、**飽和度**、8 段式**混色器**(色相/飽和度/明度),以及附帶混合與平衡的 +三區**色彩分級**色輪。所有運算都在 OkLCh 這個現代感知色彩空間中完成。 + + + +### ✨ 效果 +讓細節更細緻的**紋理**、讓中間調更有力的**清晰度**、**去朦朧**(暗通道先驗加引導式細化; +推到負值可增添氛圍感)、裁切後附帶亮部優先、圓度與羽化的**暗角**,以及解析度無關、可調大小與粗糙度的 +底片**顆粒**。 + +### 📈 色調曲線 +可移動分界點的參數曲線(區域曲線),**再加上** RGB、紅、綠、藍的點曲線。曲線在結構上就是單調的, +所以不會意外出現色調反轉。 + +
+ +

+ 「光線」面板下方開啟的色調曲線,RGB 點曲線上是一條平緩的 S 形曲線,套用在《提頓山脈與蛇河》上 +
+ RGB 點曲線上一條平緩的 S 形曲線,就在「光線」滑桿正下方。安瑟·亞當斯,1942(公眾領域)。 +

+ +
+ +## Color grading, the cinematic way + +用可隨處拖曳的色輪,分別為陰影、中間調與亮部做分離色調。下圖中,桃樂絲·蘭格的 +*《移民母親》* 只拖曳兩次,就調出溫暖、如沖印照片般的色調(亮部 42°,陰影 28°)。 + +

+ 中間調、陰影與亮部的色彩分級色輪,旁邊是桃樂絲·蘭格的《移民母親》,調成溫暖的沖印色調 +
+ 「色彩」面板中的陰影、中間調與亮部色輪。桃樂絲·蘭格,《移民母親》(1936)。公眾領域,美國國會圖書館。 +

+ +
+ +## Before & after + +按住 \\ 偷看原圖,按 Y 並排比較,或按 Shift+Y 分割檢視。 +下面每一張都是 LightCraft 的真實截圖,由代理透過[控制通道](#built-for-agents)自動擷取。 + + + + + + + + + + +
《提頓山脈與蛇河》的並排編輯前/後對比:編輯後的雲層更深,河岸邊的暗部更通透
《提頓山脈與蛇河》。 亮部 −45、陰影 +38、清晰度 +28、去朦朧 +18。安瑟·亞當斯,1942(公眾領域)。
《移民母親》的並排編輯前/後對比:編輯後更溫暖,陰影被提亮
《移民母親》。 陰影 +42、紋理 +18、分離色調分級、暗角。桃樂絲·蘭格,1936(公眾領域)。
《地出》的並排編輯前/後對比:月球地平線上方是地球,編輯後略暖、更濃郁
《地出》。 去朦朧 +22、亮部 −30、更暖的白平衡、自然飽和度 +22。NASA/比爾·安德斯,阿波羅 8 號,1968(公眾領域)。
《藍色大理石》的並排編輯前/後對比:編輯後黑色更深,雲層細節更扎實
《藍色大理石》。 亮部 −38、黑色色階 −20、去朦朧 +15、自然飽和度 +30。NASA,阿波羅 17 號,1972(公眾領域)。
+ +
+ +## Masking that goes where you point + +用**筆刷**塗抹(大小、羽化、流量、濃度、擦除),放置附可拖曳控制點的**線性漸層**與**放射狀漸層**, +或依**明度範圍**、**色彩範圍**、**天空**、**主體**與**背景**來選取。用**新增/減去/交集**組合各個元件, +反轉其中任何一個,每個遮色片可設定 15 項局部調整(色溫、色調、曝光、對比、亮部、陰影、白色色階、黑色色階、紋理、 +清晰度、去朦朧、色相、飽和度、銳利化、雜訊抑制),再加上一個整體的強度。 + +

+ 遮色片面板中有一個線性天空遮色片與一個放射狀陽光光暈遮色片;放射狀漸層在湖景照片上以紅色疊加層畫在太陽周圍 +
+ 疊加在線性「天空」遮色片之上的放射狀「陽光光暈」遮色片(色溫 +40、曝光 +0.50)。照片來自 LightCraft 程式產生的示範照片庫。 +

+ +
+ +## Presets, profiles & the Color Mixer + +內建十八個手工打造的預設集(*黃金時刻、青綠與橙色、褪色霧面、硒調色、清晰風景*等),每個都有 0 到 200 % 的**強度**滑桿。你可以從任意一組設定儲存自己的預設集、加入喜好項目, +並在整個選取範圍中,只複製、貼上或同步你選定的那些群組。 + +

+ 預設集欄依黑白、色彩、底片、風景、人像與樣式分組,旁邊是「色彩」面板,8 段式混色器開啟在紫色上 +
+ 預設集欄與 8 段式混色器並排。照片來自示範照片庫。 +

+ + + + + + + + + + +
+ +### ✂️ 裁切、拉直與幾何 +自由或鎖定的長寬比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖曳旋轉拉直時會始終把最大的裁切框 +保持在畫面內,三分法/格線/黃金比例裁切疊加,翻轉與 90° 旋轉,再加上手動的垂直、水平、旋轉、長寬比、 +縮放與偏移變換。 + + + +### ⚫️ 黑白 +一鍵(V)轉為單色,搭配 8 段式**黑白混色**,可壓暗天空或讓樹葉發亮。 +之後再用色彩分級調出硒調色、深褐色調或分離色調的沖印效果。 + +
《提頓山脈與蛇河》上的裁切工具:一個略微旋轉的 16:9 裁切框,附三分法裁切疊加,右側是幾何滑桿
一個 16:9 的裁切框拉直了 +2.5°,附三分法裁切疊加。安瑟·亞當斯,1942(公眾領域)。
沙丘的編輯前/後分割檢視:左邊是彩色,右邊是附顆粒與暗角的黑白
分割檢視:左邊是彩色原圖,右邊是附清晰度、暗角與顆粒的黑白。示範照片庫。
+ +
+ +## Organize everything + +一個不擋路的照片庫:**所有照片**、**最近加入**、**精選照片**、**依日期**、以巢狀收在**資料夾**裡的**相簿**, +以及**最近刪除**。用 0–5 評分,用 P / X / +U 加旗標,用 6–9 加色彩標籤。搜尋支援欄位語法: +`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每個檢視都能依拍攝日期、 +匯入日期、編輯日期、名稱、評分、大小或隨機排序(一種穩定的亂序;「檢視 → 排序 → 重新隨機排序」可換一個順序)。 +左右對齊的**照片格狀檢視**與**方格檢視**都經過虛擬化處理, +所以無論是四十張還是四萬張照片都一樣流暢。 + + + + + + + + + +
24 張示範照片的左右對齊照片格狀檢視,附評分與旗標,側邊欄是 Travel 2026 資料夾下的巢狀相簿
照片格狀檢視,相簿以巢狀收在資料夾裡,附評分與旗標。示範照片庫。
方格檢視,顯示四張公眾領域展示照片及其星級評分與精選旗標
四張公眾領域展示照片的方格檢視。
《地出》的資訊面板,顯示檔名、2400 × 2400 的 JPEG 尺寸、評分、標題欄位與相機中繼資料
資訊面板:檔案、尺寸、評分、標題、圖說、著作權與相機中繼資料。NASA/比爾·安德斯,《地出》,阿波羅 8 號,1968(公眾領域)。
+ +
+ +## Built for agents + +LightCraft 裡的每一個選單項目、滑桿、筆刷筆觸、裁切控點與按鍵,都是一個擁有穩定 ID 與 +JSON 參數的**指令**。介面、鍵盤、CLI、JSON-lines 控制通道與一個 **MCP 伺服器**全都透過同一個入口分派。 +代理可以挑片、顯影、為天空加上遮色片並匯出,而且能*看到*結果。 + +```sh +lightcraft --control 7980 ~/Pictures/trip +``` +```jsonc +{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}} +{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}} +{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}} +{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 依名稱操作任意控制項 +{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}} +{"method": "ui.screenshot", "params": {"path": "after.png"}} +``` + +- **指令註冊表。** `engine.commands` 列出可用的指令;`develop.controls` 列出每個滑桿的範圍、 + 預設值與目前值。 +- **MCP 伺服器。** `lightcraft-cli mcp` 把指令註冊表開放給 Claude(或任何 MCP 用戶端), + 同時提供匯入、查詢、顯影、遮色片、算繪(以影像回傳)與匯出的輔助工具。它可以無介面執行,也可以附掛 + 到正在執行的應用程式上,支援截圖、點擊與手勢。詳見 [docs/mcp.md](docs/mcp.md)。 + + ```sh + cargo build --release -p lightcraft-cli + claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 無介面 + claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 操作執行中的應用程式 + ``` +- **可腳本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export + path=out.jpg longEdge=2048` 可執行任意指令鏈(無介面、對已儲存的照片庫,或對執行中的應用程式), + 每條指令印出一行 JSON 結果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。 +- **一切皆可復原**,包括代理的操作:一次滑桿拖曳(或腳本中一連串的更新)算一步復原。 +- **縮圖目錄(原生應用程式/CLI):** 選取照片,然後 **檔案 → 縮圖目錄 PDF…**。可選擇 A4 或 Letter、橫向、 + 列數與欄數,以及檔名圖說;儲存分頁 PDF,再用你的 PDF 檢視器列印。匯出會套用目前的編輯,每張照片完整放入 + 而不再次裁切,以 150 dpi 和 sRGB 算繪。背景匯出支援取消,並保護原始檔案與 XMP 附屬檔案。從 CLI 呼叫: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true + ``` + + `export.contactSheet` 也接受明確的 `ids`、`landscape=true` 和 `paper=letter`。只有在整份 PDF 成功產生之後, + 它才會取代既有的輸出檔案。限制:1,000 張照片、100 頁、256 MiB;圖說使用與浮水印相同的字型涵蓋範圍來點陣化 + (CJK 文字需要 craft-fonts),過長的名稱會在中間省略。 + 這是一套縮圖目錄工作流程, + 不是印表機驅動程式或自訂拼版編輯器。 +- **列印版面(原生應用程式/CLI):** **檔案 → 列印…** 會開啟「列印」模組的版面編輯器:單張照片、 + 縮圖目錄、相片包(自動版面會把格子排進頁面)與自訂相片包(拖曳並調整格子大小、堆疊順序、對齊格子或 + 格線),縮放填滿/旋轉以符合、相片邊框與內框線,紙張尺寸從 4 × 6 到 13 × 19 以及 A3 到 A5 或自訂, + 邊界、以英吋/公分/公釐/點為單位的尺規、頁面背景、72–720 ppi,以及已儲存的範本。**列印為 PDF…** + 會在背景算繪。一切都是 `print.*` 指令 + (`print.get`、`print.set`、`print.cell.add`、`print.layout.autoLayout`、`print.exportPdf`、 + `print.template.save`……),所以 CLI 和 MCP 也能操作它: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + 印表機色彩管理與頁面疊加層(身分標識、浮水印、頁碼)目前還沒有。 +- **每個控制項都可定址**(`ui.widgets`)且可依名稱點擊,所以代理操作的是真實介面,而不是走旁門。 +- 本 README 中的截圖全部由 [`docs/showcase/`](docs/showcase/) 腳本全自動產生。 + 協定參考:[docs/control-protocol.md](docs/control-protocol.md)。 + +
+ +## Fast, native, private + +- **純 Rust,無 C。** 我們自己的 RAW 解碼器(DNG、Canon CR2/CR3、Sony ARW、Nikon NEF、Fujifilm RAF 含 X-Trans、 + Panasonic RW2/Leica RWL、Pentax PEF、Olympus ORF),我們自己的色彩科學,我們自己的管線。JPEG、PNG、TIFF、WebP、 + PSD 合成圖與 JPEG XL 現在就能開啟;加上 `--features heif` 還能開啟 HEIC/HEIF 照片(HEVC 於建置時選用, + 與 PhotoCraft 中一致)。 +- **場景參考與寬色域。** 內部使用線性 Rec.2020 浮點、Bradford 色彩適應的白平衡、以色域映射取代裁剪, + raw 走底片式肩部曲線,未經改動的 JPEG 則採像素級直通。 +- **解析度無關的編輯。** 半徑與筆刷大小都相對於影像,所以 400 px 的預覽、你的 5K 顯示器和 60 MP 的匯出 + 看起來完全一致。 +- **GPU 加速,與 CPU 結果一致。** 整條顯影管線都以 wgpu 計算核心執行(Metal/Vulkan/DX12), + 並與 CPU 管線比對到 1/255 以內的誤差。在 24 MP 的 raw 上(Apple M4 Pro):一次滑桿更新約 4 ms 重新算繪, + 冷啟動的 2.5 MP 放大檢視約 30 ms,全尺寸匯出約 0.3 秒(含平行 JPEG 編碼)。 + 沒有 GPU 時,同一條管線會在所有 CPU 核心上執行,只重做受滑桿影響的那些階段。批次匯出會同時算繪多張照片, + 所以一張的解碼與編碼能和另一張的算繪重疊。 +- **即時挑片。** 開啟一張 raw 會在約 0.1 秒內顯示其內嵌的相機預覽或快取算繪結果,完整算繪隨後跟上 + (24 MP 約 0.2–0.5 秒)。上一張與下一張會在背景準備好,因此瀏覽整批照片時每張約 50 ms。 +- **背景算繪。** 一個工作執行緒池在 UI 執行緒之外算繪放大檢視、編輯前/後對比與每一個可見縮圖: + 拖曳時用草稿品質,放開後以完整品質算繪。 +- **本機優先。** 沒有帳號、沒有雲端、沒有遙測、沒有訂閱。你的目錄是一份只會附加寫入、可讀的操作紀錄, + 可以比對差異、備份或重播。 + +
+ +## Feature status + +LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,我們目前的進度是**(細節見[路線圖](ROADMAP.md)和 +[docs/target-app-parity.md](docs/target-app-parity.md)): + +- **依功能數量計算,約達 Lightroom 的 81%**(核心功能 98%),逐行記錄在 + [docs/parity-checklist.md](docs/parity-checklist.md)。 +- **作為日常取代 Lightroom 的工具,整體產品更接近 61%,對一般全職攝影師約 51%,一般休閒用途約 65%。** + 在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/ + Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。 +- **最大的缺口:** + - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣; + - **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md); + - **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法; + - **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md); + - **螢幕上的 HDR 顯示、影片,以及 Lightroom Classic 的列印、書冊與地圖模組。** +- **接下來做什麼:** 見[依影響力排序的缺口](docs/gaps.md)和[路線圖](docs/roadmap.md)。 + +| 領域 | 狀態 | +|---|---| +| 照片庫:相簿、資料夾、智慧型相簿、堆疊(含自動堆疊)、虛擬副本、評分、旗標、標籤、篩選列、搜尋、排序、格狀檢視、底片顯示窗格 | ✅ | +| 挑片:比較(同步縮放)與總覽檢視、自動前進、即時預覽 | ✅ | +| 光線、色彩、效果(暗角樣式)、色調曲線(+ 細化飽和度、目標調整)、混色器(+ 目標調整)、點色彩、色彩分級、校正、黑白 | ✅ | +| 遮色片:筆刷、線性、放射狀、明度/色彩範圍、新增/減去/交集 | ✅(AI 主體/天空目前使用傳統啟發式演算法) | +| 裁切、拉直工具 + 自動拉直、翻轉、旋轉、長寬比、裁切疊加參考線 | ✅ | +| 描述檔(色彩、中性、鮮豔、風景、人像、單色:我們自己的風格)、預設集、版本、歷史紀錄、複製/貼上/同步設定 | ✅ | +| 相機色彩:DNG 檔案使用自帶矩陣 | ✅ DNG · 🚧 自家 Sony/Fujifilm 描述檔;缺少實測校正資料庫 | +| 原生 macOS 選單列(由指令註冊表產生)、控制通道 + 每個控制項可定址、無介面 UI 快照 | ✅ | +| RAW:DNG、CR2、CR3(無損 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未壓縮 + 無損/有損壓縮,含有損壓縮經分割後的格式)、Fujifilm RAF(未壓縮 + 無損/有損壓縮,Bayer + X-Trans)、Panasonic RW2/Leica RWL/Panasonic RAW(所有 raw 格式,從 DMC-LX1 到 DC-S1RM2)、Pentax PEF、Olympus ORF(未壓縮,12.8 bpp)、Samsung SRW(未壓縮);所有格式皆支援內嵌預覽,含 CR3 | 🚧 · 其他 CR3 變體、壓縮 ORF/SRW 和 Nikon HE NEF 解碼 ⬜([raw 相容度](docs/raw-parity.md)) | +| 細節:銳利化、明度 + 色彩降噪 | ✅ · AI 降噪 🚧(Bayer,使用者自行安裝權重)、超解析度 ⬜ | +| 斑點移除/修復/仿製(自動取樣來源)、顯示斑點、紅眼與寵物眼校正(自動瞳孔偵測、眼神光) | ✅ · 內容感知填色、斑點圖釘編輯 🚧 | +| 匯出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸調整、檔案大小上限、輸出銳利化、命名範本、批次處理、中繼資料政策、文字或影像浮水印 | ✅ | +| HDR:附餘裕上限的 HDR 編輯、SDR 呈現、視覺化 HDR;匯出為 ISO 21496-1 增益圖 JPEG、PQ AVIF 或 32 位元浮點 TIFF | ✅ · HDR 顯示 ⬜ | +| 照片庫持久化(防當機的操作紀錄 + 快照、背景壓縮、儲存失敗會回報)、磁碟縮圖快取 | ✅ | +| 匯入:原位置加入/複製/移動、重新命名與資料夾範本、裝置、重複檢測、監看資料夾;本機資料夾瀏覽 | ✅ | +| MCP 伺服器(無介面或執行中的應用程式、持久化照片庫)、CLI、控制通道 | ✅ | +| XMP 附屬檔案(讀取/寫入、自動寫入)、讀取 `crs:` 顯影設定、預設集檔案(`.lcpreset`、XMP 預設集) | ✅ | +| 鏡頭校正(變形、暗角、自動 + 手動色差、去除邊緣色差、內嵌於 DNG 檔案的鏡頭校正資料、Panasonic/Leica RW2/RWL 變形資料以及 Nikon Z 6II/Z 8/Z f 變形資料)、幾何(變換、限制裁切範圍)、透視校正(自動/水平/垂直/完整/引導式) | ✅ · 相機鏡頭描述檔(我們自己的)⬜ | +| 照片合併:HDR(自動對齊、去除殘影)、全景(球面/圓柱/透視、邊界變形、自動裁切)、HDR 全景 → DNG | ✅ | +| GPU 管線(wgpu 計算,與 CPU 一致到 1/255 以內)、裝置限制/發生錯誤時改用 CPU | ✅ · 瀏覽器中的 WebGPU 🚧 | +| AI:分割遮色片、AI 降噪、超解析度、人臉;影片 | 🚧(見[缺口清單](docs/gaps.md)) | +| 網頁版(透過 WASM 在瀏覽器中使用同一套介面):照片庫持久化於 OPFS/IndexedDB、Web Worker 算繪、匯出下載 | ✅ · WebGPU、Safari/Firefox 測試 🚧 | + +✅ 現已可用 · 🚧 進行中 · ⬜ 尚未開始 + +
+ +## Quick start + +建置整個工作區(含桌面、網頁與 CLI 應用程式)需要 Rust 1.95 或更新版本。 + +```sh +git clone https://github.com/storytold/lightcraft && cd lightcraft +cargo run --release -p lightcraft # 開啟你的照片庫(~/Pictures/LightCraft Library;新照片庫會先帶示範照片) +cargo run --release -p lightcraft -- ~/Pictures/trip # 匯入你的照片(掃描資料夾,跳過重複項目) +cargo run --release -p lightcraft -- --memory # 用完即丟的記憶體示範工作階段(不寫入任何內容) +cargo run --release -p lightcraft -- --control 7980 # 附帶自動化通道 +cargo xtask web --serve # 在瀏覽器中執行同一個應用程式:http://127.0.0.1:8080/ +cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 +cargo xtask ci # fmt、clippy、測試、分層、wasm 檢查 +``` + +CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開一個建置工作 +(每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置, +而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑 +一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定 +`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 與 `NEXTEST_TEST_THREADS` 會覆寫這些預設值。 +日常開發指令保留各自的設定。 + +**記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196) +回報在一台 16 GB 的 Arch/AUR 機器上,同時建置桌面應用程式與 CLI,並啟用完整除錯資訊、thin LTO +及單一程式碼產生單元時,連結過程遭到 OOM 終止。上述 CI 預設值並不適用於一般的 +`cargo build --release`。建議先用一個建置工作;若記憶體充足,可嘗試兩個: + +```sh +CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli +# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli +``` + +如果打包環境啟用了完整除錯資訊或較耗記憶體的 LTO 設定,可以使用僅對本條指令生效的 +覆寫設定,降低原始碼建置所需的記憶體: + +```sh +CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli +``` + +這些 POSIX shell 賦值只影響這一條指令,不會變更全域 Cargo 設定或儲存庫的 release profile。 +停用 LTO 可能改變效能與二進位檔大小;停用除錯資訊會減少可用的除錯細節。這是建置建議, +並不保證任何機器都有足夠的記憶體。下游打包者應逐一建置大型二進位檔、不要同時進行,並避免在低記憶體 +的建置機器上同時使用完整除錯資訊、LTO 與單一程式碼產生單元。自行匯出 profile 設定的 +PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替 +`makepkg` 加上環境變數前綴就能優先生效。 + +測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」 +這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因, +執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項 +跳過的原因。它會寫入 `target/ci/report.json` 和 `target/ci/summary.md`,後者是可直接貼進 PR 的 +Markdown 區塊(`--summary-md` 會把它印出來);`--hardware` 還會在每個有實體配接器的後端上執行 +GPU 測試。不會上傳任何內容。 + +**中文與日文文字**需要共用字型儲存庫,這是一個可選的建置輸入(所有正式發布版都會包含它): + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft +``` + +不指定它時,LightCraft 一樣能建置與執行,只是中文和日文文字沒有字形。字型從不提交到 +本儲存庫,字型的收錄規範記載在該儲存庫:見 [craft-fonts README](https://github.com/storytold/craft-fonts#readme)。 + +**介面語言:** **編輯 → 語言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français、Українська、Čeština、ქართული)或 **設定 → 一般 → +語言**;選擇後立即生效並會保留。詳見 [docs/localization.md](docs/localization.md)。 + +**記錄檔:** 桌面應用程式會把記錄檔寫到標準錯誤輸出,以及設定資料夾裡的 `logs/lightcraft.log` +(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,預設為 `~/.config/lightcraft/logs/`;macOS +`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),絕不會寫進照片庫裡。 +**可攜模式:** 在執行檔旁放一個名為 `portable.txt`(或 `LightCraft.portable`)的檔案,會把設定 +資料夾移到執行檔旁的 `LightCraftData`。從桌面選單或 Dock 啟動沒有終端機,所以請把這個檔案附在 +錯誤回報裡(**說明 ▸ 開啟記錄資料夾** 會在檔案管理員中顯示它)。每次啟動會把 +上一個記錄檔移到 `lightcraft.1.log`,再上一個移到 `lightcraft.2.log`,這樣當機那次的記錄檔就能在下次啟動後 +保留下來;檔案到 16 MiB 就會停止增長,`--version` 和 `--help` 不會寫記錄檔,帶 +`LIGHTCRAFT_NO_PREFS` 執行時只會寫到標準錯誤輸出。預設情況下,LightCraft 自己的 crate 會記錄到 `info` +層級,其他一切則記錄到 `warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行為和以前一樣(LightCraft 自己的 crate +用該層級,其餘只記警告與錯誤;其他值:只記警告與錯誤),並且優先於 `RUST_LOG`;未設定 `LIGHTCRAFT_LOG` 時,`RUST_LOG` 會把預設值換成 +env_logger 風格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以 +`*` 結尾的指令會涵蓋所有以它開頭的目標,如 `lightcraft*=debug`)。panic 一樣會記錄在那裡, +並且仍然會寫在暫存資料夾裡的 `lightcraft-panics.log`。記錄器程式碼位於 `apps/lightcraft/src/logging.rs`。 + +網頁版需要 `wasm32-unknown-unknown` target 以及對應版本的 `wasm-bindgen` CLI +(`cargo xtask web` 會印出確切的安裝指令);詳見 [docs/web.md](docs/web.md)。 + +**Nix** 會建置桌面應用程式與 `lightcraft-cli`(Nix 建置永遠包含 craft-fonts 輸入,所以日文 +文字有字形): + +```sh +nix run github:storytold/lightcraft # 桌面應用程式 +nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli} +nix develop github:storytold/lightcraft # rust 工具鏈 + 原生相依套件 + 字型 +``` + +在 NixOS 上,請從 `nix develop` 執行 `cargo` 建置:在它之外,應用程式找不到 `libvulkan.so.1`, +偵測不到 GPU,會改用 CPU 算繪(啟動時會印出一次提示,說明這樣會慢很多)。`nix run` 和 `nix build` +會自行設定好路徑。 + +在 flake 設定中(NixOS、home-manager、nix-darwin): + +```nix +# flake.nix +inputs.lightcraft.url = "github:storytold/lightcraft"; +# 選用:使用你自己的 nixpkgs,而不是 LightCraft 鎖定的那個 +# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs"; + +# 然後在 NixOS 或 home-manager 模組中(`inputs` 在作用範圍內): +nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 讓 `pkgs.lightcraft` 可用 +environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ]; +``` + +`nix build` 會安裝與 .deb/.rpm 相同的 desktop 檔案、hicolor 圖示和 AppStream 中繼資料,並執行 +`cargo test --workspace` 作為其檢查階段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳過)。 + +**鍵盤:** G 格狀檢視 · D 詳細檢視 · E 編輯 · C 裁切 · M 遮色片 · +Shift+P 預設集 · \\ 原圖 · Y 編輯前/後 · Z 縮放 · +J 顯示溢色 · ⌘Z 復原 · ⌘/ 全部快速鍵。 + +## How it's built + +一個引擎優先的 Cargo 工作區,由一群小而經過測試的 crate 組成,並強制分層(`cargo xtask layers`):`geom`、 +`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。 +egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI 的存在。`mcp` 和各個應用程式(`lightcraft`、 +`lightcraft-cli`)則坐落在 `engine` 之上。 + +## Contributing + +人類與代理遵循同一套規則,所以請先讀 [AGENTS.md](AGENTS.md)。重點如下: + +- **淨室。** 絕不閱讀 Adobe 的二進位檔或 GPL 的 raw/照片程式碼(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); + 只依據公開規格與黑盒觀察來工作。 +- **絕不使用 Adobe 資產:** 不使用 Adobe 產品的圖示、截圖、預設集、描述檔、LUT 或字型。儲存庫中的每一張 + 影像、圖示和字型都是原創、公眾領域、創用 CC、OFL 或寬鬆授權的,並在同一次提交中 + 於 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 新增條目。新的字型要提交到 + [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是這裡。 +- **純 Rust**、強制的 crate 分層、一切皆指令,並且每次提交前 `cargo xtask ci` 都必須通過 + (一次提交對應一個任務 ID)。 +- **絕不當機。** 非測試程式碼要回傳錯誤而不是 panic:不使用 `unwrap()`、`expect()`、`panic!` 或 + `unsafe`,對任何來自輸入的資料都做受檢的索引存取,並且每一個當機修復都要附一個回歸測試。細節見 + [AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。 + +有問題、想法,或是想先討論一個 bug?來 [Discord](https://discord.gg/artcraft)。 + +
+ +## Downloads + +**第一次使用 LightCraft?** 從 [getartcraft.com 的 LightCraft 頁面](https://getartcraft.com/apps/lightcraft)下載。那是最簡單的安裝方式。 + +**想要特定的建置版本或格式?** 在 GitHub 上,[最新發布版](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有建置版本,[所有發布版](https://github.com/storytold/lightcraft/releases)則有更早的版本與其說明。檔名中的 `` 是版本號,`SHA256SUMS.txt` 列出了每個檔案的檢查碼。 + +### Windows + +| 建置版本 | 安裝程式 | 可攜版 | +|---|---|---| +| x64(64 位元 Intel/AMD) | `lightcraft--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` | +| arm64(Snapdragon 及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | +| x86(32 位元) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | + +安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為 +`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, +並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 + +### macOS + +| 建置版本 | 檔案 | 備註 | +|---|---|---| +| 應用程式,通用版(Apple 晶片 + Intel) | `lightcraft--macos-universal.dmg` | 已簽章並經過公證 | +| 命令列工具,通用版 | `lightcraft-cli--macos-universal.zip` | 已簽章並經過公證 | + +### Linux + +| 格式 | x86_64 | aarch64(ARM64) | 備註 | +|---|---|---|---| +| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | 隨處可執行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 檔案)自我更新 | +| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | 沙箱化;`flatpak install --user ` | +| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | | +| 壓縮檔 | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | 解壓到任意位置即可使用 | + +RISC-V(riscv64):`lightcraft--linux-riscv64.tar.gz`,僅提供壓縮檔(glibc 2.39 或更新版本,例如 Ubuntu 24.04+)。 + +在 Wayland 桌面環境中,LightCraft 會在可用時透過 XWayland 執行,因為只有 X11 支援 +視窗的檔案拖放;設定 `LIGHTCRAFT_WAYLAND=1` 可改用原生 Wayland(更清晰的小數縮放,但無法拖放)。 + +### FreeBSD + +| 建置版本 | 檔案 | +|---|---| +| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| 建置版本 | 檔案 | 備註 | +|---|---|---| +| 靜態網站 | `lightcraft-web-.zip` | 可在現代瀏覽器中執行;可託管在任何靜態伺服器上 | + +
+ +## The Crafting Apps + +LightCraft 是 **Crafting Apps** 之一:來自 [ArtCraft](https://getartcraft.com/) 團隊的免費開源創作工具, +每一個都用 Rust 從零開始撰寫,每一個都能獨立使用。 + +| | 應用程式 | 用途 | 程式碼 | 了解更多 | +|:-:|---|---|---|---| +| | **PhotoCraft** | 影像編輯:圖層、遮色片、文字和真正的 PSD 檔案 | [GitHub](https://github.com/storytold/photocraft) | [網站](https://getartcraft.com/apps/photocraft) | +| | **VectorCraft** | 向量插畫 | [GitHub](https://github.com/storytold/vectorcraft) | [網站](https://getartcraft.com/apps/vectorcraft) | +| | **FilmCraft** | 影片剪輯、調色與聲音 | [GitHub](https://github.com/storytold/filmcraft) | [網站](https://getartcraft.com/apps/filmcraft) | +| | **LightCraft** | **照片庫與 raw 顯影 · 你現在在這裡** | [GitHub](https://github.com/storytold/lightcraft) | [網站](https://getartcraft.com/apps/lightcraft) | +| | **PdfCraft** | 閱讀、整理與保護 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [網站](https://getartcraft.com/apps/pdfcraft) | +| | **EffectCraft** | 動態圖形與視覺特效 | [GitHub](https://github.com/storytold/effectcraft) | [網站](https://getartcraft.com/apps/effectcraft) | +| | **DesignCraft** | 版面編排與出版 | [GitHub](https://github.com/storytold/designcraft) | [網站](https://getartcraft.com/apps/designcraft) | + +還有 [**ArtCraft**](https://getartcraft.com/) 本身,我們為想要真正掌控創作的人打造的 AI 影像與影片工作室。 + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

+ 我們的 Discord 是各種創作者聚集的地方:畫畫的人、拍照的人、做動畫的人、剪片子的人、 + 排版的人,還有仍在摸索自己喜歡做什麼的人。分享你在做的東西, + 尋求幫助,告訴我們哪裡壞了,或者告訴我們你希望這些工具能做到什麼。 + 不管你用什麼媒材,也不管你做了多久,這裡都歡迎你。 +

+ +

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

+ +
+ +## License and credits + +LightCraft 採用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 雙重授權,任你選擇。 +Copyright (c) 2026 ArtCraft Team and the LightCraft contributors。必要的聲明見 [NOTICE](NOTICE)。 + +隨附的字型、圖示、影像與其他資產保留各自的開放授權;每一項都在 +[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、來源與授權。 + +展示用照片皆為公眾領域作品,取自 Wikimedia Commons:安瑟·亞當斯,*《提頓山脈與蛇河》* +(1942,美國國家檔案館);桃樂絲·蘭格,*《移民母親》*(1936,美國國會圖書館);比爾·安德斯/NASA, +*《地出》*(1968);NASA,*《藍色大理石》*(1972)。示範照片庫由 LightCraft 程式產生。介面字型: +Inter(SIL OFL 1.1)。使用 [craft-fonts](https://github.com/storytold/craft-fonts) 建置的版本(所有正式發布版) +還會內嵌其中文與日文字型(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列於其 +[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有圖示皆為原創。 + +[`docs/brand/`](docs/brand/) 中的 ArtCraft 名稱、字標與標誌是 +ArtCraft 團隊的商標,不在本授權範圍內。它們只能以未經修改的形式,作為 +本儲存庫與 LightCraft 的一部分,依 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。 +分支與修改版本必須移除它們。 + +Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 與 InDesign 是 Adobe Inc. 在美國及/或其他國家的商標或註冊商標。LightCraft 是一個獨立的開源專案,與 Adobe Inc. 沒有任何關係,也未獲其贊助或認可;這些名稱僅用於說明它所相容的工作流程。 + +

+ ArtCraft
+ 由 ArtCraft 團隊與社群製作。 +

+ +## Star history + +[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) + +照片庫、工具與預設集面板可以重新分組、拆分、浮動於應用程式視窗內、關閉與重新開啟。工具面板會保留目前的編輯/裁切/遮色片模式;照片檢視區與底片顯示窗格則維持在一起。拖曳面板標題到某個目標邊緣或另一個標題上即可調整。右鍵選單提供「浮動面板」、「移至群組」與「關閉面板」。既有的面板切換按鈕會在上次儲存的位置重新開啟面板;「重設面板配置」則會還原應用程式的版面配置。浮動面板會保持在目前的應用程式視窗之內。 diff --git a/ROADMAP.md b/ROADMAP.md index f142cd226..6d8720b6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -49,7 +49,7 @@ Hours are calibrated on this repo's PR history; see | UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | -| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | +| Localization | ~64% of the 12 key languages | 8 of 12 present | 130–210 h (+135–239 h new scripts) | [localization-parity](docs/localization-parity.md) | | Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | @@ -78,7 +78,7 @@ Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features) ## Languages -Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full` +Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated; German counts 3,644 of 3,754, because its What's New lines add keys); none is `full` while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md). | Language | Code | Translated | Status | @@ -90,13 +90,13 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Arabic | ar | 0% | none (no RTL / shaping) | | French | fr | 88% | partial | | Portuguese (Brazil) | pt-br | 85% | partial | -| Indonesian | id | 0% | none | +| Indonesian | id | 100% (What's New in English) | partial | | Japanese | ja | 87% | partial | -| German | de | 91% | partial | +| German | de | 97% (3,644/3,754) | partial | | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%), Finnish (79%). ## Upcoming @@ -117,8 +117,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ - 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277. +- 2026-10-18: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words, + RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on + CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged. - 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and - checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of + checked against the camera JPEG (`crates/raw/tests/it/canon_pentax_corpus.rs`). 334 models verified, ~50% of photographers on a verified body; headline numbers unchanged. - 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models verified, 35 unsupported; headline numbers unchanged. @@ -132,7 +135,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%. - 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked - against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 + against the camera JPEG (`crates/raw/tests/it/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files @@ -183,17 +186,21 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | | 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | +| 2026-10-16 | minor | German 91% → 97% (217 interface strings and 862 What's New lines added); headline numbers unchanged | | 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged | | 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged | | 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) | +| 2026-10-17 | minor | Finnish (`fi`) interface language added: 2,610 of 3,308 messages (79%), locale, menu command and tests; headline numbers unchanged | | 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | +| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged | | 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% | | 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..6d7340dd5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security Policy + +LightCraft reads camera raw files, image formats, metadata and sidecars, imports other photo catalogs, downloads and runs machine-learning models, and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, catalogs, model files, automation requests, and command parameters as potentially malicious. + +## Reporting a vulnerability + +Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files. + +This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment. + +If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact. + +## What to include + +A useful report includes: + +- affected LightCraft version, commit, or release artifact; +- operating system, architecture, build profile (desktop or web), and relevant feature flags; +- the affected component or path; +- security impact and required attacker access; +- exact reproduction steps; +- expected behavior and actual behavior; +- crash logs, backtraces, sanitizer output, or resource measurements when applicable; +- whether the problem reproduces from a clean checkout; +- any known workaround or containment. + +Remove credentials, personal paths, private photos, and unrelated system information from logs. + +## Malicious test files + +Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample. + +Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical. + +## Scope + +Security reports may cover: + +- camera raw, DNG, TIFF, HEIF, JPEG and other image decoding, and EXIF/XMP metadata and sidecar parsing; +- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs; +- the LightCraft catalog, and importing other catalogs and libraries (such as Lightroom catalogs); +- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite or move behavior; +- downloading, verifying, unpacking and running machine-learning models (denoise, faces, masks); +- engine command validation and serialization/deserialization; +- the desktop control channel, the MCP server, and the MCP-to-app connection; +- the WebAssembly build; +- GPU resource validation or driver-facing input handling; +- dependencies, CI, packaging, signing, checksums, and release artifact integrity. + +General bugs without a security impact should use the project's normal issue and contribution process. + +## Current limitations + +The control channel (`lightcraft --control PORT`) listens on loopback only and has no authentication, so any local process can drive the app while it is enabled; only enable it when you need it ([control-protocol.md](docs/control-protocol.md)). diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 01dae3055..c8371cbb9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> { for (k, v) in opts { p[k] = v; } - let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; if p.get("format").is_none() { let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - o.format = ExportFormat::parse(&ext) - .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + // given before validating, so `bitDepth` is checked against the format written (issue #1952) + p["format"] = json!(ext); } + let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; // Add to Apple Photos: refused before anything is written when it can't be done let after = s.after_export(&o)?; let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/it/cli.rs similarity index 100% rename from apps/lightcraft-cli/tests/cli.rs rename to apps/lightcraft-cli/tests/it/cli.rs diff --git a/apps/lightcraft-cli/tests/it/main.rs b/apps/lightcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..0c07c6438 --- /dev/null +++ b/apps/lightcraft-cli/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod cli; +mod mcp_shutdown; diff --git a/apps/lightcraft-cli/tests/mcp_shutdown.rs b/apps/lightcraft-cli/tests/it/mcp_shutdown.rs similarity index 100% rename from apps/lightcraft-cli/tests/mcp_shutdown.rs rename to apps/lightcraft-cli/tests/it/mcp_shutdown.rs diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs index e20f6dc9c..1dbc2266b 100644 --- a/apps/lightcraft-web/src/backend.rs +++ b/apps/lightcraft-web/src/backend.rs @@ -22,6 +22,12 @@ fn err(e: JsValue) -> String { e.as_string().or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}")) } +/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure +/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507). +fn not_found(e: &JsValue) -> bool { + crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref()) +} + /// `navigator.storage` in a window or a worker. fn storage_manager() -> Option { let g = js_sys::global(); @@ -134,10 +140,7 @@ impl Backend { opts.set_create(create); match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await { Ok(d) => dir = d.unchecked_into(), - Err(e) if !create => { - let _ = e; - return Ok(None); - } + Err(e) if !create && not_found(&e) => return Ok(None), Err(e) => return Err(err(e)), } } @@ -150,7 +153,7 @@ impl Backend { opts.set_create(create); match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await { Ok(h) => Ok(Some(h.unchecked_into())), - Err(_) if !create => Ok(None), + Err(e) if !create && not_found(&e) => Ok(None), Err(e) => Err(err(e)), } } @@ -250,7 +253,7 @@ impl Backend { let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) }; match JsFuture::from(dir.remove_entry(&name)).await { Ok(_) => Ok(()), - Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()), + Err(e) if not_found(&e) => Ok(()), Err(e) => Err(err(e)), } } diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs index 00ec12a41..281a32296 100644 --- a/apps/lightcraft-web/src/backup.rs +++ b/apps/lightcraft-web/src/backup.rs @@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool { !h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric()) } +/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in +/// the app but not yet written to browser storage (a save failed). The zip copies the stored +/// library files, so it would report success while missing them; `None` when everything is saved. +pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option { + let (ops, why) = unsaved?; + let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") }; + Some(format!( + "Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \ + Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved." + )) +} + /// Zip entry name for an original. pub fn original_entry(hash: &str, file_name: &str) -> String { let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect(); diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs index d0c9f348b..9cd934ce5 100644 --- a/apps/lightcraft-web/src/files.rs +++ b/apps/lightcraft-web/src/files.rs @@ -198,6 +198,27 @@ impl Store for FilesStore { /// Names of the library files read at start-up. pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"]; +/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only +/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure. +pub fn is_not_found(dom_exception_name: Option<&str>) -> bool { + dom_exception_name == Some("NotFoundError") +} + +/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that +/// exists but couldn't be read stops start-up instead (#1507): opening the library without it +/// would show an incomplete catalog and then save over the stored copy. +pub fn preload_library(files: &Files, reads: Vec<(&str, Result>, String>)>) -> Result<(), String> { + let mut failed = Vec::new(); + for (name, read) in reads { + match read { + Ok(Some(data)) => files.preload(name, data), + Ok(None) => {} + Err(e) => failed.push(format!("{name}: {e}")), + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) } +} + #[cfg(test)] mod tests { use super::*; @@ -282,6 +303,66 @@ mod tests { assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived"); } + /// A backup copies the stored library files, so while a save is failing it would miss the + /// queued changes: it is refused with the reason until they are written (#1508). + #[test] + fn backup_is_refused_while_changes_are_unsaved() { + let files = Files::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start"); + files.set_error(Some("QuotaExceededError".into())); + let _ = s.execute("photo.rate", &json!({"rating": 5})); + let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved"); + assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}"); + files.set_error(None); + s.persist_if_dirty(); + s.persist().unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written"); + } + + #[test] + fn unsaved_refusal_counts_changes() { + use crate::backup::unsaved_refusal; + assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not")); + assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not")); + } + + /// Start-up only treats a missing file as absent: a stored file that couldn't be read stops + /// it, naming the file, instead of opening an incomplete library (#1507). + #[test] + fn unreadable_library_file_stops_start_up() { + let files = Files::default(); + let reads = vec![ + ("catalog.snap", Ok(Some(b"snap".to_vec()))), + ("catalog.log", Err("NotReadableError: the file could not be read".to_string())), + ("presets.json", Ok(None)), + ]; + let e = preload_library(&files, reads).unwrap_err(); + assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}"); + } + + /// A new library (nothing stored yet) still starts. + #[test] + fn missing_library_files_are_a_new_library() { + let files = Files::default(); + let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect(); + preload_library(&files, reads).unwrap(); + assert!(files.get("catalog.snap").is_none()); + let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))]; + preload_library(&files, reads).unwrap(); + assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..])); + } + + #[test] + fn only_not_found_errors_mean_absent() { + assert!(is_not_found(Some("NotFoundError"))); + for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] { + assert!(!is_not_found(name), "{name:?}"); + } + } + #[test] fn library_survives_reload_through_flushes() { let mut disk = BTreeMap::new(); diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6a4487d09..14a5d050b 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen screen_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; + if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) { + return Err(why); + } // the photos' own names for the originals in the zip let names: std::collections::HashMap = session .catalog @@ -280,6 +283,9 @@ struct Boot { index: ThumbIndex, /// The library folder in storage (`library`, or a restored backup's). lib_dir: String, + /// Library files that exist but couldn't be read (#1507): the library isn't opened and + /// nothing is saved, so the stored copy stays as it was. + load_error: Option, } async fn boot(opts: Options) -> Boot { @@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot { let files = Files::default(); let mut index = ThumbIndex::default(); let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string(); + let mut load_error = None; if let Some(b) = &backend { if opts.reset { // everything this site keeps in the browser, photos included: never without asking @@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot { } } // a restored backup lives in its own folder (see `backup`) - if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await { - let d = String::from_utf8_lossy(&d).trim().to_string(); - if valid_library_dir(&d) { - lib_dir = d; + match b.read(ACTIVE_LIBRARY).await { + Ok(Some(d)) => { + let d = String::from_utf8_lossy(&d).trim().to_string(); + if valid_library_dir(&d) { + lib_dir = d; + } + } + Ok(None) => {} + // not knowing which library is in use, any library opened could be the wrong one + Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")), + } + if load_error.is_none() { + let mut reads = Vec::new(); + for name in LIBRARY_FILES { + reads.push((name, b.read(&library_key(&lib_dir, name)).await)); + } + if let Err(e) = crate::files::preload_library(&files, reads) { + log::error!("{e}"); + load_error = Some(e); } } - for name in LIBRARY_FILES { - match b.read(&library_key(&lib_dir, name)).await { - Ok(Some(data)) => files.preload(name, data), - Ok(None) => {} - Err(e) => log::error!("reading {name}: {e}"), - } - } - if let Ok(Some(j)) = b.read(THUMB_INDEX).await { - index = ThumbIndex::from_json(&j); + let index_read = b.read(THUMB_INDEX).await; + match &index_read { + Ok(Some(j)) => index = ThumbIndex::from_json(j), + Ok(None) => {} + Err(e) => log::warn!("reading the thumbnail index: {e}"), } crate::backend::request_persistence(|granted| { if !granted { @@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot { ); } }); - // thumbnails stored without an index entry (index saved late, or lost): delete them + // thumbnails stored without an index entry (index saved late, or lost): delete them; not + // when the index couldn't be read, which would make every thumbnail look unindexed let b2 = b.clone(); let known: std::collections::HashSet = b .list("thumbs") @@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot { .filter(|n| n.ends_with(".jpg")) .map(|n| n.trim_end_matches(".jpg").to_string()) .collect(); - let orphans: Vec = known.into_iter().filter(|k| !index.contains(k)).collect(); + let orphans: Vec = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() }; if !orphans.is_empty() { wasm_bindgen_futures::spawn_local(async move { for k in &orphans { @@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot { index.len(), index.total() as f64 / 1e6 ); - Boot { opts, backend, files, index, lib_dir } + Boot { opts, backend, files, index, lib_dir, load_error } } struct WebApp { @@ -389,7 +408,7 @@ struct WebApp { impl WebApp { fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self { - let Boot { opts, backend, files, index, lib_dir } = boot; + let Boot { opts, backend, files, index, lib_dir, load_error } = boot; let originals = Originals::default(); let t = perf_now(); let mut session = Session::new(); @@ -401,7 +420,13 @@ impl WebApp { on_disk: false, }; let mut problem = None; - match session.open_library_in(stores, true).cloned() { + // a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy + let read_failed = load_error.is_some(); + let opened = match load_error { + Some(e) => Err(format!("{e}. Nothing was changed in storage")), + None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()), + }; + match opened { Ok(r) => log::info!( "lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)", if r.created { "created" } else { "loaded" }, @@ -432,7 +457,8 @@ impl WebApp { } // previews are large (≤ 2560 px, f32): keep few in a 32-bit address space session.media.preview_capacity = 3; - let frozen = Rc::new(Cell::new(false)); + // nothing is saved while the stored library couldn't be read + let frozen = Rc::new(Cell::new(read_failed)); let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); @@ -667,11 +693,40 @@ impl eframe::App for WebApp { } } +/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else +/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible +/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English, +/// as before. +fn install_browser_language() { + let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return }; + let mut tags: Vec = Vec::new(); + if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into()) + && let Some(languages) = languages.dyn_ref::() + { + for tag in languages.iter() { + if let Some(tag) = tag.as_string() { + tags.push(tag); + } + } + } + if tags.is_empty() + && let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into()) + && let Some(language) = language.as_string() + { + tags.push(language); + } + if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) { + lightcraft_ui_egui::i18n::set_default_language(language); + log::info!("lightcraft: default language {language:?} from the browser's languages"); + } +} + /// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds). #[wasm_bindgen] pub fn start(chinese_font: Vec) { eframe::WebLogger::init(log::LevelFilter::Info).ok(); safety::install_panic_hook(); + install_browser_language(); log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now()); if chinese_font.is_empty() { log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes"); diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 26122c3e6..95e080974 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -26,6 +26,7 @@ lightcraft-raster = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } +chrono = { workspace = true } # probing the window's renderer before it opens (`window_gpu`) pollster = { workspace = true } log = { workspace = true } diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 537c75bd1..c7244c703 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -39,22 +39,33 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); - for mut stream in listener.incoming().flatten() { - if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { - open.fetch_sub(1, Ordering::SeqCst); - let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); - continue; - } - let tx = tx.clone(); - let ctx = ctx.clone(); - let open = Arc::clone(&open); - std::thread::spawn(move || { - serve(stream, &tx, &ctx); - open.fetch_sub(1, Ordering::SeqCst); - }); + for stream in listener.incoming().flatten() { + let _ = spawn_connection(stream, tx.clone(), ctx.clone(), Arc::clone(&open), IDLE_TIMEOUT); } } +/// Admit and serve one connection, releasing its slot when serving ends. +fn spawn_connection( + mut stream: TcpStream, + tx: Sender, + ctx: egui::Context, + open: Arc, + idle_timeout: Duration, +) -> Option> { + if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + open.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); + return None; + } + Some(std::thread::spawn(move || { + serve(stream, &tx, &ctx, idle_timeout); + open.fetch_sub(1, Ordering::SeqCst); + })) +} + +/// Five minutes allows pauses between automation commands without retaining abandoned slots forever. +const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + /// How long a request waits for its reply. const REPLY_TIMEOUT: Duration = Duration::from_secs(60); /// A request the app has not picked up while it drew no frame for this long is given up: the @@ -66,9 +77,17 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5); pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \ (on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again"; -fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { - let Ok(read) = stream.try_clone() else { return }; - serve_lines(read, stream, |method, params| { +/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no +/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts +/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a +/// copy of the connection: closing it then reached the client only when that program exited, as a +/// reset instead of a clean close. +fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context, idle_timeout: Duration) { + if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) { + log::warn!("control connection closed: could not set idle read timeout: {e}"); + return; + } + serve_lines(&stream, &stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); let state = Arc::clone(&req.state); tx.send(req).ok()?; @@ -157,7 +176,10 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - buf.clear(); // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(), + _ => e.to_string(), + })?; if n == 0 { return Ok(false); } @@ -209,6 +231,103 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St mod tests { use super::*; + /// Own the client and worker together: even a failing timeout assertion closes the socket + /// before joining, so the regression cannot leave a worker stuck in a read. + struct Connection { + client: BufReader, + requests: Receiver, + worker: Option>, + } + + impl Connection { + /// `before` acts on the client before the worker starts reading, so what it sends is + /// already there however late this thread gets scheduled. + fn new(open: &Arc, idle: Duration, before: impl FnOnce(&mut TcpStream)) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + 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(); + before(&mut client); + let (tx, requests) = channel(); + let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap(); + Self { client: BufReader::new(client), requests, worker: Some(worker) } + } + + fn join(&mut self) { + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } + } + + impl Drop for Connection { + fn drop(&mut self) { + let _ = self.client.get_ref().shutdown(std::net::Shutdown::Both); + while let Ok(req) = self.requests.try_recv() { + let _ = req.reply.send(json!({"ok": false, "error": "test closing"})); + } + self.join(); + } + } + + #[test] + fn idle_connections_release_their_slots_without_dispatching() { + for input in ["", "{\"method\":\"engine.execute\""] { + let open = Arc::new(AtomicUsize::new(0)); + let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(input.as_bytes()).unwrap()); + assert_eq!(open.load(Ordering::SeqCst), 1); + let mut reply = String::new(); + let closed = connection.client.read_to_string(&mut reply); + assert!(closed.is_ok(), "idle connection stayed open: {closed:?}"); + connection.join(); + assert_eq!(open.load(Ordering::SeqCst), 0, "the production worker releases its slot"); + assert!(connection.requests.try_recv().is_err(), "incomplete input never runs"); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().contains("idle read timed out")); + + // A subsequent connection is admitted through the same accounting path. + let mut next = Connection::new(&open, Duration::from_secs(1), |c| c.shutdown(std::net::Shutdown::Write).unwrap()); + let mut eof = String::new(); + next.client.read_to_string(&mut eof).unwrap(); + next.join(); + assert!(eof.is_empty()); + assert_eq!(open.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() { + let open = Arc::new(AtomicUsize::new(0)); + let request = |id: u64| format!("{}\n", json!({"id": id, "method": "engine.commands"})); + // Each request reaches the socket before the worker reads it (the next one while the worker + // waits for the reply), so only the reply waits outlast the idle timeout, however slowly + // this thread runs. + let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(request(1).as_bytes()).unwrap()); + for id in [1, 2] { + let req = connection.requests.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(req.method, "engine.commands"); + assert!(req.take()); + if id == 1 { + connection.client.get_mut().write_all(request(2).as_bytes()).unwrap(); + } else { + connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + } + // A dispatched command waits for its reply, not for socket input. + std::thread::sleep(Duration::from_millis(250)); + req.reply.send(json!({"ok": true, "result": id})).unwrap(); + let mut reply = String::new(); + connection.client.read_line(&mut reply).unwrap(); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert_eq!(reply, json!({"id": id, "ok": true, "result": id})); + } + let mut eof = String::new(); + connection.client.read_to_string(&mut eof).unwrap(); + connection.join(); + assert!(eof.is_empty()); + assert_eq!(open.load(Ordering::SeqCst), 0); + } + /// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given /// up with a reason, quickly, and the app then skips it instead of running it late. #[test] @@ -216,7 +335,8 @@ mod tests { let (req, rrx) = ControlRequest::new("ui.inspect", json!({})); let t0 = std::time::Instant::now(); let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10)); - assert!(t0.elapsed() < Duration::from_secs(2)); + // the frame window (50 ms), not the 10 s timeout; the margin is for a slow machine + assert!(t0.elapsed() < Duration::from_secs(5), "{:?}", t0.elapsed()); assert_eq!(r["ok"], false); assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}"); assert!(!req.take(), "the app does not run it once it shows up"); @@ -337,6 +457,37 @@ mod tests { assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); } + /// A program the app starts while a client is connected doesn't keep the connection open: + /// when the server closes it, the client sees that at once, not when the program exits. + #[cfg(windows)] + #[test] + fn a_program_started_meanwhile_does_not_hold_the_connection_open() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, _rx) = channel::(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + serve(s, &tx, &egui::Context::default(), IDLE_TIMEOUT); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + // the connection is being served: start a program that lives about 4 s + std::thread::sleep(Duration::from_millis(200)); + let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + // junk makes the server answer once and close + c.write_all(b"hello\n").unwrap(); + let t0 = std::time::Instant::now(); + let mut text = String::new(); + let read = c.read_to_string(&mut text); + let waited = t0.elapsed(); + server.join().unwrap(); + let _ = child.wait(); + assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}"); + assert!(text.contains("closing the connection"), "{text}"); + assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited"); + } + #[test] fn tcp_connection_is_closed_after_junk() { // End to end over a real loopback socket (the transport the app uses). diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs index 718392f35..6239aded6 100644 --- a/apps/lightcraft/src/logging.rs +++ b/apps/lightcraft/src/logging.rs @@ -113,28 +113,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -483,6 +491,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 4ac40bcac..7f8bd6006 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -456,10 +456,6 @@ mod tests { set_language(*language); assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); } - set_language(Locale::ZhHant); - assert_eq!(label_text("File"), "檔案"); - assert_eq!(label_text("Settings…"), "設定…"); - assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft"); set_language(Locale::En); } diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7f8a8a719..30eea5654 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -33,7 +33,11 @@ covers a whole directory), or if a licence file referenced in the Licence column | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | | `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | | `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | +| `crates/ui-egui/locales/id.json`, `crates/ui-egui/locales/id-formats.json` | Indonesian interface catalogs | @RevDonz with Claude Code; PhotoCraft, VectorCraft, FilmCraft and FigCraft translation contributors for reused labels | original translations from English source; 356 labels match the sibling apps' Indonesian wording, reviewed from [PhotoCraft c39d752](https://github.com/storytold/photocraft/blob/c39d752758155492bbaba71d802cfaa03206fb4a/crates/ui-egui/src/i18n/id.tsv), [VectorCraft 9fe0f03](https://github.com/storytold/vectorcraft/blob/9fe0f03de2bd29e8c86a1179f977c3e651069424/crates/ui-egui/src/i18n/id.tsv), [FilmCraft fae7f8a](https://github.com/storytold/filmcraft/blob/fae7f8a9be09bd74f95fbe08c3d98adc17fe1030/crates/ui-egui/src/i18n/id.tsv) and [FigCraft 8482c3e](https://github.com/storytold/figcraft/blob/8482c3e85bc5f2a430e2f3be2826674872f6d084/crates/ui-egui/locales/id.toml) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-19 | adapted to LightCraft's photo context, count-neutral nouns, reviewed placeholders | | `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked | +| `crates/ui-egui/locales/sv.json`, `crates/ui-egui/locales/sv-formats.json` | Swedish interface catalogs | LightCraft contributors | original work: clean-room Swedish translations of English source phrases | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-15 | none | +| `crates/ui-egui/locales/fi.json`, `crates/ui-egui/locales/fi-formats.json` | Finnish interface catalogs | @roopeg with Claude Code (AI-assisted; native-speaker review by @roopeg) | original work: translations of the English source phrases, using common Finnish photo-editing vocabulary; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-17 | count-neutral labels for Finnish partitive agreement, Finnish dates and times (`20.9.2026`, `16:04`), reviewed placeholders | +| `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/contributors/people.toml b/contributors/people.toml index 08d002e8b..4eb002cef 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -57,3 +57,10 @@ sync_github_name = false [people.egegl] real_name = "Ege Gursel" sync_github_name = true + +[people.mahmatias] +display_name = "Márcio Matias de Andrade" +sync_github_name = true + +[people.ronibalthazar] +sync_github_name = true diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 97d8868de..27273d5c4 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } crc32fast = { workspace = true } log = { workspace = true } +chrono = { workspace = true } web-time = { workspace = true } [dev-dependencies] diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 4d16c2f4d..2b71049ee 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -2,6 +2,7 @@ //! weekday and month names, shifting a time by an offset, and grouping a photo list into runs of //! the same day / month / year (the date headers of the photo grid). +use chrono::Datelike; use serde::{Deserialize, Serialize}; pub use crate::stacks::iso_seconds; @@ -11,32 +12,32 @@ pub const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", " pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (proleptic Gregorian; H. Hinnant's algorithm). +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC, proleptic Gregorian). A time outside chrono's year +/// range still converts: the calendar repeats every 400 years, so the instant is moved into range +/// by whole cycles and only the year is carried (matching the old `civil_from_days`), rather than +/// resetting to the epoch. The callers here stay well inside years 0000–9999. pub fn civil(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60) -} - -/// Days since 1970-01-01 → (year, month 1..=12, day 1..=31). -pub fn civil_from_days(days: i64) -> (i64, i64, i64) { - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - (yoe + era * 400 + i64::from(m <= 2), m, d) + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not). + format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%S")) + } + _ => "1970-01-01T00:00:00".to_string(), // unreachable + } } /// Weekday name of an ISO date (`None` if it doesn't parse). pub fn weekday(iso: &str) -> Option<&'static str> { let days = iso_seconds(iso.get(..10)?)?.div_euclid(86_400); - // 1970-01-01 was a Thursday - Some(WEEKDAYS[(days + 3).rem_euclid(7) as usize]) + let date = chrono::NaiveDate::from_num_days_from_ce_opt(i32::try_from(days.checked_add(719_163)?).ok()?)?; + Some(WEEKDAYS[date.weekday().num_days_from_monday() as usize]) } /// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. @@ -156,38 +157,56 @@ impl Catalog { /// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`] /// ([`GroupBy::Auto`] means days here). pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec { - if by == GroupBy::None || ids.is_empty() { + if !groups_by_date(key, by) || ids.is_empty() { return Vec::new(); } - let n = by.prefix(); - let date_of = |id: &PhotoId| -> String { - let Some(p) = self.photo(*id) else { return String::new() }; - let d = match key { - SortKey::CaptureDate => p.captured.as_deref(), - SortKey::ImportDate => Some(p.imported.as_str()), - SortKey::EditDate => p.edited.as_deref(), - _ => None, - }; - d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string() - }; - if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) { - return Vec::new(); - } - let stacks = self.stack_index(); - let mut runs: Vec = Vec::new(); - let mut stack_key: std::collections::HashMap = Default::default(); - for (i, id) in ids.iter().enumerate() { - let k = match stacks.get(id) { - Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(), - None => date_of(id), - }; - match runs.last_mut() { - Some(r) if r.key == k => r.count += 1, - _ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }), - } - } - runs + date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by)) } + + /// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` / + /// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its + /// first photo in the list there. + pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String { + let Some(p) = self.photo(id) else { return String::new() }; + let d = match key { + SortKey::CaptureDate => p.captured.as_deref(), + SortKey::ImportDate => Some(p.imported.as_str()), + SortKey::EditDate => p.edited.as_deref(), + _ => None, + }; + d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string() + } +} + +/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`. +pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool { + by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) +} + +/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see +/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]). +pub fn date_runs_of>( + ids: &[PhotoId], + stacks: &std::collections::HashMap, + mut group: impl FnMut(usize, PhotoId) -> K, +) -> Vec { + let mut runs: Vec = Vec::new(); + let mut stack_key: std::collections::HashMap = Default::default(); + for (i, id) in ids.iter().enumerate() { + let own; + let k: &str = match stacks.get(id) { + Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()), + None => { + own = group(i, *id); + own.as_ref() + } + }; + match runs.last_mut() { + Some(r) if r.key == k => r.count += 1, + _ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }), + } + } + runs } #[cfg(test)] @@ -199,6 +218,14 @@ mod tests { fn calendar() { assert_eq!(civil(0), "1970-01-01T00:00:00"); assert_eq!(civil(951_782_400), "2000-02-29T00:00:00"); + // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did. + assert_eq!(civil(253_402_300_800), "10000-01-01T00:00:00"); + // Beyond chrono's year range the old `civil_from_days` still converted; pin that output + // (never the epoch) in both directions and at the i64 extremes. + assert_eq!(civil(10_000_000_000_000), "318857-05-20T17:46:40"); + assert_eq!(civil(-10_000_000_000_000), "-314918-08-13T06:13:20"); + assert_eq!(civil(i64::MAX), "292277026596-12-04T15:30:07"); + assert_eq!(civil(i64::MIN), "-292277022657-01-27T08:29:52"); assert_eq!(weekday("2026-09-30"), Some("Wednesday")); assert_eq!(weekday("2026-10-01T09:00:00"), Some("Thursday")); assert_eq!(weekday("2000-02-29"), Some("Tuesday")); @@ -233,6 +260,15 @@ mod tests { assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); } + #[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"] { + assert_eq!(normalize_iso(bad), None, "{bad}"); + assert_eq!(shift_iso(bad, 5), None, "{bad}"); + } + assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59")); + } + #[test] fn runs_by_day_month_year_keep_stacks_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 6ac3544ee..b405e1db9 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -329,7 +329,7 @@ pub struct Analysis { /// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: /// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { - ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f)) + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f)) } /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed @@ -610,6 +610,7 @@ mod edited_tests { ("a.pef", "pef", true), ("a.orf", "ORF", true), ("a.erf", "ERF", true), + ("a.mrw", "MRW", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); @@ -653,6 +654,7 @@ mod edited_tests { ("SONY ILCE-1", "ARW", true), ("", "ARW", true), ("SONY ILCE-7M3", "DNG", false), + ("NIKON CORPORATION NIKON Z5_2", "NEF", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00"); p.kind = MediaKind::Raw; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 7625c9ab6..a94421369 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -141,9 +141,40 @@ pub(crate) struct Prepared { only: Option>, } +/// The tokens of a search text: split at whitespace, except inside double quotes, so +/// `person:"Jane Doe"` is one token (issue #1449). An unclosed quote runs to the end of the text; a +/// backslash is an ordinary character. One pass over the text, nothing allocated. +fn search_tokens(text: &str) -> impl Iterator { + let mut rest = text; + std::iter::from_fn(move || { + rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let mut quoted = false; + let end = rest + .char_indices() + .find(|&(_, c)| { + quoted ^= c == '"'; + !quoted && c.is_whitespace() + }) + .map_or(rest.len(), |(i, _)| i); + let (tok, tail) = rest.split_at(end); + rest = tail; + Some(tok) + }) +} + +/// `s` without the double quotes around it (a missing closing quote is allowed). +fn unquote(s: &str) -> &str { + s.strip_prefix('"').map_or(s, |inner| inner.strip_suffix('"').unwrap_or(inner)) +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); - if let Some((field, val)) = t.split_once(':') { + // a token that starts with a quote is a phrase, even with a colon in it + if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) { + let val = unquote(quoted); let num = |s: &str| -> Option<(char, f64)> { let (op, rest) = match s.chars().next()? { c @ ('>' | '<' | '=') => (c, &s[1..]), @@ -180,18 +211,22 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)), "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), - // Not a known field: the colon is part of a plain word ("10:30", "Re:"). + // Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted + // (`re:"a b"` is the text `re:a b`). + _ if val.len() != quoted.len() => text_matches(p, &format!("{field}:{val}")), _ => text_matches(p, &t), }; } - text_matches(p, &t) + text_matches(p, unquote(&t)) } /// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location, -/// format or a keyword of `p`. +/// format, a keyword or the name of a person (a named face) of `p`. fn text_matches(p: &Photo, t: &str) -> bool { let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format]; - hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t)) + hay.iter().any(|h| h.to_lowercase().contains(t)) + || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t)) + || p.people().iter().any(|n| n.to_lowercase().contains(t)) } /// Whether `p` has a named face region called `name` (case-insensitive). @@ -409,7 +444,7 @@ impl Filter { { return false; } - self.text.split_whitespace().all(|tok| token_matches(p, tok)) + search_tokens(&self.text).all(|tok| token_matches(p, tok)) } } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index a87c9effc..667509d6f 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -794,12 +794,90 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { } } +/// One byte as the `filePath` rules compare it: `/` and `\` both separate, case doesn't matter. +fn fold_path_byte(b: u8) -> u8 { + if b == b'\\' { b'/' } else { b.to_ascii_lowercase() } +} + +/// Whether `a` and `b` are equal under [`fold_path_byte`]. +fn path_bytes_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| fold_path_byte(*x) == fold_path_byte(*y)) +} + +/// `needle` inside `hay`, under [`fold_path_byte`]; allocates nothing. +fn path_bytes_contains(hay: &[u8], needle: &[u8]) -> bool { + let Some((&first, rest)) = needle.split_first() else { return true }; + // the spellings of the needle's first byte that fold to it: a plain two-way compare per byte + let (a, b) = match fold_path_byte(first) { + b'/' => (b'/', b'\\'), + c if c.is_ascii_lowercase() => (c, c.to_ascii_uppercase()), + c => (c, c), + }; + let Some(starts) = hay.len().checked_sub(needle.len()).and_then(|last| hay.get(..=last)) else { return false }; + let at = |i: usize, c: u8| (c == a || c == b) && hay.get(i + 1..i + needle.len()).is_some_and(|tail| path_bytes_eq(tail, rest)); + // eight bytes at a time: does any of them spell the first byte (a zero byte after xor)? + const ONES: u64 = 0x0101_0101_0101_0101; + let has_zero = |v: u64| v.wrapping_sub(ONES) & !v & (ONES << 7) != 0; + let (chunks, tail) = starts.as_chunks::<8>(); + let tail_from = starts.len() - tail.len(); + for (k, chunk) in chunks.iter().enumerate() { + let w = u64::from_le_bytes(*chunk); + if (has_zero(w ^ (ONES * u64::from(a))) || has_zero(w ^ (ONES * u64::from(b)))) && chunk.iter().enumerate().any(|(j, &c)| at(k * 8 + j, c)) { + return true; + } + } + tail.iter().enumerate().any(|(j, &c)| at(tail_from + j, c)) +} + +/// A `filePath` rule. The common case (an ASCII path and an ASCII text) is compared in place, folding +/// case and `\` as it goes, with nothing allocated per photo; any other text takes +/// [`file_path_matches_slow`], so Unicode lower-casing (ß, İ, final sigma…) behaves as it always did. +fn file_path_matches(op: &str, source: &Source, value: &Value) -> bool { + let path = match source { + Source::File { path } => path.as_str(), + Source::Demo { .. } => "", + }; + let Value::String(want) = value else { return file_path_matches_slow(op, path, value) }; + if !path.is_ascii() || !want.is_ascii() { + return file_path_matches_slow(op, path, value); + } + let (path, want) = (path.trim().as_bytes(), want.trim().as_bytes()); + match op { + // the whole string, spaces included (not word by word like the other text fields) + "contains" => !want.is_empty() && path_bytes_contains(path, want), + "notContains" => want.is_empty() || !path_bytes_contains(path, want), + "is" => path_bytes_eq(path, want), + "isNot" => !path_bytes_eq(path, want), + "startsWith" => path.get(..want.len()).is_some_and(|head| path_bytes_eq(head, want)), + "endsWith" => path.len().checked_sub(want.len()).and_then(|from| path.get(from..)).is_some_and(|tail| path_bytes_eq(tail, want)), + "isEmpty" => path.is_empty(), + "isNotEmpty" => !path.is_empty(), + _ => false, + } +} + +/// [`file_path_matches`] for any text: the path and the text are normalised into new strings. +fn file_path_matches_slow(op: &str, path: &str, value: &Value) -> bool { + // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path + let path = path.replace('\\', "/"); + let want = lower(value).replace('\\', "/"); + match op { + // the whole string, spaces included (not word by word like the other text fields) + "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), + "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), + _ => text_op(op, &path, &want), + } +} + impl Rule { pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { let (field, op, value) = match self { Rule::Group { group } => return group.matches(p, cat), Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value), }; + if field == "filePath" { + return file_path_matches(op, &p.source, value); + } let m = &p.meta; let want = lower(value); let text = |have: &str| text_op(op, have, &want); @@ -854,20 +932,6 @@ impl Rule { "fileName" => text(&p.file_name), "extension" => text_op(op, p.extension(), want.trim_start_matches('.')), "duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value), - "filePath" => { - // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path - let path = match &p.source { - Source::File { path } => path.replace('\\', "/"), - Source::Demo { .. } => String::new(), - }; - let want = want.replace('\\', "/"); - match op { - // the whole string, spaces included (not word by word like the other text fields) - "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), - "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), - _ => text_op(op, &path, &want), - } - } "format" => text(&p.format), "title" => text(&m.title), "caption" => text(&m.caption), @@ -1693,4 +1757,120 @@ mod tests { assert_eq!(field_label("filePath"), Some("File Path")); assert_eq!(field_label("nope"), None); } + + /// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo. + fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool { + let want = lower(value); + let path = match source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + let want = want.replace('\\', "/"); + match op { + "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), + "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), + _ => text_op(op, &path, &want), + } + } + + #[test] + fn file_path_rules_match_the_old_strings_for_any_path() { + let paths = [ + "", + " ", + " /a/b.jpg \t", + "/Volumes/share/photos/Trip/IMG_0001.jpg", + "/Volumes/share/PHOTOS/trip/img_0001.JPG", + r"D:\Photos\Lake Como\IMG_0001.JPG", + r"d:/photos/lake como/img_0001.jpg", + r"\\nas\share\x.jpg", + "/photos/Straße/a.jpg", + "/photos/STRASSE/a.jpg", + "/photos/İstanbul/a.jpg", + "/photos/i̇stanbul/a.jpg", + "/photos/ΟΔΟΣ/a.jpg", + "/photos/οδος/a.jpg", + "/photos/ΟΔΌΣ/a.jpg", + "/photos/日本/東京.jpg", + "/p/\u{0B}x\u{0B}", + "/p/\u{85}x\u{85}", + "a", + ]; + let wants = [ + "", + " ", + "a", + "A", + "/", + r"\", + r"\photos\", + "/photos/", + "photos", + "PHOTOS", + "Lake Como", + " lake como ", + "lake como", + r"d:\photos\lake", + "IMG_0001.JPG", + ".jpg", + "straße", + "STRASSE", + "strasse", + "İstanbul", + "i̇stanbul", + "istanbul", + "ΟΔΟΣ", + "οδος", + "οδοσ", + "ΟΔΌΣ", + "東京", + "日本", + "x", + "\u{0B}x", + "/a/b.jpg", + "/a/b.jpg \t", + "\u{85}x", + ]; + let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"]; + let mut values: Vec = wants.iter().map(|w| json!(w)).collect(); + values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]); + let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect(); + sources.push(Source::Demo { scene: 0 }); + for source in &sources { + for op in ops { + for value in &values { + assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}"); + } + } + } + } + + #[test] + fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() { + let mut p = photo(1); + p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() }; + let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]})); + assert!(r.matches(&p, &Catalog::default())); + } + + /// Every piece of a long path, in both cases and both slash directions, wherever it starts + /// (the search skips eight bytes at a time, so starts and ends at every offset matter). + #[test] + fn file_path_contains_finds_every_piece_of_a_long_path() { + let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG"; + let source = Source::File { path: path.into() }; + let shout = |s: &str| s.to_uppercase(); + let swap = |s: &str| s.replace('\\', "/"); + for from in 0..path.len() { + for to in from + 1..=path.len() { + let piece = &path[from..to]; + for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] { + let value = json!(want); + for op in ["contains", "notContains"] { + assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}"); + } + } + } + } + } } diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index eb7e89ef4..84e89d124 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -296,14 +296,15 @@ pub fn iso_seconds(s: &str) -> Option { } let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) }; - // days from civil (proleptic Gregorian) - let y2 = if m <= 2 { y - 1 } else { y }; - let era = y2.div_euclid(400); - let yoe = y2 - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + if hh >= 24 || mm >= 60 || ss >= 60 { + return None; + } + // Days since 1970-01-01 (proleptic Gregorian). A day past the month's length rolls over the + // way the old Hinnant arithmetic did (e.g. 2026-02-30 → 2026-03-02), which `normalize_iso` + // relies on to reject impossible dates. + let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?; + let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?; + let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days(); Some(days * 86_400 + hh * 3600 + mm * 60 + ss) } diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index 2f3dc4912..553eb241f 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -242,7 +242,7 @@ impl Store for MemStore { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests_staging { use super::*; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index a32b5acf5..f8b72dad8 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -836,6 +836,71 @@ fn cmp_name_ignores_case_without_allocating() { assert_eq!(cmp_name("a", "ab"), Less); } +/// Search Photos finds named people in free text, and a quoted value keeps a name of several words +/// together: `person:"Jane Doe"` (issue #1449). Malformed quotes narrow the result or change nothing. +#[test] +fn search_text_finds_people_and_quoted_values() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let region = |name: &str, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: Some(name.to_string()), + description: None, + }; + let mut c = Catalog::new(); + let mut add = |name: &str, regions: Vec, keywords: &[&str]| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.regions = regions; + p.meta.keywords = keywords.iter().map(|k| k.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let jane = add("a.jpg", vec![region("Jane Doe", RegionKind::Face), region("Rex", RegionKind::Pet)], &[]); + let both = add("b.jpg", vec![region("Jane Roe", RegionKind::Face), region("John Doe", RegionKind::Face)], &[]); + let zoe = add("c.jpg", vec![region("Zoë Ångström", RegionKind::Face)], &["New York"]); + let none = add("lunch 10:30 am.jpg", vec![], &["york minster"]); + let q = |text: &str| { + let mut ids = c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default()); + ids.sort(); + ids + }; + + // free text: a person's name, any case, any part of it; pets are not people + assert_eq!(q("jane"), vec![jane, both]); + assert_eq!(q("DOE"), vec![jane, both]); + assert_eq!(q("ångström"), vec![zoe]); + assert!(q("rex").is_empty(), "a pet is not a person"); + // words are still ANDed: b.jpg has a Jane and a Doe, a quoted phrase asks for them together + assert_eq!(q("jane doe"), vec![jane, both]); + assert_eq!(q("\"jane doe\""), vec![jane]); + + // fielded: the exact name, of several words when quoted + assert_eq!(q("person:\"Jane Doe\""), vec![jane]); + assert_eq!(q("who:\"jane roe\""), vec![both]); + assert_eq!(q("person:\"Zoë Ångström\""), vec![zoe]); + assert!(q("person:jane").is_empty(), "person: is the whole name, as before"); + assert!(q("person:Jane Doe").is_empty(), "unquoted, the second word is its own token, as before"); + assert_eq!(q("keyword:\"new york\""), vec![zoe], "other fields take quoted values too"); + assert_eq!(q("person:\"Jane Doe\" a.jpg"), vec![jane], "tokens after a quoted one still count"); + + // malformed input never widens the match and never panics + assert_eq!(q("person:\"Jane Doe"), vec![jane], "an unclosed quote runs to the end"); + assert!(q("person:\"Jane Doe a.jpg").is_empty(), "…and takes the rest of the text with it"); + assert!(q("person:\"\"").is_empty()); + assert!(q("person:").is_empty()); + assert_eq!(q("\""), vec![jane, both, zoe, none], "a lone quote is an empty phrase, like an empty search"); + assert_eq!(q("\"\" jane"), vec![jane, both]); + assert!(q("\"jane:doe\"").is_empty(), "a quoted phrase is free text even with a colon"); + // an unknown field is plain text, its quoted value unquoted (#2099): `10:"30 am"` is the text `10:30 am` + assert_eq!(q("10:\"30 am\""), vec![none]); + assert!(q("10:\"30 pm\"").is_empty()); + assert!(q("jane\\\"").is_empty(), "a backslash is an ordinary character"); + let long = format!("person:\"{}", "é ".repeat(50_000)); + assert!(q(&long).is_empty()); + assert_eq!(q(" \t jane \u{3000} "), vec![jane, both], "any whitespace separates tokens"); +} + /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 5902243fc..c9d5cec7d 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -100,7 +100,7 @@ jpegxl-rs / imagequant (GPL / C), dav1d / libheif (C). ## Performance (Apple M-series, release, synthetic 24 MP JPEG q90 4:2:0) -Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`. +Run `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture`. | Operation | Time | |---|---| diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 836aed548..2cdf67381 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -99,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) - } pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result { - let n = raw.width * raw.height; let ch = raw.model.channels() + raw.alpha as usize; - if n == 0 || raw.buf.len() < n * ch { + let buf_len = + crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?; + if (raw.buf.len() as u64) < buf_len { return Err(Error::Malformed(format, "sample buffer too short".into())); } let float = matches!(raw.buf, Buf::F32(_)); @@ -141,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, alpha) = match opts.max_size { Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); - let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + // (Lanczos at 2:1 and more as well: a box average there halves the contrast of + // detail near the output's resolution limit and aliases what is finer) + let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell }; let img = fit(&image, mw as usize, mh as usize, f); let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); (img, alpha) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d14b1812f..a20a47d14 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Option { + (width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0) +} + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_sample_buf_len_wrap_inputs_rejected() { + assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None); + assert_eq!(checked_sample_buf_len(0, 1, 3), None); + } + + #[test] + fn checked_sample_buf_len_normal_accepted() { + assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300)); + assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1)); + // 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count. + assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000)); + } +} diff --git a/crates/codecs/tests/bench.rs b/crates/codecs/tests/it/bench.rs similarity index 97% rename from crates/codecs/tests/bench.rs rename to crates/codecs/tests/it/bench.rs index 4c6f03fa1..c66feeb77 100644 --- a/crates/codecs/tests/bench.rs +++ b/crates/codecs/tests/it/bench.rs @@ -1,5 +1,5 @@ //! Benchmark-ish, ignored by default. Run with: -//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture` +//! `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture` use lightcraft_codecs::*; use std::time::Instant; diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/it/corpus.rs similarity index 100% rename from crates/codecs/tests/corpus.rs rename to crates/codecs/tests/it/corpus.rs diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/it/float_tiff.rs similarity index 100% rename from crates/codecs/tests/float_tiff.rs rename to crates/codecs/tests/it/float_tiff.rs diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/it/fuzz.rs similarity index 100% rename from crates/codecs/tests/fuzz.rs rename to crates/codecs/tests/it/fuzz.rs diff --git a/crates/codecs/tests/header.rs b/crates/codecs/tests/it/header.rs similarity index 100% rename from crates/codecs/tests/header.rs rename to crates/codecs/tests/it/header.rs diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/it/heif.rs similarity index 99% rename from crates/codecs/tests/heif.rs rename to crates/codecs/tests/it/heif.rs index d14f51ac4..3364aec4d 100644 --- a/crates/codecs/tests/heif.rs +++ b/crates/codecs/tests/it/heif.rs @@ -1,7 +1,6 @@ //! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from //! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a //! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input. -#![cfg(feature = "heif")] use lightcraft_codecs::*; use lightcraft_heif::testdata::{Prop, Spec, build}; diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/it/jxl_streaming.rs similarity index 98% rename from crates/codecs/tests/jxl_streaming.rs rename to crates/codecs/tests/it/jxl_streaming.rs index 298034656..a16a82e72 100644 --- a/crates/codecs/tests/jxl_streaming.rs +++ b/crates/codecs/tests/it/jxl_streaming.rs @@ -1,5 +1,3 @@ -#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] - use lightcraft_codecs::exif::minimal_exif; use lightcraft_codecs::*; use lightcraft_raster::Rgba8; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 000000000..607562025 --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,13 @@ +//! The integration tests, one module per file, linked into one test binary. +//! `tests/jxl_tail_heap.rs` stays a binary of its own: it installs dhat as the global allocator. + +mod bench; +mod corpus; +mod float_tiff; +mod fuzz; +mod header; +#[cfg(feature = "heif")] +mod heif; +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] +mod jxl_streaming; +mod roundtrip; diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs similarity index 100% rename from crates/codecs/tests/roundtrip.rs rename to crates/codecs/tests/it/roundtrip.rs diff --git a/crates/denoise/tests/it/main.rs b/crates/denoise/tests/it/main.rs new file mode 100644 index 000000000..010fac0e2 --- /dev/null +++ b/crates/denoise/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +#[cfg(feature = "runtime")] +mod real_model; diff --git a/crates/denoise/tests/real_model.rs b/crates/denoise/tests/it/real_model.rs similarity index 99% rename from crates/denoise/tests/real_model.rs rename to crates/denoise/tests/it/real_model.rs index 203e99378..ee6c27694 100644 --- a/crates/denoise/tests/real_model.rs +++ b/crates/denoise/tests/it/real_model.rs @@ -2,7 +2,7 @@ //! //! ```text //! LC_DENOISE_MODEL= LC_DENOISE_MOSAICS= LC_DENOISE_OUT= \ -//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture +//! cargo test --release -p lightcraft-denoise --features runtime --test it real_model:: -- --ignored --nocapture //! ``` //! //! `LC_DENOISE_MOSAICS` holds `.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white @@ -11,8 +11,6 @@ //! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none), //! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop). -#![cfg(feature = "runtime")] - use std::path::{Path, PathBuf}; use std::time::Instant; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 091efe066..9caa8cdb0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -46,6 +46,8 @@ lightcraft-segment = { workspace = true, optional = true } # model downloads run natively only (the web build fetches no models) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] lightcraft-fetch = { workspace = true } +# capture-time parsing for face recognition (native only) +chrono = { workspace = true } [features] # AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds. diff --git a/crates/engine/build.rs b/crates/engine/build.rs index 056c7adc8..7f5aa7777 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -3,7 +3,7 @@ //! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this //! script only reads a local directory (no network). //! -//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the +//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; @@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result { has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular"); has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans"); has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai"); - if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) { + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..d074567a7 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,12 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews. +/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699); +/// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a +/// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data +/// stored with each smart preview. +pub const LOOK_VERSION: u32 = 12; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -67,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Pef | RawFormat::Srw | RawFormat::Orf + | RawFormat::Mrw | RawFormat::Erf ) } @@ -88,7 +94,18 @@ pub(crate) fn starting_colour( lens: Option<&LensGeometry>, embedded: Option<&lightcraft_develop::EmbeddedLens>, ) -> (Xy, CameraTransform, Option) { - let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); + let (xy, t, look) = starting_colour_with( + raw, + |raw, t| fit_preview(raw, bytes, t, lens), + |raw, t| monochrome_look(raw, bytes, t, lens), + |raw, t| { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; + fit_dark_tone(&sensor, &reference, &clipped) + }, + ); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, embedded); return (xy, t, look); @@ -107,22 +124,22 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo } /// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no -/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an +/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on +/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables +/// to its proxy, so it fits only the tone around them. pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { let p = &raw.color.profile; - raw.format == RawFormat::Dng - && !t.matrix_is_fallback - && identity_tone_curve(p.tone_curve.as_ref()) - && p.look_table.is_none() - && p.gain_table_map.is_none() + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white -/// JPEG's tone fit passed in (tests pin the order with them). +/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). fn starting_colour_with( raw: &mut RawImage, fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, + dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, ) -> (Xy, CameraTransform, Option) { let start = |raw: &RawImage| { let xy = lightcraft_raw::color::as_shot_white_xy(raw); @@ -146,9 +163,9 @@ fn starting_colour_with( } None => (xy, t), }; - // only a black-and-white JPEG: a colour one that gave no look pays nothing more + // A black-and-white JPEG first tries its existing tone fit on the chosen colour. let look = if black_and_white { monochrome(raw, &t) } else { None }; - (xy, t, look) + (xy, t, look.or_else(|| dark(raw, &t))) } /// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo @@ -270,20 +287,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf // try its colour first and fit tone/chroma per photo (picture styles vary). let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) { + // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, + // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { Some(look) => look, // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve // (`monochrome_look` does it without a profile). None if colour.is_some() && black_and_white => { let unclipped = without_clipped(&sensor, &clipped); - let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) }; + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; look } None => return (None, black_and_white), }; - // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, - // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); let look = if dro { with_dro_off_tone(look) } else { look }; if lightcraft_pipeline::profiling() { eprintln!( @@ -302,6 +320,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf /// the render's highlight reconstruction. const SENSOR_CLIP: f32 = 0.99; +/// [`fit_look_with`] for a photo without Sony's DRO. +#[cfg(test)] +fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { + fit_look_with(sensor, reference, clipped, colour, false) +} + /// The photo's look, refitted without the proxy pixels whose sensor values are clipped. /// /// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip @@ -320,17 +344,19 @@ const SENSOR_CLIP: f32 = 0.99; /// replace a look fitted away from edges). A photo that had no look gets one when the search /// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly /// closer to the camera JPEG than the neutral fallback. -fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { +fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>, dro: bool) -> Option { let unclipped = without_clipped(sensor, clipped); - let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else { + // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; + let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { return unclipped .as_ref() - .and_then(|u| search_ordered(u, reference, colour, TONE_FITS)) + .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) .map(|(look, _)| look) - .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference)); + .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); }; let Some(unclipped) = unclipped else { return Some(look) }; - let refit = fit_attempt(&unclipped, reference, &colour, attempt); + let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); if refit.is_none() && lightcraft_pipeline::profiling() { eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); } @@ -389,8 +415,14 @@ fn sony_dro_off_tone() -> Option { /// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps /// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. fn with_dro_off_tone(look: CameraLook) -> CameraLook { - let Some(dro_off) = sony_dro_off_tone() else { return look }; - let own = look.tone.knots(); + CameraLook { tone: dro_off(&look.tone), ..look } +} + +/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, +/// or `own` unchanged when it can't be. +fn dro_off(own_tone: &CameraTone) -> CameraTone { + let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; + let own = own_tone.knots(); let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the @@ -400,11 +432,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook { let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); knots[30][1] = knots[30][1].max(floor).min(last[1]); - let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look }; - if !at_most(&tone, &look.tone) { - return look; + let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; + if !at_most(&tone, own_tone) { + return *own_tone; } - CameraLook { tone, ..look } + tone } /// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone @@ -424,9 +456,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model /// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given -/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near -/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than -/// the plain render ([`clips_more`]). +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a +/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not +/// clip more highlights than the plain render ([`clips_more`]). fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. let refuse = |why: &str| { @@ -452,10 +484,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: let unclipped = without_clipped(&sensor, &clipped); let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; let look = TONE_FITS.iter().find_map(|&tone| { - [false, true] - .into_iter() - .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + [false, true].into_iter().find_map(|away_from_edges| { + fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) + }) }); let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { @@ -500,8 +533,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb } /// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a -/// small thumbnail is not fitted. -const MIN_PREVIEW_FRACTION: f64 = 0.9; +/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview +/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. +const MIN_PREVIEW_FRACTION: f64 = 0.1; /// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark /// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. @@ -927,7 +961,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, - None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box), + // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. + None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { + fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) + } None => return None, }) } @@ -984,7 +1021,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 { /// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). -type Pairs = Vec<([f64; 3], [f64; 3])>; +type Pair = ([f64; 3], [f64; 3]); +type Pairs = Vec; fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; @@ -1146,7 +1184,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt /// The look from [`search_ordered`] without its attempt. #[cfg(test)] fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { - search_ordered(sensor, reference, colour, order).map(|(look, _)| look) + search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) + .map(|(look, _)| look) } /// The tone fits the search tries, in order: the first whose look passes the acceptance gates is @@ -1176,21 +1215,33 @@ struct Attempt { /// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole /// search runs with one tone fit before the next is tried, so the conditional-median fallback /// accepts exactly what the previous releases accepted. -fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> { +fn search_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], + bright: &HighlightCheck, +) -> Option<(CameraLook, Attempt)> { let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { let attempt = Attempt { tone, profile, away_from_edges, dark: false }; - fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) + fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) }) }) }) } -fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option { +fn fit_attempt( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + attempt: Attempt, + bright: &HighlightCheck, +) -> Option { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) } fn fit_pairs_on( @@ -1200,6 +1251,7 @@ fn fit_pairs_on( edge_limit: Option, tone_fit: ToneFit, dark: bool, + highlights: &HighlightCheck, ) -> Option { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. @@ -1227,6 +1279,9 @@ fn fit_pairs_on( look.tone = tone; } let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if !highlights_hold(&look.tone, highlights, colour) { + continue; + } let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); for (x, target) in pairs.iter().step_by(3) { let corrected = displayed(colour(*x), &tone); @@ -1341,11 +1396,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { /// and in gamma-encoded display values, and when the JPEG shows the same picture /// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly /// closer to the camera JPEG than what the photo would get otherwise. -fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit)) +fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) } -fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option { +fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { // as for a known colour model: enough signal for the tone, but no monochrome reference let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); @@ -1369,6 +1424,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) look.tone = tone; } let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { + return None; + } let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); for (x, target) in pairs.iter().step_by(3) { @@ -1419,7 +1477,7 @@ fn monochrome(reference: &Rgb32f) -> bool { /// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker /// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. /// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and -/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The +/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The /// chroma curve stays neutral: the JPEG has no colourfulness to learn. fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { if !file_local_look(raw.format) { @@ -1429,10 +1487,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le if !monochrome(&reference) { return None; } - let unclipped = without_clipped(&sensor, &clipped); - let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?; // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; let look = if dro { with_dro_off_tone(look) } else { look }; if lightcraft_pipeline::profiling() { eprintln!( @@ -1449,18 +1508,103 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le /// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of /// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture /// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below -/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. -fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit)) +/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than +/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). +fn fit_tone_only_with( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + highlights: &HighlightCheck, +) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) } -fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { +/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. +#[cfg(test)] +fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) +} + +fn fit_tone_only_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + highlights: &HighlightCheck, +) -> Option { // every pair counts: the JPEG has no colour to require let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; - let related = rank_correlation_ties(&pairs); + fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights) +} + +/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. +/// Try only after every existing look fails. Keep the colour of the source transform, +/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG +/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. +fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { + if (sensor.width, sensor.height) != (reference.width, reference.height) + || sensor.data.len() != reference.data.len() + || clipped.len() != sensor.data.len() + || sensor.data.is_empty() + || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) + || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) + { + return None; + } + let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); + if (dark as f64) < reference.data.len() as f64 * 0.9 { + return None; + } + // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }; + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { + let pairs: Pairs = sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, (input, output))| { + // The same signal floors as the camera toe, without colour ratios or counts. + !clipped.get(*i).copied().unwrap_or(true) + && input.iter().all(|v| *v > 0.0001 && *v < 1.5) + && (0.0003..0.85).contains(&luminance_2020(**output)) + && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) + }) + .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) + .collect(); + if pairs.len() < 256 { + return None; + } + let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?; + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { + if *clipped { + let target = luma(target.map(f64::from)); + let before = luma(displayed(input.map(f64::from), &neutral)); + let after = luma(displayed(input.map(f64::from), &tone)); + if !after.is_finite() || (after - target).abs() > (before - target).abs() { + return None; + } + } + } + Some(look) + }) +} + +fn fit_tone_only_pairs( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + pairs: &Pairs, + dark: bool, + highlights: &HighlightCheck, +) -> Option { + let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; + let related = rank_correlation_ties(pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture"); + eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); } return None; } @@ -1470,8 +1614,19 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?; + let tone = if dark { + // Split this wider set once. The camera toe's separate colour/extra split belongs to + // the original colour pairs and would put some of these held-out pixels into training. + fit_tone( + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), + )? + } else { + fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? + }; let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; + if !highlights_hold(&look.tone, highlights, coloured) { + return None; + } let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); @@ -1485,7 +1640,7 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, } if lightcraft_pipeline::profiling() { eprintln!( - "[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", + "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", (before / samples.max(1) as f64).sqrt(), (after / samples.max(1) as f64).sqrt(), if edge_limit.is_some() { ", away from edges" } else { "" } @@ -1779,6 +1934,89 @@ impl ToneFit { } } +/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel +/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped +/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped +/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can +/// leave them out. +fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { + if sensor.data.len() != reference.data.len() { + return Vec::new(); + } + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) + .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) + .collect() +} + +/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the +/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are +/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be +/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance +/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very +/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial +/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of +/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. +/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. +const MIN_TONE_RANGE: f32 = 1.2; + +/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also +/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does +/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG +/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted +/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed +/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has +/// 0.93. +const NARROW_TONE_RANGE: f32 = 1.5; + +/// What a narrow tone curve is checked against ([`highlights_hold`]). +struct HighlightCheck { + /// The frame's [`highlights`]. + pairs: Pairs, + /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the + /// fit, so the check looks at it lowered. + dro: bool, + /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that + /// fit's gates do, since the JPEG has no colour to match. + grey: bool, +} + +/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps +/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further +/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get +/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than +/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. +fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { + let knots = tone.knots(); + if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { + return true; + } + // the curve as the photo will get it + let used = if check.dro { dro_off(tone) } else { *tone }; + let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let (mut look, mut fallback) = (0.0, 0.0); + for (x, target) in &check.pairs { + let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); + if check.grey { + look += (luma(rendered) - luma(*target)).powi(2); + fallback += (luma(base) - luma(*target)).powi(2); + continue; + } + for c in 0..3 { + look += (rendered[c] - target[c]).powi(2); + fallback += (base[c] - target[c]).powi(2); + } + } + let hold = look.is_finite() && look <= fallback; + if !hold && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); + } + hold +} + /// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs /// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th /// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the @@ -1806,7 +2044,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { let mid = a + (b - a) / 2; *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; } - if knots[31][0] < knots[0][0] * 1.5 { + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { return None; } CameraTone::new(knots) @@ -1834,7 +2072,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; } - if knots[31][0] < knots[0][0] * 1.5 { + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { return None; } // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. @@ -2557,6 +2795,7 @@ mod tests { &mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices"), |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), + |_, _| panic!("no dark tone fit for a raw with its own matrices"), ); assert_eq!(raw.color, color); assert!(!t.matrix_is_fallback && look.is_none()); @@ -2578,6 +2817,7 @@ mod tests { (Some(fitted.clone()), false) }, |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), + |_, _| panic!("no dark tone fit after an accepted fit"), ); assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); @@ -2600,7 +2840,8 @@ mod tests { let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step")); + let (xy, t, look) = + starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); assert!(look.is_none()); assert_eq!(raw.color.illuminant, [17, 21]); assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); @@ -2630,6 +2871,7 @@ mod tests { assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); Some(toned.clone()) }, + |_, _| panic!("an accepted monochrome look keeps its tone"), ); assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); assert!(!t.matrix_is_fallback); @@ -2643,6 +2885,7 @@ mod tests { assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); Some(toned.clone()) }, + |_, _| panic!("an accepted monochrome look keeps its tone"), ); assert!(look.is_some() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); @@ -2733,6 +2976,7 @@ mod tests { RawFormat::Pef, RawFormat::Srw, RawFormat::Orf, + RawFormat::Mrw, RawFormat::Erf, ]; assert!(local.into_iter().all(file_local_look)); @@ -3503,7 +3747,8 @@ mod tests { let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) - let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3590,6 +3835,110 @@ mod tests { assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); } + fn near_black_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; + let rgb = [1.15, 1.0, 0.75]; + *input = rgb.map(|v| v * y / luminance_2020(rgb)); + *output = input.map(|v| v * 0.08); + } + (sensor, reference) + } + + #[test] + fn a_near_black_scene_gets_tone_without_learning_colour() { + let (sensor, reference) = near_black_scene(); + assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); + let (_, _, look) = starting_colour_with( + &mut raw, + |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), + |_, _| None, + |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), + ); + let look = look.expect("tone from the dark pixels"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = |tone: &ToneMap| { + sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) + .sum::() + }; + assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); + assert_eq!(look.tone.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 0..10_000 { + let y = look.tone.apply(i as f32 / 10_000.0); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + // Models with spectral matrices keep them before the dark tone is fitted. + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, false), + |_, _| None, + |raw, t| { + assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); + fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) + }, + ); + assert!(!t.matrix_is_fallback); + assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); + } + + #[test] + fn dark_tone_holdout_never_enters_training() { + let (sensor, mut reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); + // Every pixel clears the wider signal floors. Changing only its held-out third must + // change the measured error, but cannot change the fitted curve. + for p in reference.data.iter_mut().step_by(3) { + *p = p.map(|v| v * 1.03); + } + let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); + assert_eq!(changed.tone, original.tone); + } + + #[test] + fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { + let (sensor, reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let mut unrelated = reference.clone(); + for (i, p) in unrelated.data.iter_mut().enumerate() { + *p = reference.data[(i * 3571) % reference.data.len()]; + } + assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); + let mut flat = reference.clone(); + flat.data.fill([0.002; 3]); + assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); + let mut sparse = reference.clone(); + sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); + assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); + let mut bright = reference.clone(); + bright.data[0] = [0.95; 3]; + assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); + let mut clipped = none; + clipped[0] = true; + let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); + let mut changed = sensor.clone(); + changed.data[0] = [0.18; 3]; + let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); + assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); + let mut highlight = reference.clone(); + highlight.data[0] = [0.8; 3]; + assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); + assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); + assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); + } + /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is /// learnt and the full look is never tried) whose camera renders it two stops brighter than the /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. @@ -3635,7 +3984,8 @@ mod tests { let neutral = ToneMap::new(0.0, 0.0, 0.0); let mut reference = sensor.clone(); reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); - assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback"); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); // an unrelated picture for (i, p) in reference.data.iter_mut().enumerate() { *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; @@ -3739,6 +4089,7 @@ mod tests { assert!(!t.matrix_is_fallback, "on the spectral matrices"); own.clone() }, + |_, _| None, ); assert!(look.is_some() && !t.matrix_is_fallback); } @@ -3894,8 +4245,10 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) - .expect("tone fitted"); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = + fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) + .expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); @@ -3958,8 +4311,29 @@ mod tests { assert!(plain.data.iter().all(|p| *p == [0.2; 3])); } + /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's + /// full-resolution corrections applied once rather than silently losing the tone fit. + #[test] + fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { + use lightcraft_raw::Opcode; + let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); + raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); + let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); + assert_eq!((proxy.width, proxy.height), (32, 24)); + let center = proxy.data[12 * 32 + 16][1]; + let corner = proxy.data[0][1]; + assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); + let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); + let small = sensor_proxy(&raw, 2, 16).unwrap(); + assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); + } + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the - /// identity, no look table; and no other raw format. + /// identity, with or without a look table; and no other raw format. #[test] fn dng_tone_gates_follow_the_files_own_tags() { use lightcraft_raw::profile::{HsvTable, ToneCurve}; @@ -3981,6 +4355,192 @@ mod tests { assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); let mut looked = color; looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); - assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); + } + + /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. + #[test] + fn dng_tone_fit_takes_lightroom_sized_previews() { + let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); + assert!(1024.0 >= floor(7008.0)); + assert!(256.0 < floor(7008.0)); + assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); + } + + /// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped + /// to the JPEG through a steep camera curve (a hazy scene the camera stretches). + fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { + (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() + } + + /// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that + /// of the darkest 1/32 (their last and first knots). + fn knot_range(mut scene: Vec) -> f64 { + scene.sort_by(f64::total_cmp); + let n = scene.len(); + scene[31 * n / 32 + n / 64] / scene[n / 64] + } + + /// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets + /// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. + #[test] + fn tone_fits_accept_low_contrast_scenes() { + for fit in [fit_tone, fit_tone_conditional] { + let pairs = low_contrast_pairs(1.47); + let range = knot_range(pairs.iter().map(|p| p.0).collect()); + assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); + let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); + assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); + assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); + } + } + + /// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera + /// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. + #[test] + fn a_low_contrast_scene_gets_a_camera_look() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); + } + let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); + let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "{}", error.sqrt()); + } + + /// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the + /// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out + /// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky + /// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' + /// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright + /// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). + #[test] + fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + for (case, sky_value, is_sky) in [ + ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), + ("clipped sky", 1.0, &|i: usize| i < 5600), + ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), + ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), + ] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut clipped = vec![false; sensor.data.len()]; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if is_sky(i) { + // bright, nearly white in the camera JPEG + *src = [sky_value; 3]; + *dst = [0.93; 3]; + clipped[i] = case == "clipped sky"; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // nearly flat midtones: the fitted curve's extrapolation can't reach the sky + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); + } + let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([f64::from(sky_value); 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); + } + } + + /// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to + /// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the + /// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the + /// sky at 0.61 where the camera JPEG has 0.93. + #[test] + fn a_narrow_curve_is_checked_as_lowered_for_dro() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); + } + let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); + if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { + let look = with_dro_off_tone(look); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([0.9; 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); + } + } + + /// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning + /// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. + #[test] + fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; + } + let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); + let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); + assert!(rms < 0.02, "{rms}"); + } + + /// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is + /// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. + #[test] + fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; + } + if let Some(look) = fit_tone_only(&sensor, &reference, None) { + let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); + assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); + } } } diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index d13023b6e..c7b6c437b 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option> { /// keys, so thumbnails rendered before a profile existed are not reused after. pub fn cache_key() -> u64 { static KEY: OnceLock = OnceLock::new(); - *KEY.get_or_init(|| { - let entries = dir().and_then(|d| std::fs::read_dir(d).ok()); - let mut files: Vec<(String, u64, u64)> = entries - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "json")) - .filter_map(|e| { - let meta = e.metadata().ok()?; - let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); - Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) - }) - .collect(); - files.sort(); - let mut h = lightcraft_preview::Hasher128::new(); - for (name, len, modified) in &files { - h.str(name).u64(*len).u64(*modified); - } - for (model, json) in BUNDLED { - h.str(model).str(json); - } - h.finish().0 as u64 - }) + *KEY.get_or_init(|| cache_key_for(dir().as_deref())) +} + +fn cache_key_for(directory: Option<&Path>) -> u64 { + let entries = directory.and_then(|d| std::fs::read_dir(d).ok()); + let mut files: Vec<(String, u64, std::time::Duration)> = entries + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) + }) + .collect(); + files.sort(); + let mut h = lightcraft_preview::Hasher128::new(); + for (name, len, modified) in &files { + h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos())); + } + for (model, json) in BUNDLED { + h.str(model).str(json); + } + h.finish().0 as u64 } /// Per camera model: the photos read and their pooled colour pairs. @@ -294,6 +296,38 @@ mod tests { assert!(bundled("No Such Camera").is_none()); } + #[test] + fn same_second_profile_replacement_changes_render_cache_key() { + let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id())); + let mut first = profile(); + first.model = "ILCE-7CR".into(); + let path = save(&first, &dir).unwrap(); + let set_modified = |millis| { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis); + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap(); + file.metadata().unwrap() + }; + let before = set_modified(100); + assert_eq!(load(&path).unwrap(), first); + let before_key = cache_key_for(Some(&dir)); + + let mut second = first; + second.matrix[0][0] = 1.7; + save(&second, &dir).unwrap(); + let after = set_modified(350); + assert_eq!(load(&path).unwrap(), second); + assert_eq!(before.len(), after.len()); + let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + assert_eq!(before_time.as_secs(), after_time.as_secs()); + assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos()); + let after_key = cache_key_for(Some(&dir)); + let _ = std::fs::remove_dir_all(dir); + + assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache"); + } + #[test] fn file_names_are_safe() { assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json"); diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 655965688..01c67c267 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -114,6 +114,16 @@ fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) - forgotten } +impl Session { + /// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what + /// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched + /// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that + /// isn't recorded lets a folder in daily use be forgotten. + pub fn note_browsed(&mut self, root: &str, files: &[String]) { + stamp_browsed(self, root, files); + } +} + /// Folders whose browse time is refreshed at most this often (keeps repeated browsing from /// growing the log; far finer than the days the eviction counts in). const RESTAMP_SECS: i64 = 3600; @@ -148,7 +158,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) { /// referred to by an undo / redo step. fn in_use(s: &mut Session) -> std::collections::HashSet { let mut keep = held(s); - keep.extend(s.visible().iter().copied()); + // Missing Photos lists library photos only, never Local records, so it keeps nothing here; and + // working it out checks every original (minutes on a sleeping NAS when the library opens on it) + if s.source != crate::LibrarySource::Missing { + keep.extend(s.visible().iter().copied()); + } keep } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index eae955cce..0871e4352 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -313,30 +313,15 @@ pub(crate) fn reload_ops( } /// Re-read photos whose files changed on disk (an external editor saved them): new size, -/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} +/// dimensions and content hash, cached sources dropped. The app runs the same steps +/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - let ids = s.targets(p)?; - let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids - .iter() - .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { - Some(Source::File { path }) => Some((*id, path)), - _ => None, - }) - .collect(); - let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>()); - let mut ops = Vec::new(); - let mut reloaded = Vec::new(); - for ((id, _), info) in paths.into_iter().zip(probed) { - let Ok(info) = info else { continue }; - reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); - } - for id in &reloaded { - s.media.forget(*id); - } - if !ops.is_empty() { - s.commit("Reload Changed Files", Op::Batch { ops })?; - } - Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})) + use crate::cmd::reload as r; + let items = r::plan(s, p)?; + let probe = r::file_probe(s); + let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {}); + let reloaded = r::apply(s, items, results)?; + Ok(r::to_json(&reloaded)) } /// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the @@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}", has_selection, reload ), diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 030cb6e93..142accd5a 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -21,6 +21,9 @@ fn secs(iso: &str) -> Option { fn analyze(s: &mut Session, p: &Value) -> Result { let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; + // a photo listed twice is one photo, not a burst of two look-alikes (first occurrence kept) + let mut seen = std::collections::HashSet::new(); + let ids: Vec = ids.into_iter().filter(|id| seen.insert(*id)).collect(); let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) @@ -87,7 +90,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result { /// content), most similar first; the view is filtered to them. fn find_similar(s: &mut Session, p: &Value) -> Result { const C: &str = "library.findSimilar"; - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?; + let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?; let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32; let all: Vec = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect(); let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> { diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index f93a2434a..644d3b89e 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -513,10 +513,6 @@ fn settings(s: &mut Session, p: &Value) -> Result { Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()})) } -fn ids_param(p: &Value) -> Option> { - p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()) -} - fn status_json(s: &Session, photo: Option) -> Value { let (files, bytes) = s.denoise_cache_usage(); let st = &s.denoise.settings; @@ -594,7 +590,7 @@ fn queue(s: &mut Session, p: &Value) -> Result { if s.denoise_products_dir().is_none() { return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first")); } - let ids = match ids_param(p) { + let ids = match super::photo_ids(p, C)? { Some(ids) => ids, None => match str_param(p, "scope").unwrap_or("selected") { "selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(), @@ -612,14 +608,14 @@ fn queue(s: &mut Session, p: &Value) -> Result { } fn cancel(s: &mut Session, p: &Value) -> Result { - let photo = p.get("id").and_then(Value::as_u64).map(PhotoId); + let photo = super::photo_id(p, "denoise.cancel")?; let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none())); Ok(json!({"cancelled": n})) } fn status(s: &mut Session, p: &Value) -> Result { s.denoise_refresh_active(false); - Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId))) + Ok(status_json(s, super::photo_id(p, "denoise.status")?)) } /// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going. diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e3d59c567..fbc3d4041 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -2,7 +2,7 @@ use lightcraft_catalog::{Op, PhotoId, Version}; use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; -use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; +use lightcraft_geom::{CropGeometry, CropHandle, MIN_CROP_FRACTION, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param}; @@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { Ok((a as u32, b as u32)) } -fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { +/// How a new aspect ratio is turned (issue #2253). +#[derive(Clone, Copy, PartialEq)] +enum Turn { + /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop + /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image. + LikeCrop, + /// As given: a custom `[w, h]` taller than wide is a portrait crop. + AsGiven, +} + +fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) { + let r = d.crop.geometry.rect_px(w, h); + let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h }; + let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) }); d.crop.aspect = aspect; - let a = aspect.map(|(aw, ah)| { - // orientation of the crop follows the image (landscape image → landscape crop) - let (aw, ah) = (aw as f64, ah as f64); - if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw } - }); + let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64); let angle = d.crop.geometry.angle; d.crop.geometry = crop_fit_angle(w, h, angle, a); } @@ -199,8 +208,7 @@ pub fn specs() -> Vec { } refit_crop_angle(angle, before, d, dims); }; - if let Some(ids) = p.get("ids").and_then(Value::as_array) { - let ids: Vec = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect(); + if let Some(ids) = super::photo_ids(p, "develop.set")? { let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -524,13 +532,24 @@ pub fn specs() -> Vec { cmd!("crop.set", "Set Crop", [], None, "{rect?: [x0,y0,x1,y1] normalized in the straightened frame, angle?: degrees}", has_active, |s, p| { let id = active(s, "crop.set")?; let (w, h) = crop_dims(s, id); + // the smallest crop a drag of the crop tool can make: below it an export comes out 1×1 (#1952) + let min_side = MIN_CROP_FRACTION * w.min(h) * (1.0 - 1e-9); + let too_small = |r: Rect| { + (r.width() * w < min_side || r.height() * h < min_side) + .then(|| bad("crop.set", format!("rect is smaller than the smallest crop ({} % of the shorter side)", MIN_CROP_FRACTION * 100.0))) + }; edit(s, "crop.set", "Crop", |d| { - if let Some(a) = p.get("rect").and_then(Value::as_array) { + let explicit = p.get("rect").and_then(Value::as_array); + if let Some(a) = explicit { let v: Vec = a.iter().filter_map(Value::as_f64).collect(); if v.len() != 4 { return Err(bad("crop.set", "rect needs 4 numbers")); } - d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])); + let r = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])); + if let Some(e) = too_small(r) { + return Err(e); + } + d.crop.geometry.rect = r; } let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0)); d.crop.geometry = match (p.get("rect"), angle) { @@ -538,6 +557,12 @@ pub fn specs() -> Vec { (None, Some(a)) => d.crop.geometry.with_angle(a, w, h), (_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h), }; + // a rect far larger than the photo is shrunk to fit it, and can come out a sliver + if explicit.is_some() + && let Some(e) = too_small(d.crop.geometry.rect) + { + return Err(e); + } Ok(()) }) }), @@ -628,11 +653,19 @@ pub fn specs() -> Vec { "Crop Aspect", [], None, - "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}", + "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either", has_active, |s, p| { let id = active(s, "crop.aspect")?; let (w, h) = crop_dims(s, id); + // checked first, whatever the aspect: a bad value is an error on every path + let turn = match p.get("orientation") { + Some(Value::String(o)) if o == "crop" => Turn::LikeCrop, + Some(Value::String(o)) if o == "given" => Turn::AsGiven, + Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), + None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, + None => Turn::LikeCrop, + }; let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some()); let lock_current = match p.get("aspect").and_then(Value::as_str) { Some("current") => true, @@ -667,7 +700,7 @@ pub fn specs() -> Vec { if aspect.is_none() { d.crop.aspect = None; } else { - set_aspect(d, w, h, aspect); + set_aspect(d, w, h, aspect, turn); } Ok(()) }) @@ -1103,11 +1136,8 @@ pub fn specs() -> Vec { |s, p| { let c = "history.restore"; let id = active(s, c)?; - let i = f64_req(p, "index", c)?; - if !(i.is_finite() && i >= 0.0) { - return Err(bad(c, "no such step")); - } - go_to_history_step(s, c, id, i as usize) + let i = index_req(p, "index", c)?; + go_to_history_step(s, c, id, i) } ), cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index e93d04175..949c08f1b 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -17,6 +17,12 @@ impl Session { /// `app.export` params with a named `preset` expanded: the preset's params, overridden by the /// call's own (`preset` itself removed). Unknown preset names are an error. pub fn export_params(&self, p: &Value) -> std::result::Result { + self.export_params_or(p, crate::export::ExportFormat::default()) + } + + /// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format + /// (see [`crate::export::ExportOptions::export_format`]). + pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result { let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) }; let (preset, _) = self .all_export_presets() @@ -24,12 +30,17 @@ impl Session { .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; // a preset saved by another version may hold keys this one doesn't know: not the caller's typo - let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); + let mut out = crate::export::ExportOptions::known_keys(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); } } + // the preset's depth is judged against the format written, known only now (`path`); a + // depth the call passes itself is the caller's, and validated as such + if !p.get("bitDepth").is_some_and(|d| !d.is_null()) { + crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback); + } Ok(out) } } @@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result { let mut params = match p.get("params") { Some(v @ Value::Object(_)) => v.clone(), Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")), - None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?, + // a stored setting, not the caller's typo: a depth it holds that its `format` lacks was + // written before #1952 and is dropped, as Export with Previous does; without `format` + // it is judged when the preset exports + None => { + let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?; + let mut last = crate::export::ExportOptions::known_keys(last); + if last.get("format").is_some_and(Value::is_string) { + crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default()); + } + last + } }; if let Some(o) = params.as_object_mut() { o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); } - // a preset that app.export would refuse is refused here, where the typo is on screen - crate::export::ExportOptions::validate(ID, ¶ms)?; + // a preset that app.export would refuse is refused here, where the typo is on screen; without + // `format`, its `bitDepth` is judged when it exports, against the format written then + let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default())); + crate::export::ExportOptions::validate_as(ID, ¶ms, format)?; let preset = ExportPreset { name: name.to_string(), params }; match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = preset, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 228a1ce3d..e66bb56f3 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -82,7 +82,7 @@ fn name_faces(s: &mut Session, p: &Value) -> Result { /// makes the face yours: a later "Detect Faces" run no longer replaces it. fn set_name(s: &mut Session, p: &Value) -> Result { const C: &str = "faces.setName"; - let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; let index = p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "missing or invalid `index`"))?; let name = name_param(p, C)?; let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); @@ -470,8 +470,7 @@ mod imp { let embedder = current(s, C)?; collect(s); let budget = Duration::from_millis(p.get("budgetMs").and_then(Value::as_u64).unwrap_or(200).min(600_000)); - let first: Vec = - p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()).unwrap_or_default(); + let first: Vec = super::super::photo_ids(p, C)?.unwrap_or_default(); let todo = pending(s, &first, false); let started = Instant::now(); let (mut embedded, mut failed, mut done) = (0, 0, 0); @@ -509,13 +508,12 @@ mod imp { if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return None; } - // days from civil (proleptic Gregorian) - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - Some((era * 146_097 + doe - 719_468) * 86_400 + hh * 3600 + mm * 60 + ss) + // Days since 1970-01-01 (proleptic Gregorian); a day past the month's length rolls over as + // the old Hinnant arithmetic did. + let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?; + let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?; + let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days(); + Some(days * 86_400 + hh * 3600 + mm * 60 + ss) } /// `faces.evaluate`: leave-one-out over the library's named faces, to see how well recognition works on *your* diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index 085183d58..c9ffb6b7e 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); + // the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest + // larger town, which may be another municipality; past this many metres City is left empty (#2242) + let max_distance = match p.get("maxDistance") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?), + }; let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() @@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result { } t.progress(n as u64, todo.len() as u64); } - let Some(place) = names.reverse(*at) else { continue }; + let Some(mut place) = names.reverse(*at) else { continue }; + // too far from the town found: State and Country still hold, City (and below) doesn't + let too_far = max_distance.is_some_and(|m| place.distance > m); + let nearest_city = too_far.then(|| std::mem::take(&mut place.city)); + if too_far { + place.sublocation.clear(); + } let Some(ph) = s.catalog.photo(*id) else { continue }; let mut meta = ph.meta.clone(); fill(&mut meta, &place, overwrite); if meta != ph.meta { ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); } - found.push(json!({"id": id.0, "place": place})); + let mut entry = json!({"id": id.0, "place": place}); + if let Some(city) = nearest_city { + entry["nearestCity"] = json!(city); + } + found.push(entry); } drop(task); let changed = ops.len(); @@ -271,7 +287,7 @@ pub fn specs() -> Vec { "Fill Location from GPS", ["Photo"], None, - "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step", + "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City", has_selection, reverse ), @@ -343,6 +359,36 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a + /// village finds the nearest larger town. `maxDistance` leaves City empty past that distance + /// (State and Country still fill) and says which town it was. + #[test] + fn max_distance_leaves_a_far_town_out_of_city() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let dir = dataset("maxdist"); + // about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris + set_gps(&mut s, ids[0], Some((48.90, 2.35)), ""); + set_gps(&mut s, ids[1], Some((48.855, 2.35)), ""); + let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap(); + let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone(); + assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France")); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough"); + let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap(); + assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}"); + assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0); + // without it, the nearest town fills City as before + s.undo_step().unwrap(); + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap(); + assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris"); + // hostile values are refused, not taken as "no limit" + for bad in [json!(-1), json!(0), json!("far"), json!(true)] { + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err(); + assert!(e.to_string().contains("maxDistance"), "{e}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn explains_missing_data_and_gps() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 860cf089f..e437dccc2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -145,10 +145,6 @@ fn strs(p: &Value, key: &str) -> Vec { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() } -fn ids_param(p: &Value) -> Option> { - p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()) -} - /// The targets an album can hold and how many were browsed (Local) photos, which album views /// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { @@ -623,7 +619,7 @@ pub fn specs() -> Vec { "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", always, |s, p| { - let ids = ids_param(p).unwrap_or_default(); + let ids = super::photo_ids(p, "library.select")?.unwrap_or_default(); let active = p.get("active").and_then(Value::as_u64).map(PhotoId); let mode = str_param(p, "mode").unwrap_or("replace"); // An id that is not in the library is an error, and the selection stays as it was: @@ -808,9 +804,13 @@ pub fn specs() -> Vec { "{label: red|yellow|green|blue|purple|none, ids?}", has_selection, |s, p| { - let l = match str_param(p, "label") { - None | Some("none") => None, - Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), + // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear + let l = match p.get("label") { + Some(Value::Null) => None, + Some(Value::String(x)) if x == "none" => None, + Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), + None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), + Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), }; for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l })) } @@ -829,8 +829,7 @@ pub fn specs() -> Vec { "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", always, |s, p| { - let id = - p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; let index = p .get("index") .and_then(Value::as_u64) @@ -856,7 +855,7 @@ pub fn specs() -> Vec { |s, p| { const C: &str = "photo.setRegion"; // a drag is one undo step however many frames it took: the caller commits once, on release - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; let index = p .get("index") .and_then(Value::as_u64) @@ -1267,7 +1266,7 @@ pub fn specs() -> Vec { has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.selected()); + let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; if al.is_smart() || al.folder { return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); @@ -1296,7 +1295,8 @@ pub fn specs() -> Vec { "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", has_library_selection, |s, p| { - let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?; + let targets = + album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { @@ -1458,7 +1458,7 @@ pub fn specs() -> Vec { _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), }, }; - let targets = ids_param(p).unwrap_or_else(|| s.selected()); + let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); @@ -1488,7 +1488,7 @@ pub fn specs() -> Vec { always, |_, p| { let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings})) + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) }), cmd!( "library.importLightroom", @@ -1709,12 +1709,21 @@ fn has_library(s: &Session) -> std::result::Result<(), String> { /// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", /// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { + // typographic minus signs and dashes mean a negative value, like ASCII '-' + let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); let t = t.trim(); // split into the two coordinates: at a comma, else after the first N/S hemisphere letter let (a, b) = match t.split_once(',') { Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), None => { - let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?; + let lead = t.starts_with(['N', 'S', 'n', 's']); + let i = if lead { + // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter + t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) + } else { + t.find(['N', 'S', 'n', 's']).map(|i| i + 1) + } + .or_else(|| t.find(char::is_whitespace))?; (t[..i].trim().to_string(), t[i..].trim().to_string()) } }; @@ -1746,6 +1755,10 @@ mod gps_tests { assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); + assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); + assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); + assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); assert_eq!(p("95, 10"), None); assert_eq!(p("hello"), None); } diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index f03809b62..b7a0d4992 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -17,7 +17,7 @@ pub struct LutProfile { pub id: String, pub name: String, pub group: String, - /// The `.cube` file (the library's copy when there is a library on disk). + /// The library's copy of the `.cube` file (empty without a library on disk). pub file: String, } @@ -123,12 +123,9 @@ fn import(s: &mut Session, p: &Value) -> Result { std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?; f.to_string_lossy().to_string() } - None => { - // no library on disk: remember the original (the in-memory session ends with the app) - let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:"))); - std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?; - f.to_string_lossy().to_string() - } + // no library on disk: the registered LUT is all this session needs (a copy would only + // be read back when a library opens, and that takes its own list from its prefs) + None => String::new(), }; lightcraft_pipeline::lut::register(&id, lut); s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file }); diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs index 387909a6d..d791b4260 100644 --- a/crates/engine/src/cmd/mask_effects.rs +++ b/crates/engine/src/cmd/mask_effects.rs @@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result { /// The selected mask's adjustments on the active photo. fn selected_mask(s: &Session, p: &Value, c: &str) -> Result { let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; - let mid = p - .get("id") - .and_then(Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .or(s.active_mask) - .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let mid = super::masks::mask_id(p, s.active_mask, c)?; let d = s.develop_of(id).unwrap_or_default(); d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}"))) } @@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result { // the masks: `ids`, `all`, else `id` or the selected one let ids: Vec = if p.get("all").and_then(Value::as_bool).unwrap_or(false) { d.masks.iter().map(|m| m.id).collect() - } else if let Some(a) = p.get("ids").and_then(Value::as_array) { - a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect() } else { - vec![selected_mask(s, p, C)?.id] + match p.get("ids") { + None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id], + Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::>()?, + Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")), + } }; if ids.is_empty() { return Err(bad(C, "no masks to apply the effect to")); diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 0efb3b658..dc403df9b 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec
, c: &str) -> Result { - p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) +pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result { + v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32")) +} + +/// Omitted or null targets keep the command's default; malformed explicit targets never do. +fn optional_mask_id(p: &Value, c: &str) -> Result> { + p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose() +} + +pub(super) fn mask_id(p: &Value, active: Option, c: &str) -> Result { + optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) +} + +fn optional_index(p: &Value, key: &str, c: &str) -> Result> { + p.get(key) + .filter(|v| !v.is_null()) + .map(|v| { + v.as_u64() + .and_then(|v| usize::try_from(v).ok()) + .ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize"))) + }) + .transpose() } fn find(masks: &mut [Mask], id: u32, c: &str) -> Result { @@ -307,7 +327,7 @@ pub fn specs() -> Vec { let connect = bool_or(p, "connect", false); let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); - let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); + let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask); let adjust = super::mask_effects::new_mask_adjust(s, "brush"); let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { @@ -363,7 +383,7 @@ pub fn specs() -> Vec { cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { let shape: MaskShape = serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?; - let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize; + let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0); let mid = mask_id(p, s.active_mask, "mask.update")?; masks_edit(s, "mask.update", "Edit Mask", |masks, _| { let i = find(masks, mid, "mask.update")?; @@ -381,6 +401,8 @@ pub fn specs() -> Vec { has_active, |s, p| { let c = "mask.sampleColor"; + let mid = mask_id(p, s.active_mask, c)?; + let comp = optional_index(p, "component", c)?; let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?); let add = p.get("add").and_then(Value::as_bool).unwrap_or(false); let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; @@ -390,8 +412,6 @@ pub fn specs() -> Vec { let req = lightcraft_pipeline::RenderRequest::fit(384, 384); let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y)) .ok_or_else(|| bad(c, "the point is outside the photo"))?; - let mid = mask_id(p, s.active_mask, c)?; - let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize); let mut out = Vec::new(); masks_edit(s, c, "Color Range", |masks, _| { let i = find(masks, mid, c)?; @@ -482,8 +502,8 @@ pub fn specs() -> Vec { let mid = mask_id(p, s.active_mask, c)?; let d = s.develop_of(id).unwrap_or_default(); let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; - let k = match p.get("component").and_then(Value::as_u64) { - Some(k) => k as usize, + let k = match optional_index(p, "component", c)? { + Some(k) => k, None => m .components .iter() @@ -604,7 +624,7 @@ pub fn specs() -> Vec { } ), cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| { - s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32); + s.active_mask = optional_mask_id(p, "mask.select")?; ok() }), cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| { @@ -684,7 +704,7 @@ pub fn specs() -> Vec { |s, p| { let c = "mask.component"; let mid = mask_id(p, s.active_mask, c)?; - let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize; + let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?; let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string(); let op: Option = match p.get("op") { Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?), @@ -879,8 +899,8 @@ pub fn specs() -> Vec { ), cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| { let n = spots_len(s); - s.active_spot = match p.get("index").and_then(Value::as_u64) { - Some(i) if (i as usize) < n => Some(i as usize), + s.active_spot = match optional_index(p, "index", "spot.select")? { + Some(i) if i < n => Some(i), Some(_) => return Err(bad("spot.select", "no such spot")), None => None, }; @@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize { /// `index` from the params, else the selected spot. fn spot_index(s: &Session, p: &Value, c: &str) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; + let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; if i >= spots_len(s) { return Err(bad(c, "no such spot")); } diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 6f194c652..1bff30d2b 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -58,7 +58,7 @@ pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op { fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.relink"; - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?; + let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; let abs = std::path::absolute(path).map_err(|e| bad(C, e.to_string()))?; if !abs.is_file() { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b86d4012..9b8dc3dbd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ pub mod previews; mod print; mod query; pub mod recheck; +pub mod reload; mod slideshow; mod sync; mod xmp; @@ -192,18 +193,50 @@ pub(crate) fn names_photos(p: &Value) -> bool { ["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) } +/// The photos `ids` names: `None` when it is missing or null (the command then falls back to the +/// selection, or its own default), else every entry as a photo id. An `ids` that isn't a list of +/// whole numbers is an error, never `None`: falling back would act on the selection instead of +/// the photos the call meant (`{"ids": 3}`, `{"ids": ["3"]}`). +pub(crate) fn photo_ids(p: &Value, cmd: &str) -> Result>> { + match p.get("ids") { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) => a + .iter() + .map(|v| v.as_u64().map(lightcraft_catalog::PhotoId).ok_or_else(|| bad(cmd, "`ids` must be photo ids (whole numbers)"))) + .collect::>>() + .map(Some), + Some(_) => Err(bad(cmd, "`ids` must be a list of photo ids")), + } +} + +/// The photo `id` names: `None` when it is missing or null (the command then uses the active +/// photo), else that id. An `id` that isn't a whole number is an error, never `None`: falling back +/// would act on the active photo instead of the one the call meant (`{"id": "5"}`). +pub(crate) fn photo_id(p: &Value, cmd: &str) -> Result> { + match p.get("id") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| bad(cmd, "`id` must be a photo id (a whole number)")), + } +} + pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } -/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize` -/// saturates to 0), just as `history.restore` already refuses a negative step index. +/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral +/// floating-point values for compatibility, without truncating or saturating their conversion. pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result { + let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer")); + if let Some(i) = p.get(key).and_then(Value::as_u64) { + return usize::try_from(i).map_err(|_| invalid()); + } let i = f64_req(p, key, cmd)?; - if !(i.is_finite() && i >= 0.0) { - return Err(bad(cmd, format!("`{key}` must be a non-negative integer"))); + // The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets. + let upper_bound = 2.0_f64.powi(usize::BITS as i32); + if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) { + return Err(invalid()); } Ok(i as usize) } @@ -223,3 +256,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::index_req; + + #[test] + fn command_indices_preserve_integer_precision_and_check_usize_bounds() { + for i in [0, 1, usize::MAX] { + assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i); + } + #[cfg(target_pointer_width = "64")] + { + let i = 9_007_199_254_740_993usize; + assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64"); + } + let upper_bound = 2.0_f64.powi(usize::BITS as i32); + assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate"); + #[cfg(target_pointer_width = "32")] + { + assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err()); + assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX); + } + } +} diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 24cc2061c..dbd0aa842 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -161,17 +161,17 @@ pub fn specs() -> Vec { Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) }), cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?; + let id = super::photo_id(p, "stack.setTop")?.or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?; let op = s.catalog.set_top_ops(id); Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?})) }), cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?; + let id = super::photo_id(p, "stack.moveUp")?.or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?; let op = s.catalog.move_in_stack_ops(id, -1); Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?})) }), cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?; + let id = super::photo_id(p, "stack.moveDown")?.or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?; let op = s.catalog.move_in_stack_ops(id, 1); Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?})) }), @@ -183,17 +183,32 @@ pub fn specs() -> Vec { "{id?} — this photo and the ones after it become their own stack", in_stack, |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?; + let id = super::photo_id(p, "stack.split")?.or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?; let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?; Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?})) } ), - cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)?); - let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); - let collapsed = bool_or(p, "collapsed", !first); - set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) - }), + // `ids` names the stacks' photos without selecting them (a click on a stack badge) + CommandSpec { + explicit_targets: true, + ..cmd!( + "stack.toggle", + "Expand/Collapse Stack", + ["Photo", "Stack"], + Some("S"), + "{ids?, collapsed?: bool} — the stacks of the named photos (else of the selection)", + in_stack, + |s, p| { + let stacks = stacks_of(s, &s.targets(p)?); + if stacks.is_empty() { + return Err(bad("stack.toggle", "none of the photos is in a stack")); + } + let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); + let collapsed = bool_or(p, "collapsed", !first); + set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) + } + ) + }, cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); set_collapsed(s, all, false, "Expand All Stacks") diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index b63ccc2b4..bef292f8f 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -176,12 +176,18 @@ fn build(s: &mut Session, p: &Value) -> Result { } /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, -/// how to load the original at preview size, whether the photo takes the per-file camera look — -/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those -/// proxies go stale when the fit changes). +/// how to load the original at preview size, whether the photo can take a preview-fitted look: +/// vendor raws and DNGs (whose tone can come from their preview); only those proxies go stale when +/// the fit changes; an older encoding makes any proxy stale). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); +/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale. +#[cfg(not(target_arch = "wasm32"))] +fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool { + photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) +} + /// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look /// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once. #[cfg(not(target_arch = "wasm32"))] @@ -205,15 +211,18 @@ struct SmartCounts { failed: Vec, } -/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart -/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy -/// fallback of a file source are not the original. +/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview +/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the +/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them +/// would stamp its older look as current. #[cfg(not(target_arch = "wasm32"))] fn original_only(source: crate::media::SourceRef) -> Option { use crate::media::SourceRef; match source { - SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }), - SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)), + SourceRef::File { path, max_edge, loader, denoise, .. } => { + Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None }) + } + SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy && !d.reduced => Some(SourceRef::Loaded(d)), _ => None, } } @@ -252,10 +261,11 @@ fn smart_run( } } else { // a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a - // full drive) is (re)built, and so is one written by an older camera-look fit (see - // `camera_preview::LOOK_VERSION`) when its original can be read + // full drive) is (re)built, and so is one written by an older encoding or camera-look + // fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its + // original can be read let damaged = path.exists(); - let stale = look && damaged && crate::smart::is_stale(&path); + let stale = damaged && crate::smart::is_stale_for(&path, look); if damaged && !stale && crate::smart::is_valid(&path) { n.built += 1; } else { @@ -268,7 +278,10 @@ fn smart_run( let r = source .ok_or_else(|| "nothing to build from".to_string()) .and_then(|s| s.load_source()) - .and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref())) + .and_then(|src| { + let info = src.info_or(Default::default()); + crate::smart::encode(&src.image, Some(&info)) + }) .and_then(|b| { let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?; // Only create the leaf shard: do not recreate a missing external drive. @@ -333,7 +346,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { } let path = crate::smart::sharded_file_path(&dir, &ph); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source, ph.fitted_look())); + jobs.push((id, path, source, preview_fitted_look(&ph))); } let custom = s.smart_previews_dir.is_some(); if background { @@ -360,14 +373,14 @@ fn smart(s: &mut Session, p: &Value) -> Result { ) } -/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when -/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker, -/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the -/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG, -/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose -/// original can't be read (offline) is kept as it is, still marked stale in its header: Build -/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none -/// is built, and a missing folder is never created. Returns the thread, if started. +/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews +/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]), +/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk +/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding +/// (every photo), or, for a raw that takes the per-file camera look or a DNG, one written by an older fit. +/// One whose original can't be read (offline) is kept as it is, still marked stale in its header: +/// Build Smart Previews rebuilds it once the original is back. Only existing proxies are touched: +/// none is built, and a missing folder is never created. Returns the thread, if started. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option> { let dir = s.media.smart_dir.clone()?; @@ -375,9 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); - let jobs: Vec = - photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect(); + let jobs: Vec = photos + .iter() + .map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), preview_fitted_look(p))) + .collect(); std::thread::Builder::new() .name("lc-smart-look".into()) .spawn(move || refresh_stale(&dir, jobs)) @@ -391,7 +406,7 @@ fn refresh_stale(dir: &std::path::Path, jobs: Vec) { let stale: Vec = jobs .into_iter() .map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look)) - .filter(|(_, path, _, _)| crate::smart::is_stale(path)) + .filter(|(_, path, _, look)| crate::smart::is_stale_for(path, *look)) .collect(); if !stale.is_empty() { // `custom`: the folder is there already, and must not be recreated if it went away since @@ -548,7 +563,8 @@ mod tests { let mut jobs = jobs_for(&mut s, id, Some(512)); assert_eq!(jobs.len(), 2, "view and thumbnail"); assert!(jobs[0].view_cache.is_some(), "the view renders first"); - jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None }; + jobs[1].source = + SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None, reduced: None }; assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source"); } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index fbe4608f0..dfbf4bfc5 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -62,7 +62,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth /// The photo a query is about: `id` when given (which must be in the library), else the active one. fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { - match p.get("id").and_then(Value::as_u64).map(PhotoId) { + match super::photo_id(p, c)? { Some(id) if s.catalog.photo(id).is_some() => Ok(id), Some(id) => Err(bad(c, format!("no such photo {}", id.0))), None => s.active().ok_or_else(|| bad(c, "no photo")), @@ -139,8 +139,11 @@ pub fn specs() -> Vec { .into_iter() .map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value})) .collect(); - // XMP: the sidecar if there is one, else the file's own packet - let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp); + // Keep sidecar precedence; reuse the loaded bytes for embedded XMP. + let packet = match crate::sidecar::find_sidecar(path, s.sidecar_naming(ph.id)) { + Some(sc) => std::fs::read_to_string(sc).ok(), + None => lightcraft_meta::embedded(&bytes).xmp, + }; let xmp: Vec = packet .and_then(|x| lightcraft_meta::parse_xmp(&x).ok()) .map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect()) diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs new file mode 100644 index 000000000..0c1cb964c --- /dev/null +++ b/crates/engine/src/cmd/reload.rs @@ -0,0 +1,114 @@ +//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a +//! raw re-decode takes a second or more per file, and a selection of them froze the window while +//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads +//! their files without the session (cancellable, with progress), and [`apply`] commits what came +//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. +//! +//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so +//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later +//! has already been applied to it: an older read never replaces a newer one. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile +/// is skipped by [`apply`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub id: PhotoId, + pub path: String, + /// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]). + pub ticket: u64, +} + +/// Which reload each photo last took its file information from, so a reload started earlier but +/// finishing later doesn't put back what its file was before (session memory, not saved). +#[derive(Debug, Default)] +pub struct Tickets { + last: u64, + applied: std::collections::BTreeMap, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No +/// file-system calls. +pub fn plan(s: &mut Session, p: &Value) -> Result> { + let ids = s.targets(p)?; + s.reload_tickets.last = s.reload_tickets.last.saturating_add(1); + let ticket = s.reload_tickets.last; + Ok(ids + .into_iter() + .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { + Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }), + _ => None, + }) + .collect()) +} + +/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file +/// access, where a reload reads nothing but the file name, as the command does). +pub fn file_probe(s: &Session) -> Option { + s.media.file_probe.clone() +} + +/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session. +/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further +/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]). +pub fn probe( + probe: Option<&FileProbe>, + items: &[Item], + cancel: &AtomicBool, + progress: &dyn Fn(usize), +) -> Vec> { + let paths: Vec = items.iter().map(|i| i.path.clone()).collect(); + let done = AtomicUsize::new(0); + #[cfg(not(target_arch = "wasm32"))] + let r = std::thread::scope(|sc| { + let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel)); + while !h.is_finished() { + progress(done.load(Ordering::Relaxed)); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // a probe that panicked (already logged): nothing is known about any file + h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect()) + }); + #[cfg(target_arch = "wasm32")] + let r = crate::import::probe_all(probe, &paths, &done, cancel); + progress(done.load(Ordering::Relaxed)); + r +} + +/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual +/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed +/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns +/// the photos that changed. +pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { + let mut ops = Vec::new(); + let mut reloaded = Vec::new(); + for (item, info) in items.into_iter().zip(results) { + let Ok(info) = info else { continue }; + let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); + let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket); + if !current || superseded { + continue; + } + s.reload_tickets.applied.insert(item.id, item.ticket); + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); + } + for id in &reloaded { + s.media.forget(*id); + } + if !ops.is_empty() { + s.commit("Reload Changed Files", Op::Batch { ops })?; + } + Ok(reloaded) +} + +/// `{reloaded: [ids]}`. +pub fn to_json(reloaded: &[PhotoId]) -> Value { + json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}) +} diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index fb0ec9883..d48ca3cd2 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { "Read Metadata from File", ["Photo"], None, - "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", + "{ids?} — reads each photo's XMP sidecar, or embedded XMP in raw/DNG and JPEG files when no sidecar exists: metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on; JPEG uses the first complete standard XMP APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; an unreadable sidecar fails without embedded fallback → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", has_selection, read ), diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 9de8c3d64..093f3a84f 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -93,6 +93,17 @@ const NON_ADJUSTMENT: &[&str] = &[ // the file's as-shot white, recorded next to the edit (ours comes from the file itself) "crs:AsShotTemperature", "crs:AsShotTint", + // which Lightroom version may read the edit, and checksums of the profiles it names + "crs:CompatibleVersion", + "crs:CameraProfileDigest", + "crs:LensProfileDigest", + // the lens profile Lightroom picked: ours is the file's own, only `LensProfileEnable` carries over + "crs:LensProfileName", + "crs:LensProfileFilename", + "crs:LensProfileIsEmbedded", + "crs:LensProfileSetup", + // a crop tool option (keep the crop inside the photo), not the crop itself + "crs:CropConstrainToUnitSquare", ]; /// True if the packet carries any `crs:` adjustment (not just bookkeeping fields). @@ -492,6 +503,8 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values || k == "AutoTone" || k == "AutoGrayscaleMix" || (k == "PointColors" && value_is(k, &empty_points)) + // the lens profile's strength, written at its default (100 %) next to every profile + || (matches!(k, "LensProfileDistortionScale" | "LensProfileVignettingScale") && value_is(k, &|v| v.iter().all(|s| s.trim() == "100"))) }; let mut unmapped: Vec = props .keys() @@ -869,6 +882,21 @@ mod tests { assert_eq!(got, ["FutureSlider", "PointColors"]); } + #[test] + fn lightroom_profile_bookkeeping_is_not_reported() { + // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering + let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" + crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" + crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" + crs:CropConstrainToUnitSquare="1""#; + let look = r#"Vivid"#; + assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); + assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); + // a lens profile's strength away from 100 % is an adjustment we don't carry over + let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); + assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); + } + #[test] fn disabled_lens_profile_switch_keeps_embedded_corrections() { // Lightroom applies a file's built-in lens corrections regardless of its profile switch, diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index c2b0e9f69..d3f455b81 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -38,13 +38,13 @@ impl std::error::Error for NotFollowed {} /// was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); - let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NotHere); + match craft_docpath::resolve_absolute(stored, document.as_deref()) { + Ok(path) => Ok(path), + // A usable path that is not absolute here: a Windows path on macOS or Linux, or a + // relative path with no document folder to take it from. + Err(Refused::Invalid) if craft_docpath::resolve(stored, document.as_deref()).is_ok() => Err(NotFollowed::NotHere), + Err(r) => Err(NotFollowed::Refused(r)), } - Ok(path) } #[cfg(test)] @@ -59,5 +59,12 @@ mod tests { assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device))); + #[cfg(windows)] + assert_eq!(resolve("Photos/2024:05/a.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + #[cfg(not(windows))] + assert_eq!(resolve(r"C:\Photos\a.jpg", Some(&doc)), Err(NotFollowed::NotHere)); + let gvfs = Path::new("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/catalog.lrcat"); + #[cfg(not(windows))] + assert_eq!(resolve("a.jpg", Some(gvfs)), Ok(gvfs.with_file_name("a.jpg"))); } } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 895664064..bbd666170 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -82,7 +82,11 @@ pub enum ResizeMode { Width, /// `value` = height in pixels. Height, - /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the larger). + /// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤ + /// `height` (Lightroom's "Width & Height"). + WidthHeight, + /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the + /// larger; Lightroom's "Dimensions"). Dimensions, /// `value` = megapixels. Megapixels, @@ -96,7 +100,7 @@ pub enum ResizeMode { pub struct Resize { pub mode: ResizeMode, pub value: f32, - /// Second dimension for [`ResizeMode::Dimensions`]. + /// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`]. pub height: u32, /// Never upscale past the photo's own (cropped) size. pub dont_enlarge: bool, @@ -127,6 +131,7 @@ impl Resize { ResizeMode::ShortEdge => v / short, ResizeMode::Width => v / w, ResizeMode::Height => v / h, + ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h), ResizeMode::Dimensions => { let (a, b) = (v, self.height as f64); (a.max(b) / long).min(a.min(b) / short) @@ -729,6 +734,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "height", "megapixels", "percent", + "fit", "dontEnlarge", "ppi", "limitKb", @@ -824,8 +830,17 @@ impl ExportOptions { /// newer version may have written and this one doesn't know dropped, also inside `watermark`. /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are /// not the caller's typo, and refusing them would make a preset or Export with Previous fail - /// for good after an update. + /// for good after an update. A `bitDepth` the stored format doesn't have (written at the + /// format's own depth before issue #1952) is dropped too. pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = Self::known_keys(p); + Self::drop_unfit_depth(&mut p, ExportFormat::default()); + p + } + + /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be + /// known once the call's own params (`path`) are merged in. + pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { let mut p = p.clone(); if let Some(o) = p.as_object_mut() { o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); @@ -836,13 +851,44 @@ impl ExportOptions { p } + /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with + /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. + pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { + let format = Self::export_format(p, fallback); + if let Some(o) = p.as_object_mut() + && let Some(depth) = o.get("bitDepth").and_then(whole) + && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) + { + o.remove("bitDepth"); + } + } + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an /// unknown parameter or a value that cannot be read as what it is for is an error, never a /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) /// are accepted and left to the caller. pub fn from_params(p: &serde_json::Value) -> crate::Result { - Self::validate("app.export", p)?; - Ok(Self::from_json(p)) + Self::from_params_or(p, ExportFormat::default()) + } + + /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no + /// format; the format returned is [`ExportOptions::export_format`], the one written. + pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { + let format = Self::export_format(p, fallback); + Self::validate_as("app.export", p, Some(format))?; + Ok(Self { format, ..Self::from_json(p) }) + } + + /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of + /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, + /// the headless backend PNG), else the default. + pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { + use serde_json::Value; + match (p.get("format"), p.get("path").and_then(Value::as_str)) { + (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), + (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), + (None, None) => ExportFormat::default(), + } } /// Check `app.export` params for `cmd` (the command named in the error): every key must be an @@ -850,6 +896,12 @@ impl ExportOptions { /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) + } + + /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported + /// (`None`: not known yet, as for a preset without `format`). + pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; @@ -902,6 +954,7 @@ impl ExportOptions { "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { num(k, v, 0.0, 1.0e9)?; } + "fit" => one_of(k, v, &["dimensions", "widthHeight"])?, "resize" => { let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { @@ -957,6 +1010,18 @@ impl ExportOptions { _ => return Err(bad(unknown_key("parameter", k, &known))), } } + // issue #1952: a depth the format doesn't have would be written at the format's own depth + if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { + let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); + if !depths.iter().any(|d| i64::from(*d) == depth) { + depths.sort_unstable(); + let mut list: Vec = depths.iter().map(u8::to_string).collect(); + let last = list.pop().unwrap_or_default(); + let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; + let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(bad(format!("`bitDepth` must be {list} for {name}"))); + } + } Ok(()) } @@ -1083,8 +1148,12 @@ impl ExportOptions { return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); } let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; + // `fit: widthHeight` binds each edge to its own number; the default fits either orientation + let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight")); Some(match (f("width"), f("height")) { - (Some(w), Some(h)) => Resize { height: h.min(65_535.0) as u32, ..r(ResizeMode::Dimensions, w) }, + (Some(w), Some(h)) => { + Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) } + } (Some(w), None) => r(ResizeMode::Width, w), (None, Some(h)) => r(ResizeMode::Height, h), (None, None) => { @@ -2028,6 +2097,9 @@ fn run_batch_with( ahead: lanes, }; std::thread::scope(|sc| { + // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer + // would otherwise leave them waiting and the scope's join would never return + let _stop = StopOnDrop(&queue); for k in 1..lanes { let q = &queue; // a lane that can't start leaves its photos to the others (and to this thread) @@ -2045,13 +2117,20 @@ fn run_batch_with( } Ok::<_, String>(()) }; - let r = placed(); - queue.stop(); - r + placed() })?; Ok(std::mem::take(&mut placer.out)) } +/// Stops a [`Queue`] when dropped, also while unwinding from a panic. +struct StopOnDrop<'a>(&'a Queue); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + /// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic /// becomes that photo's error, not the batch's end. fn run_gated(item: PreparedExport) -> Result { @@ -2953,6 +3032,13 @@ mod tests { assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225))); + // Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620) + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337))); + assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160))); + // `fit` only matters with both numbers + assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667))); let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); @@ -2967,6 +3053,8 @@ mod tests { let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; assert_eq!(back, Some(r)); + let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true }; + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r)); assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); // never larger than the encoders' limit, never empty @@ -3038,6 +3126,7 @@ mod tests { mode(ResizeMode::Width, value, 0), mode(ResizeMode::Height, value, 0), mode(ResizeMode::Dimensions, value, 1365), + mode(ResizeMode::WidthHeight, value, 1365), mode(ResizeMode::Megapixels, value / 1000.0, 0), mode(ResizeMode::Percent, value / 100.0, 0), ] { @@ -3334,6 +3423,7 @@ mod tests { assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); + assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight")); assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); @@ -3352,6 +3442,7 @@ mod tests { resize: Some(Resize::default()), limit_kb: Some(500), watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + format: ExportFormat::Png, bit_depth: Some(16), ..Default::default() }; @@ -3388,6 +3479,51 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers + /// it), not written at the format's own depth; the error names the depths the format takes. + #[test] + fn bit_depth_must_suit_the_format() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); + assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); + assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); + // no `format`: the one `path` names, else the default (JPEG) + assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); + assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); + assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); + // a path naming no format, or a `null` format, exports the default too + assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); + // or the caller's own format for such a path, which is the one returned + let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); + assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); + assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); + assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); + // stored settings (a preset, Export with Previous) lose such a depth instead of failing + let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(stored.get("bitDepth"), None); + assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); + for f in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff, + ExportFormat::Psd, + ExportFormat::Webp, + ExportFormat::Avif, + ExportFormat::Jxl, + ExportFormat::Original, + ExportFormat::Dng, + ExportFormat::RenderedDng, + ] { + for &(b, _) in ExportOptions::bit_depths(f) { + let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); + } + } + } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own /// folder; it is checked like the other params and kept in presets and Export with Previous. #[test] @@ -3614,6 +3750,32 @@ mod tests { assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); } + // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it + #[test] + fn a_panic_in_progress_does_not_hang_the_lanes() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); + let items = prepare_batch(&mut s, &ids, &o).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let to = Destination { dir: "out".into(), exact: None }; + let mut write = |_: &str, _: &[u8]| Ok(()); + let mut calls = 0; + let mut progress = |_: usize, _: &str| { + calls += 1; + assert!(calls < 2, "progress panics on its second call"); + true + }; + let r = crate::guard::catch("test batch", || { + run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) + }); + tx.send(r.and_then(|r| r)).ok(); + }); + let r = rx.recv_timeout(std::time::Duration::from_secs(60)); + assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); + } + // A photo that fails is reported in its place while the others are exported; with // stop_on_error the batch ends there and nothing after it is written #[test] diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs index df79d5630..f5d0dd5fc 100644 --- a/crates/engine/src/face_download.rs +++ b/crates/engine/src/face_download.rs @@ -181,9 +181,16 @@ impl Drop for Downloads { for job in self.jobs.values() { job.cancel.store(true, Ordering::Relaxed); } - let started = std::time::Instant::now(); - while self.running() && started.elapsed() < Duration::from_secs(1) { - std::thread::sleep(Duration::from_millis(10)); + // Give a running download a moment to notice. Not on the web: there are no download + // threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"), + // which killed the page when a `Session` was replaced after its library failed to open + // (issue #1507). + #[cfg(not(target_arch = "wasm32"))] + { + let started = std::time::Instant::now(); + while self.running() && started.elapsed() < Duration::from_secs(1) { + std::thread::sleep(Duration::from_millis(10)); + } } } } @@ -317,6 +324,18 @@ mod tests { } } + /// Dropping a `Downloads` with nothing running (every session has one) returns at once: the + /// wait for running threads is the only part that takes time, and the web has no threads. + #[test] + fn dropping_idle_downloads_returns_at_once() { + let t = std::time::Instant::now(); + drop(Downloads::default()); + let mut d = Downloads::default(); + d.set_outcome("m", State::Failed("x".into())); + drop(d); + assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed()); + } + #[test] fn a_running_download_shows_in_activity_and_cancels() { use crate::activity::{Activity, Unit}; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 31bb930fb..ae5a9ae0a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo}; +use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader}; fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); @@ -366,20 +366,27 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) } /// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded /// (less memory held while it is developed). pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and +/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether +/// it did: such a picture is not the full image, and an export never renders from it. +pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) } /// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain /// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all /// there is) and the source facts. Both pictures are the same size. pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) } /// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default @@ -408,14 +415,25 @@ fn load_bytes_now( bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize, denoise: Option<&DenoiseSpec>, -) -> Result<(Rgb32f, Option, SourceInfo), String> { + allow_reduced: bool, +) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { if lightcraft_raw::probe(&bytes).is_some() { - let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { + // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image + // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) + .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) + .flatten(); + let used_reduced = reduced.is_some(); + let decoded = match reduced { + Some(r) => Ok(r), + None => lightcraft_raw::decode(&bytes), + }; + let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { Ok(r) => r, Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info)) + .map(|(img, info)| (img, None, info, false)) .ok_or(format!("unsupported raw ({why}) without an embedded preview")); } Err(Err(e)) => return Err(e), @@ -426,13 +444,26 @@ fn load_bytes_now( let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); + // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, + // then remove them again: the rendered source leaves those corrections to the pipeline. + let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) + .then(|| raw.opcodes.list3.clone()); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted let ((xy, t, camera_look), mattes) = rayon::join( - || crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()), + || { + let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); + // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. + let embedded = if removed.is_some() { None } else { lens.as_ref() }; + let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); + if let Some(ops) = removed { + raw.opcodes.list3 = ops; + } + colour + }, || dng_mattes(&bytes, &info), ); drop(bytes); @@ -529,13 +560,14 @@ fn load_bytes_now( // (the catalog knows the original's size: `media::source_info`) native_long: None, }, + used_reduced, )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) } /// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from @@ -1060,17 +1092,7 @@ pub fn fs_pair_loader() -> PairLoader { /// interactive loads (the loupe, exports) never wait — they are counted, so background work /// yields to them. pub fn fs_hooks() -> (FileLoader, FileProbe) { - let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }; - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load_vec(bytes, max_edge); - // the file, the samples and the intermediate images are gone: give their pages back - crate::memory::release(); - r - }); + let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); let probe: FileProbe = Arc::new(|path: &str| { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); @@ -1082,6 +1104,24 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { (loader, probe) } +/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. +pub fn fs_reduced_loader() -> ReducedLoader { + Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) +} + +/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. +fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load(bytes); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r +} + /// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the /// filesystem doesn't say or the time is before 1970. fn file_modified(path: &str) -> Option { @@ -1096,6 +1136,7 @@ impl crate::Session { pub fn with_fs(mut self) -> Self { let (l, p) = fs_hooks(); self.media.file_loader = Some(l); + self.media.reduced_loader = Some(fs_reduced_loader()); self.media.file_probe = Some(p); self.media.preview_loader = Some(fs_preview_loader()); self.media.denoise.loader = Some(fs_pair_loader()); @@ -1183,6 +1224,21 @@ pub(crate) mod tests { let (t, tint) = a99m2.as_shot(d65); assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); assert!(bare.legacy_relative && bare.scale_white.is_none()); // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone @@ -1659,6 +1715,49 @@ pub(crate) mod tests { Some((best.1, best.0)) } + #[test] + fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { + use lightcraft_geom::{Orientation, Point}; + use lightcraft_pipeline::optics::Warp; + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { + let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; + let off = lightcraft_raw::probe_info(&bytes).unwrap(); + assert!(embedded_lens(&off).is_none()); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); + raw.orientation = Orientation::Normal; + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.optics.lens_profile = true; + let base_lens = embedded_lens(&raw).unwrap(); + let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); + for orientation in 1..=8 { + let o = Orientation::from_exif(orientation); + raw.orientation = o; + let lens = embedded_lens(&raw).unwrap(); + for edge in [1200.0, 2560.0, 9504.0] { + let scale = edge / 9504.0; + let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; + let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); + for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { + let point = Point::new(x * 9504.0, y * 6336.0); + let source = base.corrected_to_source(point, 1); + let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); + let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); + let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); + assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); + } + } + } + } + } + /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide @@ -1831,6 +1930,98 @@ pub(crate) mod tests { assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); } + /// Corrections used while fitting a DNG's preview must not be baked into its editable source. + /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. + #[test] + fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); + } + + /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. + /// The source pixels stay uncorrected, including when the file is rotated. + #[test] + fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } + } + /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's /// own white-balanced neutral comes out yellow (blue 12 % low). fn yellowing_look() -> lightcraft_color::Mat3 { @@ -2090,6 +2281,84 @@ pub(crate) mod tests { } } + /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the + /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The + /// copy's samples are much brighter than the image's here, so a picture says which one it came from. + #[test] + fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky /// heuristic would never look) is used; a matte with an unknown name is ignored. @@ -2393,8 +2662,8 @@ pub(crate) mod tests { } } - /// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is - /// overwritten in the file) import as preview only with the JPEG's size, instead of failing. + /// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first + /// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. #[test] fn undecodable_container_with_a_preview_imports_as_preview_only() { let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); @@ -2540,7 +2809,7 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). fn sony_arw(model: &str) -> Vec { use lightcraft_tiff::tags::{self as t, photometric}; diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index a246c4dca..db9e658d0 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -105,7 +105,7 @@ mod tests { assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); // Every script face that ships is a UI fallback, and has the glyphs of its script (#736). use ab_glyph::Font; - for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] { + for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] { let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue }; let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses"); assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..bc8c1c962 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; -/// File extensions LightCraft imports (lower case). +/// File extensions offered by import discovery and file pickers (lower case). +/// This includes preview-only raws; the file probe decides whether sensor decoding is available. pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", - "jxl", "gif", "bmp", + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", + "srw", "psd", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature @@ -494,7 +495,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. -fn probe_all( +pub(crate) fn probe_all( probe: Option<&crate::media::FileProbe>, paths: &[String], done: &std::sync::atomic::AtomicUsize, @@ -691,6 +692,11 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &Sc .iter() .map(|f| { let info = input.cache.remove(f)?; + // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is + // not reached counts as not probed, like the files after it) + if progress.cancel.load(Relaxed) { + return None; + } let size = std::fs::metadata(f).map(|m| m.len()).ok(); (size.is_none() || size == Some(info.file_size)).then_some(info) }) @@ -965,9 +971,11 @@ impl ImportJob { } /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. - fn look_up_library(&mut self) { - let Some((library, fresh)) = self.library.take() else { return }; + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; for p in library.photos() { // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate if p.deleted && fresh { @@ -980,12 +988,14 @@ impl ImportJob { self.by_hash.insert(h.clone(), Some(p.id)); } } + Some(library) } /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { use std::sync::atomic::Ordering::Relaxed; - self.look_up_library(); + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); let opts = self.opts.clone(); let mode = self.mode; let mut out = Prepared { scanned: files.len(), items: Vec::new() }; @@ -1025,6 +1035,13 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -1063,7 +1080,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, self.naming) + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); @@ -1566,6 +1583,7 @@ impl Session { display: None, slide: None, preview_cut: None, + display_fit: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 6338b3b40..6e7f28120 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -40,7 +40,15 @@ pub fn database() -> Option<&'static LensDb> { pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option { let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?; let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default()); - let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?; + let lens = match m.lens_model.as_deref() { + Some(model) => db.find_lens(m.lens_make.as_deref(), model, camera)?, + // Nikon DSLRs record only the lens specification ("24-70mm f/2.8"); a profile must be named exactly that + // (a user's alias), since the specification fits other makers' lenses too + None => { + let name = lightcraft_lensdb::spec_name(m.lens_spec?); + db.find_lens_named(m.lens_make.as_deref(), &name, camera)? + } + }; let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm); let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; let corr = lens.correction(&shot)?; @@ -108,6 +116,27 @@ mod tests { assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); } + /// A Nikon DSLR raw names no lens, only its specification (maker note `0x0084`): it is looked up by the name + /// built from it, and only a profile named exactly that is used, never another maker's lens that merely shares + /// the focal range and aperture. + #[test] + fn lens_specification_needs_a_profile_named_by_it() { + let nikon = lightcraft_meta::Metadata { lens_model: None, lens_spec: Some([18.0, 55.0, 3.5, 5.6]), ..meta() }; + let at = |db: &LensDb| lookup(db, &nikon, 6000.0, 4000.0, Orientation::Normal); + // "Testcam 18-55mm f/3.5-5.6" fits the specification, but so would any maker's 18-55 f/3.5-5.6: refused + assert!(at(&LensDb::parse(DB).unwrap()).is_none()); + let aliased = LensDb::parse( + &DB.replace("Testcam 18-55mm f/3.5-5.6", "Testcam 18-55mm f/3.5-5.618-55mm f/3.5-5.6"), + ) + .unwrap(); + let lens = at(&aliased).unwrap(); + assert!(lens.warp.is_some() && lens.vignette.is_some()); + assert_eq!(Some(lens), lookup(&aliased, &meta(), 6000.0, 4000.0, Orientation::Normal), "same profile as by its name"); + // a lens model, when the file has one, still decides + let named_other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..nikon.clone() }; + assert!(lookup(&aliased, &named_other, 6000.0, 4000.0, Orientation::Normal).is_none()); + } + /// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the /// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's /// corners don't get the full frame's corner correction. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..323ed90ef 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -186,6 +186,10 @@ pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc, + /// Whether an edit during the interaction changed the crop itself. If none did, the crop the + /// photo shows was fitted by Constrain Crop, and the final commit starts again from the original + /// crop, so Auto Sync carries the edit and not the active photo's fitted crop. + pub crop_edited: bool, } /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. @@ -240,6 +244,8 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]). + pub(crate) reload_tickets: cmd::reload::Tickets, /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by /// [`Session::retry_unsaved_sidecars`] once their file is reachable. pub(crate) unsaved_sidecars: std::collections::BTreeSet, @@ -434,6 +440,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + reload_tickets: Default::default(), unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, @@ -881,22 +888,58 @@ impl Session { /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); - let settings = Arc::new(settings); - if let Some(i) = &self.interaction + // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990) + let requested = settings; + let settings = Arc::new(self.fit_constrained_crop(id, requested.clone())); + let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry); + if let Some(i) = &mut self.interaction && i.photo == id { + i.crop_edited |= crop_edited; return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; - ops.extend(self.auto_sync_ops(id, &settings, label)); + ops.extend(self.auto_sync_ops(id, &requested, label)); let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } }; self.commit(label, op) } - /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the - /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor - /// history / snapshot restores. + /// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue + /// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections, + /// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner + /// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as + /// drawn (the render still constrains it). + fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings { + if !d.geometry.constrain_crop { + return d; + } + let Some(photo) = self.catalog.photo(id) else { return d }; + let before = &photo.develop; + let turned_on = !before.geometry.constrain_crop; + let warp_changed = before.geometry != d.geometry + || before.optics != d.optics + || before.orientation != d.orientation + || ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s)); + let crop_edited = before.crop.geometry != d.crop.geometry; + if !(turned_on || (warp_changed && !crop_edited)) { + return d; + } + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &d, + true, + photo.embedded_lens.as_ref(), + ); + d.crop.geometry.rect = frame.crop.rect; + d + } + + /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked, + /// before its crop is fitted) over to the other selected photos: only the settings that changed; + /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process + /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each + /// photo's own crop: the active photo's fitted crop is never copied over another's. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -907,7 +950,16 @@ impl Session { return Vec::new(); } let Some(old) = self.develop_of(id) else { return Vec::new() }; - let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() }; + let mut old_json = old.to_json(); + let mut new_json = new.to_json(); + // False is omitted from stored profile JSON. Keep it explicit for this comparison so + // switching camera local tone mapping off is a change without copying untouched values. + old_json["profile"]["camera_local_tone"] = Value::Bool(old.profile.camera_local_tone); + new_json["profile"]["camera_local_tone"] = Value::Bool(new.profile.camera_local_tone); + // Compare actual HDR leaves even when persisted defaults omit the whole HDR object. + old_json["hdr"] = serde_json::json!(old.hdr); + new_json["hdr"] = serde_json::json!(new.hdr); + let Some(mut delta) = json_delta(&old_json, &new_json) else { return Vec::new() }; if let Some(o) = delta.as_object_mut() { for k in ["spots", "red_eye", "version", "process"] { o.remove(k); @@ -922,7 +974,7 @@ impl Session { .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { - let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); + let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0)); (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() @@ -944,7 +996,7 @@ impl Session { } let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?; let original = self.develop_of(id).unwrap_or_default(); - self.interaction = Some(Interaction { label: label.into(), photo: id, original }); + self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false }); Ok(()) } @@ -955,9 +1007,15 @@ impl Session { if *cur == *i.original { return Ok(()); } - // Restore the original silently, then commit the final value as one step. + // Restore the original silently, then commit the final value as one step. A crop nobody + // edited goes back to the original's: Constrain Crop fits it again for the final warp, and + // Auto Sync carries the edit, not the active photo's fitted crop (review of #1990). self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?; - self.set_develop(i.photo, (*cur).clone(), &i.label) + let mut last = (*cur).clone(); + if !i.crop_edited { + last.crop.geometry = i.original.crop.geometry; + } + self.set_develop(i.photo, last, &i.label) } pub fn cancel_interaction(&mut self) -> Result<()> { @@ -1249,8 +1307,12 @@ mod tests_albums_local; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_constrain_crop; +#[cfg(test)] mod tests_crop_angle; #[cfg(test)] +mod tests_crop_aspect; +#[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; @@ -1275,6 +1337,8 @@ mod tests_history_cursor; #[cfg(test)] mod tests_hold_view; #[cfg(test)] +mod tests_ids_params; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e91f0bfff..a5071b4b3 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -84,6 +84,9 @@ pub struct Library { /// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes /// exist only in memory ([`Session::unsaved`]). pub unsaved_error: Option, + /// Why the last write of presets.json failed; `None` once it is written. While set, the preset + /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266). + presets_error: Option, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option, /// What forgetting untouched Local records did when the library opened. @@ -555,7 +558,10 @@ impl Session { self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some()); } + // not on Missing Photos: working that view out checks every original (minutes on a + // sleeping NAS), and the app checks them in the background once it runs if self.selection.active.is_none() + && self.source != LibrarySource::Missing && let Some(first) = self.visible_cloned().first() { self.selection = Selection::single(*first); @@ -577,6 +583,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -651,10 +658,17 @@ impl Session { let Some(lib) = self.library.as_mut() else { return Ok(()) }; if state.hash == lib.presets_saved.hash { lib.presets_saved = state; + lib.presets_error = None; } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - // the state stays stale: the next save tries again + // the state stays stale: retried by the next command and the frame loop (backing off); + // meanwhile the presets count as unsaved, so the UI warns and a backup refuses log::error!("library: presets: {e}"); + lib.presets_error = Some(format!("presets: {e}")); + lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF); } else { + if lib.presets_error.take().is_some() { + log::info!("library: presets written to disk"); + } lib.presets_saved = state; } Ok(()) @@ -664,7 +678,8 @@ impl Session { /// changed outside [`Session::execute`]). pub fn persist_if_dirty(&mut self) { let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t); - if self.library.is_some() && !self.pending_log.is_empty() && !backing_off { + let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some()); + if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off { let _ = self.persist(); } else if let Some(lib) = self.library.as_mut() { poll_compaction(lib); @@ -672,11 +687,14 @@ impl Session { } /// Changes applied in memory but not yet written to disk because saving failed: the number of - /// queued ops and why (`None` when everything is saved, or no library is open). Retried by - /// every command and by [`Session::persist_if_dirty`]. + /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library + /// is open). Retried by every command and by [`Session::persist_if_dirty`]. pub fn unsaved(&self) -> Option<(usize, &str)> { let lib = self.library.as_ref()?; - lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e)) + let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()); + let presets = lib.presets_error.as_deref(); + let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some()); + ops.or(presets).map(|why| (n, why)) } /// Flush everything and write a snapshot (on quit). Ends an open interaction first. diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 5133fc80c..9c9979d97 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -524,6 +524,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } @@ -547,6 +548,7 @@ mod tests { members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])], collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])], warnings: vec!["one warning".into()], + skipped: Vec::new(), } } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 5f39188a1..797a96708 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -8,7 +8,7 @@ use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }; -use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; +use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue}; use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; use lightcraft_geom::{Affine, Orientation, Point, Rect}; use serde::{Deserialize, Serialize}; @@ -181,6 +181,34 @@ pub struct CatalogImport { pub members: Vec, pub collection_content: Vec, pub warnings: Vec, + /// Catalog images left out because their records are incomplete; reported as data (not text) + /// so each front end can say so in its own language. + #[serde(default)] + pub skipped: Vec, +} + +/// A catalog image that was not imported, and why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkippedImage { + /// The image's `Adobe_images.id_local`. + pub image: i64, + pub reason: SkipReason, +} + +/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SkipReason { + /// Its `AgLibraryFile` record is gone. + MissingFile, + /// Its file's `AgLibraryFolder` record is gone. + MissingFolder, + /// Its folder's `AgLibraryRootFolder` record is gone. + MissingRoot, + /// Its file name or root folder path is empty. + IncompletePath, + /// A virtual copy whose master was skipped. + MasterSkipped, } #[derive(Clone, Default, Serialize, Deserialize)] @@ -408,6 +436,9 @@ pub fn read(path: &Path) -> Result { read_with_progress(path, &cancel, &total, &done) } +/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and +/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and +/// listed in [`CatalogImport::skipped`] instead of failing the read. pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result { // 13 catalog tables plus one validation stage; ImportJob adds one unit per original path. total.store(14, Ordering::Relaxed); @@ -416,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let before = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_path = PathBuf::from(format!("{}-wal", path.display())); let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); - let main = bytes(path)?; + let main = FileSource::open(path)?; let wal = match std::fs::metadata(&wal_path) { Ok(_) => bytes(&wal_path)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(e) => return Err(e.to_string()), }; - let wal_len = committed_wal_len(&main, &wal)?; + let wal_len = committed_wal_len(main.header(), &wal)?; ensure_read_active(cancel)?; - let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; + let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; let after = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after { @@ -522,19 +553,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, members: optional_table("AgLibraryCollectionImage", &mut warnings)?, collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?, warnings, + skipped: Vec::new(), }; + // images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves + // these behind and ignores them); they and their virtual copies are skipped and reported + // instead of failing the import + let mut orphans = HashSet::new(); for image in table("Adobe_images", true)? { ensure_read_active(cancel)?; let id = number(&image, "id_local"); - let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; - let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; - let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; + let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| { + let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?; + let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?; + Ok((file, folder, root)) + }); + let (file, folder, root) = match record { + Ok(found) => found, + Err(reason) => { + out.skipped.push(SkippedImage { image: id, reason }); + orphans.insert(id); + continue; + } + }; let root_id = number(root, "id_local"); let root_path = root_paths.get(&root_id).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { - return Err(format!("image {id}: incomplete original path")); + out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath }); + orphans.insert(id); + continue; } let (photo_path, original) = original_path(root_path, folder_path, name, path); if let Original::Refused(why) = &original @@ -562,11 +610,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, original, }); } + drop_copies_of(&mut out, &orphans); refuse_non_media(&mut out); ensure_read_active(cancel)?; + reader.db.ensure_unchanged()?; + let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + if wal_before != wal_after { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } Ok(out) } +/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has +/// no original of its own. +fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet) { + if masters.is_empty() { + return; + } + let mut skipped = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // 0 marks a master, never a copy + if master == 0 || !masters.contains(&master) { + return true; + } + skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped }); + false + }); + data.skipped.extend(skipped); +} + /// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the /// import's warnings. /// @@ -785,6 +858,8 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } +/// Turn the read catalog into library records in one undo step and return the import report +/// (added, missing, failed, warnings and skipped images). pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135) @@ -1018,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -1062,6 +1137,7 @@ mod tests { ], collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so @@ -1521,6 +1597,16 @@ mod tests { } /// A one-row-per-table SQLite file: `(name, columns, row)` each. fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + native_catalog_with_last_table_page(tables, None) + } + /// [`native_catalog`] whose last table claims `last_page` as its root page. + fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { + let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); + native_catalog_rows(&tables, last_page) + } + /// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last + /// table's root page. + fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { use SqlValue::{Integer as I, Text as T}; let mut bytes = vec![0u8; 5 * 2048]; bytes[..16].copy_from_slice(b"SQLite format 3\0"); @@ -1534,15 +1620,15 @@ mod tests { bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); let mut schema = Vec::new(); - for (i, (name, columns, row)) in tables.iter().enumerate() { + for (i, (name, columns, rows)) in tables.iter().enumerate() { schema.push(vec![ T("table".into()), T((*name).into()), T((*name).into()), - I(i as i64 + 2), + I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), T(format!("CREATE TABLE {name} ({columns})")), ]); - leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); + leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); } leaf(&mut bytes[..2048], 100, &schema); bytes @@ -1576,11 +1662,104 @@ mod tests { assert_eq!(catalog.photos[0].uuid, "fixture-image"); assert_eq!(number(&catalog.photos[0].image, "rating"), 5); assert!(catalog.photos[0].path.ends_with("test.ARW")); + // Put the required image table beyond the old 1 GiB cutoff without allocating it. + // A main-file-sized allocation would defeat the purpose of the page-backed reader. + // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. + #[cfg(unix)] + { + let far_offset = 1u64 << 30; + let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); + use std::io::{Seek, SeekFrom, Write}; + let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); + sparse.write_all(&bytes).unwrap(); + sparse.set_len(far_offset + 2048).unwrap(); + sparse.seek(SeekFrom::Start(far_offset)).unwrap(); + sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); + drop(sparse); + let large = read(&path).unwrap(); + assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); + let mut session = crate::Session::new(); + session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(session.catalog.len(), 1); + assert_eq!(session.catalog.photos().next().unwrap().rating, 5); + session.execute("edit.undo", &json!({})).unwrap(); + assert!(session.catalog.is_empty()); + } std::fs::write(&path, b"bad catalog").unwrap(); assert!(read(&path).is_err()); std::fs::remove_file(path).unwrap(); } + /// Reads `tables` as a catalog file and returns what the reader made of it. + fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { + let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); + std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); + let catalog = read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + catalog + } + + /// `tables` with each row as a one-row table, for [`native_catalog_rows`]. + fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { + tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() + } + + #[test] + fn images_with_incomplete_records_are_skipped_not_fatal() { + use SqlValue::{Integer as I, Text as T}; + // (table, column, value): the record that breaks, and the reason the import gives + let cases = [ + (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file + (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder + (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root + (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty + ]; + for (table, column, value, reason) in cases { + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[table].2[0][column] = value; + let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); + assert!(catalog.photos.is_empty(), "{reason:?}"); + assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); + assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); + } + } + + #[test] + fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[3] = ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", + vec![ + // the master's file record is gone, so it is skipped … + vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], + // … and its virtual copy goes with it, although its own file record is there + vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], + ], + ); + let catalog = read_tables(&tables, "skipped-copy"); + assert!(catalog.photos.is_empty()); + assert_eq!( + catalog.skipped, + [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] + ); + validate(&catalog).unwrap(); + } + + #[test] + fn skipped_images_are_in_the_import_report() { + let mut data = sample(); + data.photos.retain(|p| p.source_id != 1); + drop_copies_of(&mut data, &HashSet::from([1])); + data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + assert_eq!(report["photos"], 0); + assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); + assert_eq!(report["warnings"], json!([])); + } + #[test] fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 52a51b30f..762c8588d 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -355,6 +355,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } @@ -399,6 +400,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), }, normal: Prepared::default(), index: ImportIndex::default(), diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 803707c1c..58019f2c1 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -6,7 +6,79 @@ //! than a SQL engine. It never writes its input and has no SQLite runtime //! dependency. +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use std::fs::{File, Metadata}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const MAX_DATABASE_BYTES: u64 = 16 << 30; + +/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file. +pub struct FileSource { + file: Mutex, + path: PathBuf, + before: Metadata, + header: Vec, +} + +impl FileSource { + pub fn open(path: &Path) -> Result { + if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + let mut file = File::open(path).map_err(|e| e.to_string())?; + let before = file.metadata().map_err(|e| e.to_string())?; + if !before.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + if before.len() > MAX_DATABASE_BYTES { + return Err("catalog exceeds 16 GiB import limit".into()); + } + let mut header = vec![0; 100]; + file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?; + let source = Self { file: Mutex::new(file), path: path.into(), before, header }; + source.ensure_unchanged()?; + Ok(source) + } + + pub fn header(&self) -> &[u8] { + &self.header + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + let handle = file.metadata().map_err(|e| e.to_string())?; + let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?; + if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } + Ok(()) + } + + fn page(&self, page_id: u32, size: usize) -> Result, String> { + let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?; + if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) { + return Err(format!("page {page_id} is truncated")); + } + let mut out = vec![0; size]; + let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?; + Ok(out) + } +} + +fn same_metadata(a: &Metadata, b: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if a.dev() != b.dev() || a.ino() != b.ino() { + return false; + } + } + a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok() +} const MAX_PAGE_ID: u32 = 16_777_216; const MAX_COLUMNS: usize = 16_384; @@ -44,6 +116,7 @@ pub struct LiveTable { /// Read-only SQLite database image, with a committed WAL overlay if supplied. pub struct Database { main: Vec, + file: Option, overlay: HashMap>, page_size: usize, reserved: usize, @@ -84,17 +157,34 @@ impl Database { /// The WAL argument must include its 32-byte header and only complete /// frames through a commit. Frames are overlaid by page number, with the /// final commit's database-size field defining the visible page count. + #[cfg(test)] pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result { + Self::open(main, None, committed_wal) + } + + pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result { + Self::open(source.header().to_vec(), Some(source), committed_wal) + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + if let Some(file) = &self.file { + file.ensure_unchanged()?; + } + Ok(()) + } + + fn open(main: Vec, file: Option, committed_wal: &[u8]) -> Result { let main_header = main.get(..100).ok_or("truncated SQLite header")?; if main_header.get(..16) != Some(b"SQLite format 3\0") { return Err("invalid SQLite header signature".into()); } let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; validate_header_fields(main_header, page_size)?; - if main.len() < page_size || !main.len().is_multiple_of(page_size) { + let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len()); + if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) { return Err("SQLite image is not a whole number of pages".into()); } - let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; + let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?; if main_pages == 0 || main_pages > MAX_PAGE_ID { return Err("invalid SQLite image page count".into()); } @@ -140,7 +230,7 @@ impl Database { _ => return Err("unsupported SQLite text encoding".into()), }; - let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; + let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; let schema = db.parse_schema()?; db.tables = schema.tables; db.rowid_aliases = schema.aliases; @@ -166,17 +256,20 @@ impl Database { Ok(state.out) } - fn page(&self, page_id: u32) -> Result<&[u8], String> { + fn page(&self, page_id: u32) -> Result, String> { if page_id == 0 || page_id > self.page_count { return Err(format!("page {page_id} is outside database")); } if let Some(page) = self.overlay.get(&page_id) { - return Ok(page); + return Ok(Cow::Borrowed(page)); + } + if let Some(file) = &self.file { + return file.page(page_id, self.page_size).map(Cow::Owned); } let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?; let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?; let end = start.checked_add(self.page_size).ok_or("page offset overflow")?; - self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated")) + self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated")) } fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> { @@ -219,7 +312,7 @@ impl Database { if state.payload_total > MAX_TABLE_PAYLOAD { return Err("SQLite table payload exceeds bounded import size".into()); } - let payload = self.payload(page, at, payload_len)?; + let payload = self.payload(&page, at, payload_len)?; let mut values = decode_record(&payload, ncols, self.encoding)?; if let Some(index) = alias { if index >= values.len() { @@ -325,7 +418,7 @@ impl Database { out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); remaining -= take; if remaining > 0 { - next = be_u32_at(overflow, 0)?; + next = be_u32_at(&overflow, 0)?; } } Ok(out) @@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result { mod tests { use super::*; + struct Scratch(PathBuf); + impl Scratch { + fn new(bytes: &[u8]) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn file_backed(mut db: Database) -> (Scratch, Database) { + let scratch = Scratch::new(&db.main); + db.file = Some(FileSource::open(&scratch.0).unwrap()); + db.main.truncate(100); + (scratch, db) + } + + #[test] + fn file_source_bounds_and_mutation_detection() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + assert_eq!(source.page(1, 512).unwrap().len(), 512); + assert!(source.page(2, 512).unwrap_err().contains("truncated")); + File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); + assert!(source.page(1, 512).is_err()); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + drop(source); + // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. + #[cfg(unix)] + { + File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); + assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); + } + } + + #[cfg(unix)] + #[test] + fn file_source_detects_same_sized_path_replacement() { + let original = Scratch::new(&[0; 512]); + let replacement = Scratch::new(&[0; 512]); + let source = FileSource::open(&original.0).unwrap(); + std::fs::rename(&replacement.0, &original.0).unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + + #[test] + fn file_source_detects_same_length_content_changes() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + std::fs::write(&scratch.0, [1; 512]).unwrap(); + // Make the timestamp change deterministic even on filesystems with coarse timestamps. + File::options() + .write(true) + .open(&scratch.0) + .unwrap() + .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) + .unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + fn varint(mut value: u64) -> Vec { let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; value >>= 7; @@ -1026,6 +1185,40 @@ mod tests { assert!(tables.iter().any(|table| table.name == "t")); let rows = db.read_table(2, 2).unwrap(); assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + let scratch = Scratch::new(&db.main); + let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); + assert_eq!(file_db.live_tables().unwrap(), tables); + assert_eq!(file_db.read_table(2, 2).unwrap(), rows); + assert_eq!(file_db.main.len(), 100); + file_db.ensure_unchanged().unwrap(); + // The same committed WAL page overrides the lazily read main page. + let mut wal = vec![0; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[32..36].copy_from_slice(&2u32.to_be_bytes()); + wal[36..40].copy_from_slice(&2u32.to_be_bytes()); + leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); + let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); + let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); + assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); + assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); + let mut malformed = db.main.clone(); + malformed.push(0); + let partial = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), + "SQLite image is not a whole number of pages" + ); + let mut malformed = db.main.clone(); + malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); + malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); + malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); + let oversized = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), + "SQLite header page count exceeds image" + ); } #[test] @@ -1042,6 +1235,7 @@ mod tests { leaf(&mut main[1024..1536], 0, 1, &[2, 0]); leaf(&mut main[1536..], 0, 2, &[2, 0]); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1073,6 +1267,7 @@ mod tests { let mut aliases = HashMap::new(); aliases.insert(1, 0); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1093,6 +1288,7 @@ mod tests { main[103..105].copy_from_slice(&1u16.to_be_bytes()); main[108..110].copy_from_slice(&490u16.to_be_bytes()); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1103,6 +1299,8 @@ mod tests { rowid_aliases: HashMap::new(), }; assert!(db.read_table(1, 1).is_err()); + let (_scratch, db) = file_backed(db); + assert!(db.read_table(1, 1).is_err()); } #[test] @@ -1115,6 +1313,7 @@ mod tests { let mut main = vec![0u8; 2 * 512]; let db = Database { + file: None, main: std::mem::take(&mut main), overlay: HashMap::new(), page_size: 512, @@ -1141,6 +1340,10 @@ mod tests { assert_eq!(bytes.len(), 600); assert_eq!(&bytes[..92], &[b'x'; 92]); assert_eq!(&bytes[92..], &[b'y'; 508]); + let (_scratch, mut db) = file_backed(db); + db.overlay.clear(); + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(&bytes[92..], &[b'y'; 508]); } #[test] @@ -1164,6 +1367,7 @@ mod tests { let mut page = vec![0u8; 512]; page[16..18].copy_from_slice(&512u16.to_be_bytes()); let db = Database { + file: None, main: vec![0; 512], overlay: HashMap::new(), page_size: 512, diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..292a9fb1e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 69; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -132,6 +132,11 @@ fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, o /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; +/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) +/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, +/// which must render the full image. +pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; + /// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than /// `max_edge` (set by the app). `None`: no usable preview. pub type PreviewLoader = Arc Option + Send + Sync>; @@ -183,11 +188,17 @@ pub struct DecodedSource { pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, + /// These pixels came from the photo's smart preview (an offline original), not from its file. + /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. + pub from_proxy: bool, + /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image + /// itself: good for a thumbnail, never for an export. + pub reduced: bool, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None } + DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, @@ -258,6 +269,8 @@ pub enum SourceRef { fallback: Option, /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). denoise: Option<(DenoiseSpec, PairLoader)>, + /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. + reduced: Option, }, /// A smart preview standing in for a missing original. Smart { @@ -277,14 +290,19 @@ impl SourceRef { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, - SourceRef::File { path, max_edge, loader, fallback, denoise } => { - let plain = || match loader { - Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), - None => Err(format!("no decoder available for {path}")), + SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { + let plain = || match (reduced, loader) { + (Some(l), _) => { + l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) + } + (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), + (None, None) => Err(format!("no decoder available for {path}")), }; // the denoised picture comes with the plain one from a single decode; when it cannot be read the // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) @@ -306,6 +324,8 @@ impl SourceRef { info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + from_proxy: false, + reduced: false, }) }) } else { @@ -351,6 +371,8 @@ pub struct MediaCache { /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. budget: usize, pub file_loader: Option, + /// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`]. + pub reduced_loader: Option, pub file_probe: Option, pub preview_loader: Option, /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. @@ -426,6 +448,7 @@ impl Default for MediaCache { preview_capacity: 0, budget, file_loader: None, + reduced_loader: None, file_probe: None, preview_loader: None, file_bytes: None, @@ -718,11 +741,11 @@ impl MediaCache { } match self.scenes.iter().find(|s| s.id == *scene) { Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, } } Source::File { path } => { - SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None } + SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } } } } @@ -761,6 +784,24 @@ pub struct RenderJob { /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } } pub struct RenderResult { @@ -784,6 +825,14 @@ pub struct RenderResult { impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; @@ -994,6 +1043,15 @@ impl RenderJob { { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } RenderResult { request_id: self.request_id, source_key: self.source_key, @@ -1201,7 +1259,12 @@ impl crate::Session { /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { let b = thumb_bucket(long_edge); - self.build_job(id, b, b, false, true, Some(b)) + let mut job = self.build_job(id, b, b, false, true, Some(b))?; + // (a thumbnail may develop a smaller copy of the raw image that the file keeps) + if let SourceRef::File { reduced, .. } = &mut job.source { + reduced.clone_from(&self.media.reduced_loader); + } + Some(job) } fn build_job( @@ -1266,6 +1329,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1319,6 +1383,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1373,6 +1438,7 @@ impl crate::Session { display: None, slide: None, preview_cut, + display_fit: None, }) } @@ -1610,6 +1676,17 @@ impl crate::Session { depth: lightcraft_pipeline::OutputDepth, ) -> Result { let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw + // image that the file keeps (Lightroom's merged DNGs) + let reduced = match &job.source { + SourceRef::Loaded(s) => s.reduced, + SourceRef::Fit { from, .. } => from.reduced, + _ => false, + }; + if reduced { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + } // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { @@ -1798,6 +1875,28 @@ mod thumbnail_hash_tests { mod tests { use super::*; + /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; + /// a draft, and a render no larger than the drawn size, are left as rendered. + #[test] + fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); + } + #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the @@ -1866,6 +1965,7 @@ mod tests { })), fallback: None, denoise: None, + reduced: None, }; job.info = SourceInfo::default(); // Header facts cannot override decoder facts. job.settings = Arc::new(DevelopSettings::default()); @@ -1921,6 +2021,7 @@ mod tests { loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), fallback: None, denoise: None, + reduced: None, }; let undo_before = s.undo.len(); let request = job.request; diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 26c69c63d..76bcfd7f2 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { } impl Session { - /// Plan a merge of `ids` (catalog order of the given ids is kept). + /// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once + /// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory. pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result { let mut sources = Vec::new(); - for id in ids { + let mut seen = std::collections::HashSet::new(); + for id in ids.iter().filter(|id| seen.insert(**id)) { let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?; match &p.source { Source::File { path } => sources.push((*id, path.clone())), diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index c955dcc01..b69a6dca2 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -75,6 +75,7 @@ pub(crate) struct Input { } /// What a detail pass re-runs. +#[derive(Clone)] #[cfg_attr(not(feature = "sam"), allow(dead_code))] pub(crate) enum Prompt { Clicks(Vec), @@ -440,7 +441,11 @@ impl Session { let job = self.segment_job(id, kind, Tag::Wait, tx, region)?; self.segmenter.worker.submit(job)?; match rx.recv_timeout(WAIT) { - Ok(o) => o.result, + Ok(o) => { + // (shown by the next poll in an app; logged anyway) + self.segmenter.messages.extend(o.notice); + o.result + } Err(_) => Err("the AI model took too long; try again".into()), } } @@ -579,7 +584,7 @@ impl Session { self.segment_queue(id, kind, tag, Some(region))?; } else { let patch = self.segment_wait(id, kind, Some(region))?; - let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false }; + let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None }; let mut polled = Polled::default(); self.apply_outcome(outcome, &mut polled); } @@ -618,6 +623,7 @@ impl Session { if o.superseded { return; } + polled.messages.extend(o.notice); match o.tag { Tag::Wait => {} Tag::Prepare => { @@ -711,6 +717,18 @@ impl Session { mod tests { use super::*; + #[cfg(feature = "sam")] + #[test] + fn a_worker_notice_reaches_the_user_with_the_result() { + // the job after which the model runs on the CPU (the GPU failed) says so + let mut s = Session::with_demo(); + let notice = Some(worker::GPU_FAILED.to_string()); + s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap(); + let polled = s.segment_poll(); + assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]); + assert!(s.segment_poll().messages.is_empty(), "once"); + } + fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask { let l: Vec = (0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect(); diff --git a/crates/engine/src/segment/worker.rs b/crates/engine/src/segment/worker.rs index 5769fd2ae..efbb1c21c 100644 --- a/crates/engine/src/segment/worker.rs +++ b/crates/engine/src/segment/worker.rs @@ -7,6 +7,13 @@ //! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy //! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded //! after [`IDLE_UNLOAD`] without requests (it holds several GB). +//! +//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an +//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the +//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest +//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and +//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on +//! the CPU from the start. use std::collections::VecDeque; use std::panic::AssertUnwindSafe; @@ -29,6 +36,7 @@ use crate::media::RenderJob; pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60); /// What to compute. +#[derive(Clone)] pub enum Kind { /// Load the model and encode the photo (so the first click is fast). Prepare, @@ -42,6 +50,12 @@ pub enum Kind { /// Panics (tests of the worker's recovery). #[cfg(test)] Panic, + /// Fails like the model on a GPU that can't run it (a panic or a candle error), and + /// succeeds on the CPU (tests of the CPU fallback). + #[cfg(test)] + FailOnGpu { + panic: bool, + }, } /// One request. @@ -65,6 +79,70 @@ pub struct Outcome { pub result: Result, String>, /// A newer request for the same selection came in; this one was skipped. pub superseded: bool, + /// Something the user should know besides the result ([`GPU_FAILED`]). + pub notice: Option, +} + +/// The notice of the job after which the model runs on the CPU. +pub const GPU_FAILED: &str = + "The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log."; + +/// Where the model is loaded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Placement { + /// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU. + Gpu, + /// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model + /// failed on it. + Cpu, +} + +impl Placement { + fn initial() -> Self { + if !lightcraft_segment::GPU_BUILD { + return Placement::Cpu; + } + if let Some(why) = lightcraft_gpu::env_off_reason() { + log::info!("SAM 3 runs on the CPU ({why})"); + return Placement::Cpu; + } + Placement::Gpu + } +} + +/// Why a job failed. +#[derive(Debug)] +enum Failure { + /// The model failed (a candle error, or a panic): on the GPU, the CPU may run it. + Model(String), + /// Anything else (no model files, the photo, a remote worker): another device won't help. + Other(String), +} + +impl Failure { + fn message(self) -> String { + match self { + Failure::Model(m) | Failure::Other(m) => m, + } + } +} + +impl From for Failure { + fn from(m: String) -> Self { + Failure::Other(m) + } +} + +impl From<&str> for Failure { + fn from(m: &str) -> Self { + Failure::Other(m.into()) + } +} + +impl From for Failure { + fn from(e: lightcraft_segment::Error) -> Self { + if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) } + } } /// Counters the session reads without locking. @@ -159,7 +237,7 @@ impl Worker { // (re)start the thread let (tx, rx) = mpsc::channel(); let shared = self.shared.clone(); - match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) { + match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) { Ok(t) => { self.thread = Some(t); self.tx = Some(tx.clone()); @@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool { } } -fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { +fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration, mut placement: Placement) { let mut state = State::default(); let mut queue: VecDeque = VecDeque::new(); loop { @@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { let done = Done(if is_detail { &shared.detail } else { &shared.pending }); if queue.iter().any(|later| supersedes(later, &job)) { drop(done); - let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true }); + let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None }); continue; } - let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) { - Ok(r) => r, - Err(p) => { - let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); - log::error!("SAM 3 panicked: {why}"); - // don't trust what the model kept: load it again next time - state = State::default(); - Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string()) - } + // what a retry on the CPU needs (running the job takes them) + let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone())); + let mut result = attempt(&mut state, shared, &mut job, placement); + let mut notice = None; + let gpu_failed = match &result { + Err(Failure::Model(why)) => Some(why.clone()), + _ => None, }; + if let (Some(why), Some((kind, render))) = (gpu_failed, retry) { + // the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free + // the model there and try once on the CPU + log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU"); + state = State::default(); + (job.kind, job.render) = (kind, render); + match attempt(&mut state, shared, &mut job, Placement::Cpu) { + Ok(r) => { + log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU"); + placement = Placement::Cpu; + notice = Some(GPU_FAILED.to_string()); + result = Ok(r); + } + Err(e) => { + // the CPU fails too, so the GPU isn't the cause: the next job uses it again + log::warn!("SAM 3 failed on the CPU too: {}", e.message()); + state = State::default(); + } + } + } + let result = result.map_err(Failure::message); if result.is_err() && !is_detail { state.cache = None; } shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst); drop(done); - let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false }); + let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice }); } } -/// The model for `dir`, loading it when needed. -fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> { +/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the +/// model kept. +fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result, Failure> { + match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) { + Ok(r) => r, + Err(p) => { + let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + log::error!("SAM 3 panicked: {why}"); + // don't trust what the model kept: load it again next time + *state = State::default(); + Err(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into())) + } + } +} + +/// The model for `dir`, loading it on `placement` when needed. +fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> { if state.model.as_ref().is_none_or(|(d, _)| d != dir) { state.model = None; state.cache = None; let _busy = Flag::raise(&shared.encoding); let t = web_time::Instant::now(); - let m = Sam3::load(dir).map_err(|e| e.to_string())?; - log::info!("SAM 3 loaded in {:?}", t.elapsed()); + let m = match placement { + Placement::Gpu => Sam3::load(dir)?, + Placement::Cpu => Sam3::load_cpu(dir)?, + }; + log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" }); state.model = Some((dir.to_path_buf(), m)); shared.loaded.store(true, Ordering::SeqCst); } - state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string()) + state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into()) } /// Render the model input. @@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result { } /// The model and the encoded photo `job.key` (rendering and encoding it when needed). -fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> { - model(state, shared, &job.dir)?; +fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> { + model(state, shared, &job.dir, placement)?; if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) { state.cache = None; let input = render(job)?; let _busy = Flag::raise(&shared.encoding); let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?; let t = web_time::Instant::now(); - let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?; - m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?; + let mut enc = m.encode(&input.rgb, input.w, input.h)?; + m.prepare_clicks(&mut enc)?; log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed()); state.cache = Some((job.key, enc)); } @@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) } } #[cfg(test)] - Kind::Panic => return Err("test panic request".into()), + Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()), }; let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } }; let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation }; @@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) })) } -fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result, String> { +fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result, Failure> { if let Some(endpoint) = remote::configured() { let result = remote_job(state, shared, job, &endpoint); if result.is_err() { state.remote = None; } - return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")); + return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."))); } let side = lightcraft_segment::MASK_SIDE; match std::mem::replace(&mut job.kind, Kind::Prepare) { Kind::Prepare => { - encoded(state, shared, job)?; + encoded(state, shared, job, placement)?; Ok(None) } Kind::Clicks(clicks) => { - let (m, enc) = encoded(state, shared, job)?; - let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?; + let (m, enc) = encoded(state, shared, job, placement)?; + let p = m.segment_clicks(enc, &clicks)?; Ok(Some(SegMask::from_logits(side, &p.logits))) } Kind::Text(text) => { - let (m, enc) = encoded(state, shared, job)?; + let (m, enc) = encoded(state, shared, job, placement)?; Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l))) } #[cfg(test)] #[allow(clippy::panic)] Kind::Panic => panic!("a test panic"), + #[cfg(test)] + #[allow(clippy::panic)] + Kind::FailOnGpu { panic } => match placement { + Placement::Gpu if panic => panic!("a test panic on the GPU"), + Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())), + Placement::Cpu => Ok(None), + }, Kind::Detail { prompt, region } => { let input = render(job)?; - let m = model(state, shared, &job.dir)?; + let m = model(state, shared, &job.dir, placement)?; let t = web_time::Instant::now(); - let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?; + let mut enc = m.encode(&input.rgb, input.w, input.h)?; let (rw, rh) = (region[2] - region[0], region[3] - region[1]); if !(rw > 0.0 && rh > 0.0) { return Ok(None); @@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result text_logits(m, &mut enc, &text)?, }; @@ -438,7 +560,7 @@ mod tests { jtx.send(job(Tag::Wait, &tx)).unwrap(); shared.pending.store(2, Ordering::SeqCst); drop(jtx); - run(&jrx, &shared, Duration::from_secs(60)); + run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu); let first = rx.recv().unwrap(); assert!(first.result.unwrap_err().contains("failed unexpectedly")); let second = rx.recv().unwrap(); @@ -447,6 +569,72 @@ mod tests { assert!(!shared.encoding.load(Ordering::SeqCst)); } + /// Run `kinds` in turn on a worker that starts on `placement`; their outcomes. + fn run_kinds(placement: Placement, kinds: Vec) -> Vec { + let (tx, rx) = mpsc::channel(); + let (jtx, jrx) = mpsc::channel(); + let shared = Shared::default(); + shared.pending.store(kinds.len(), Ordering::SeqCst); + for kind in kinds { + let mut j = job(Tag::Wait, &tx); + j.kind = kind; + jtx.send(j).unwrap(); + } + drop(jtx); + run(&jrx, &shared, Duration::from_secs(60), placement); + assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset"); + assert!(!shared.encoding.load(Ordering::SeqCst)); + rx.try_iter().collect() + } + + #[test] + fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() { + // issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build + let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]); + assert_eq!(out.len(), 2); + assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err()); + assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED)); + assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU"); + assert_eq!(out[1].notice, None, "said once"); + } + + #[test] + fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() { + let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]); + assert!(matches!(out[0].result, Ok(None))); + assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED)); + } + + #[test] + fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() { + let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]); + assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly")); + assert_eq!(out[0].notice, None); + // not the GPU's fault: the next job tries the GPU first again + assert!(matches!(out[1].result, Ok(None))); + assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED)); + } + + #[test] + fn a_worker_on_the_cpu_never_tries_the_gpu() { + // no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off + let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]); + assert!(matches!(out[0].result, Ok(None))); + assert_eq!(out[0].notice, None); + if !lightcraft_segment::GPU_BUILD { + assert_eq!(Placement::initial(), Placement::Cpu); + } + } + + #[test] + fn missing_model_files_are_not_retried_on_the_cpu() { + let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]); + assert!(out[0].result.as_ref().unwrap_err().contains("not found")); + assert_eq!(out[0].notice, None); + let missing = lightcraft_segment::Error::Missing(PathBuf::from("models")); + assert!(matches!(Failure::from(missing), Failure::Other(_))); + } + #[test] fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() { let (tx, rx) = mpsc::channel(); @@ -464,7 +652,7 @@ mod tests { drop(jtx); let shared = Shared::default(); shared.pending.store(3, Ordering::SeqCst); - run(&jrx, &shared, Duration::from_secs(60)); + run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu); let out: Vec = rx.try_iter().collect(); assert_eq!(out.len(), 3); assert!(out[0].superseded && out[1].superseded && !out[2].superseded); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index cdee1e894..d13b95125 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -17,15 +17,20 @@ //! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`. //! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then //! the first by file name — [`Session::sidecar_naming`]); the others use `.xmp` -//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. +//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks +//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF +//! arriving beside its raw's `IMG_0001.xmp` reads its own `.xmp`, never the raw's packet +//! (issue #1905). //! //! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except //! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, //! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. +//! Manual reads also accept the first complete standard XMP APP1 in a bounded JPEG header (see `read_jpeg_xmp`). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -71,6 +76,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { sidecar_candidates(original, naming).find(|p| p.is_file()) } +/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out +/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not +/// own its stem sidecar reads its own `.xmp` (the name it writes) and never the packet of +/// the sibling that owns the stem one. +pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option { + if stems.owns(original) { + return find_sidecar(original, naming); + } + let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem)); + sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file()) +} + /// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, /// then under the other naming (the full name also with the photo extension in the other case), /// each as `.xmp`, `.XMP` and `.Xmp`. @@ -89,7 +106,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl } v }) - .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) + .flat_map(xmp_cases) +} + +/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`. +fn xmp_cases(p: PathBuf) -> [PathBuf; 3] { + [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")] } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -500,18 +522,77 @@ fn file_path(p: &Photo) -> Option<&str> { } } -/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP. +/// The XMP packet that describes a photo on disk: its sidecar, else embedded raw/DNG or JPEG XMP. pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> { - if let Some(sc) = find_sidecar(original, naming) - && let Ok(s) = std::fs::read_to_string(&sc) - { - return Some((s, sc)); + if let Some(sc) = find_sidecar(original, naming) { + // A discovered sidecar keeps precedence even when reading it fails. + return std::fs::read_to_string(&sc).ok().map(|s| (s, sc)); } if kind == MediaKind::Raw { let bytes = std::fs::read(original).ok()?; let x = lightcraft_meta::embedded(&bytes).xmp?; return Some((x, PathBuf::from(original))); } + if kind == MediaKind::Image { + let mut file = std::fs::File::open(original).ok()?; + let x = read_jpeg_xmp(&mut file, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS)?; + return Some((x, PathBuf::from(original))); + } + None +} + +const JPEG_XMP_HEADER_SPAN: u64 = 16 * 1024 * 1024; +const JPEG_XMP_HEADER_STEPS: usize = 4096; + +/// Read from SOI at the current position, stopping at the first standard XMP packet or SOS/EOI. +/// The span includes SOI, segment headers and skipped payloads; each marker/fill step after SOI +/// consumes one iteration. Only a complete APP1 payload (at most 65533 bytes) reaches the shared +/// parser, in a single SOI+APP1 wrapper (at most 65539 bytes). This does not validate the whole JPEG. +fn read_jpeg_xmp(reader: &mut (impl Read + Seek), header_span: u64, marker_steps: usize) -> Option { + let mut header = reader.take(header_span); + let mut soi = [0; 2]; + header.read_exact(&mut soi).ok()?; + if soi != [0xff, 0xd8] { + return None; + } + let mut filled = false; + for _ in 0..marker_steps { + if !filled { + let mut prefix = [0]; + header.read_exact(&mut prefix).ok()?; + if prefix != [0xff] { + return None; + } + } + let mut code = [0]; + header.read_exact(&mut code).ok()?; + let [marker] = code; + filled = marker == 0xff; + match marker { + 0xff | 0xd8 | 0xd0..=0xd7 | 0x01 => continue, + 0xd9 | 0xda => return None, + _ => {} + } + let mut length = [0; 2]; + header.read_exact(&mut length).ok()?; + let payload_len = u16::from_be_bytes(length).checked_sub(2)?; + let remaining = header.limit().checked_sub(u64::from(payload_len))?; + if marker != 0xe1 { + // Seeking must spend the same span budget as reading, before the seek is attempted. + header.set_limit(remaining); + header.get_mut().seek(SeekFrom::Current(i64::from(payload_len))).ok()?; + continue; + } + let segment_len = 6usize.checked_add(usize::from(payload_len))?; + let [hi, lo] = length; + let mut segment = Vec::with_capacity(segment_len); + segment.extend_from_slice(&[0xff, 0xd8, 0xff, 0xe1, hi, lo]); + segment.resize(segment_len, 0); + header.read_exact(segment.get_mut(6..)?).ok()?; + if let Some(xmp) = lightcraft_meta::jpeg_segments(&segment).xmp { + return Some(xmp); + } + } None } @@ -528,6 +609,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) { } } +/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file +/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks +/// last among files of its kind and name: see [`ImportStems`]). +type Rank = (bool, String, u64); + +/// The rank of the file at `path`, of kind `kind`, catalogued as `id`. +fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank { + (kind != MediaKind::Raw, path.to_lowercase(), id) +} + /// Which photo owns each stem sidecar that several catalogued files map to (see /// [`Session::sidecar_naming`]). pub(crate) struct StemOwners(HashMap); @@ -538,10 +629,10 @@ impl StemOwners { /// owners were worked out before, for every write). #[cfg(test)] pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners { - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -566,7 +657,7 @@ impl StemOwners { /// The owners of the shared stems in `wanted`. fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet) -> StemOwners { // per stem: (raw first, then file name, then id) of the best candidate; and how many - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); let mut key = String::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; @@ -574,7 +665,7 @@ impl StemOwners { if !wanted.contains(key.as_str()) { continue; } - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -593,6 +684,66 @@ impl StemOwners { } } +/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of +/// their stem, and which read their own `.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905: +/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating, +/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with +/// the arriving files ranked against the library and against each other: a raw first, then the +/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when +/// the import commits is above every id in the library, so it loses a tie against a catalogued +/// file; between arriving files the order they arrive in stands for it. +pub(crate) struct ImportStems { + /// The arriving files that own the stem sidecar of their stem, by path as it arrives. + owners: HashSet, +} + +impl ImportStems { + /// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a + /// library already looked over (a job prepared twice): there is nothing to rank against, so + /// every arriving file reads as it did before. The library is passed over once, and only the + /// stems the arriving files map to are looked at closer. + pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator) -> ImportStems { + // per stem: the best of the library's files, and the best arriving file with its path + let mut best: HashMap, Option<(Rank, &'a str)>)> = HashMap::new(); + let mut arriving_paths = HashSet::new(); + for (arriving, (path, kind)) in incoming.enumerate() { + arriving_paths.insert(path); + let e = best.entry(stem_key(path)).or_default(); + let rank = rank_of(path, kind, arriving as u64); + if e.1.as_ref().is_none_or(|(b, _)| rank < *b) { + e.1 = Some((rank, path)); + } + } + if let Some(cat) = cat { + let mut key = String::new(); + for p in cat.photos().filter(|p| p.copy_of.is_none()) { + // the arriving file's own record (a trashed photo imported again) is not a rival + let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue }; + stem_key_into(&mut key, path); + let Some(e) = best.get_mut(key.as_str()) else { continue }; + let rank = rank_of(path, p.kind, p.id.0); + if e.0.as_ref().is_none_or(|b| rank < *b) { + e.0 = Some(rank); + } + } + } + let owners = best + .into_values() + .filter_map(|(in_library, arriving)| { + let (rank, path) = arriving?; + let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str())); + wins.then(|| path.to_string()) + }) + .collect(); + ImportStems { owners } + } + + /// Whether the arriving file at `original` owns the stem sidecar of its stem. + pub(crate) fn owns(&self, original: &str) -> bool { + self.owners.contains(original) + } +} + /// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming` /// ([`Session::sidecar_namings`], for a thread that has the library and not the session). pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec { @@ -929,3 +1080,220 @@ mod tests_repair_provenance; #[cfg(test)] #[path = "sidecar/tests_stem_owners.rs"] mod tests_stem_owners; + +#[cfg(test)] +mod jpeg_header_tests { + use super::{JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS, read_jpeg_xmp}; + use std::io::{self, Cursor, Read, Seek, SeekFrom}; + + struct CountedReader { + cursor: Cursor>, + bytes_read: usize, + largest_read: usize, + seeks: usize, + chunk: usize, + read_error_at: Option, + seek_error: bool, + } + + impl CountedReader { + fn new(bytes: Vec) -> Self { + Self { cursor: Cursor::new(bytes), bytes_read: 0, largest_read: 0, seeks: 0, chunk: usize::MAX, read_error_at: None, seek_error: false } + } + } + + impl Read for CountedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.largest_read = self.largest_read.max(buf.len()); + if self.read_error_at.is_some_and(|at| self.cursor.position() >= at) { + return Err(io::Error::other("injected header read failure")); + } + let len = buf.len().min(self.chunk); + let read = self.cursor.read(&mut buf[..len])?; + self.bytes_read += read; + Ok(read) + } + } + + impl Seek for CountedReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + self.seeks += 1; + if self.seek_error { + return Err(io::Error::other("injected header seek failure")); + } + self.cursor.seek(pos) + } + } + + fn segment(marker: u8, payload: &[u8]) -> Vec { + let len = u16::try_from(payload.len() + 2).unwrap(); + let mut bytes = vec![0xff, marker]; + bytes.extend_from_slice(&len.to_be_bytes()); + bytes.extend_from_slice(payload); + bytes + } + + fn packet(rating: i8) -> String { + lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), ..Default::default() }, None) + } + + fn xmp_segment(packet: &str) -> Vec { + let mut payload = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); + payload.extend_from_slice(packet.as_bytes()); + segment(0xe1, &payload) + } + + fn jpeg(packet: &str) -> Vec { + let mut bytes = vec![0xff, 0xd8]; + bytes.extend(xmp_segment(packet)); + bytes + } + + #[test] + fn first_standard_packet_skips_other_segments_and_stops_before_tail() { + let first = packet(2); + let first_payload_len = xmp_segment(&first).len() - 4; + let mut bytes = vec![0xff, 0xd8]; + bytes.extend(segment(0xe0, &[7; 17])); + bytes.extend(segment(0xe1, b"Exif\0\0tiny")); + let mut extended = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + extended.extend_from_slice(&[0; 40]); + extended.extend_from_slice(b"not a standard packet"); + bytes.extend(segment(0xe1, &extended)); + bytes.extend(xmp_segment(&first)); + let end = bytes.len(); + bytes.extend(xmp_segment(&packet(5))); + bytes.extend_from_slice(&[0xff, 0xda, 0, 2]); + bytes.extend_from_slice(&[3; 128]); + let mut reader = CountedReader::new(bytes); + reader.read_error_at = Some(end as u64); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), Some(first)); + assert_eq!(reader.cursor.position(), end as u64, "later packets and image bytes are not read"); + assert_eq!(reader.bytes_read, end - 17, "APP0 payload is skipped, not buffered"); + assert_eq!(reader.seeks, 1); + assert_eq!(reader.largest_read, first_payload_len, "reads at most one complete APP1 payload at a time"); + } + + #[test] + fn stops_at_sos_or_eoi_without_reading_later_xmp() { + for stop in [0xda, 0xd9] { + let mut bytes = vec![0xff, 0xd8, 0xff, stop]; + bytes.extend(xmp_segment(&packet(3))); + let mut reader = CountedReader::new(bytes); + reader.read_error_at = Some(4); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None); + assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (4, 4, 0)); + } + } + + #[test] + fn rejects_incomplete_app1_even_when_partial_xmp_parses() { + let expected = packet(4); + let mut bytes = jpeg(&expected); + let declared = u16::from_be_bytes([bytes[4], bytes[5]]) + 7; + bytes[4..6].copy_from_slice(&declared.to_be_bytes()); + let partial = lightcraft_meta::jpeg_segments(&bytes).xmp.unwrap(); + assert_eq!(partial, expected, "the shared extractor tolerates a truncated declared payload"); + assert_eq!(super::parse_sidecar(&partial, crate::crs::Target::Rendered).unwrap().rating, Some(4)); + let len = bytes.len(); + let mut reader = CountedReader::new(bytes); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None); + assert_eq!(reader.bytes_read, len); + } + + #[test] + fn span_budget_counts_soi_headers_and_skipped_payloads() { + let expected = packet(3); + let mut bytes = vec![0xff, 0xd8]; + bytes.extend(segment(0xe0, &[9; 23])); + bytes.extend(xmp_segment(&expected)); + let end = bytes.len(); + let mut exact = CountedReader::new(bytes.clone()); + assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected)); + assert_eq!(exact.cursor.position(), end as u64, "a complete early APP1 does not require EOI"); + assert_eq!(exact.bytes_read, end - 23); + let mut short = CountedReader::new(bytes); + assert_eq!(read_jpeg_xmp(&mut short, (end - 1) as u64, JPEG_XMP_HEADER_STEPS), None); + assert_eq!((short.bytes_read, short.cursor.position(), short.seeks), (10, 33, 1), "reject before APP1 payload read"); + assert_eq!(short.largest_read, 2); + } + + #[test] + fn span_budget_rejects_skip_before_seeking() { + let mut bytes = vec![0xff, 0xd8]; + bytes.extend(segment(0xe0, &[8; 18])); + bytes.extend(xmp_segment(&packet(1))); + let mut reader = CountedReader::new(bytes); + assert_eq!(read_jpeg_xmp(&mut reader, 6, JPEG_XMP_HEADER_STEPS), None); + assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (6, 6, 0)); + } + + #[test] + fn marker_budget_counts_fill_and_standalone_markers() { + let expected = packet(1); + // Two fill steps, SOI, TEM, RST3, then APP1: six steps after the initial SOI. + let mut bytes = vec![0xff, 0xd8, 0xff, 0xff, 0xff, 0xd8, 0xff, 0x01, 0xff, 0xd3]; + bytes.extend(xmp_segment(&expected)); + for (steps, end) in [(0, 2), (5, 10)] { + let mut reader = CountedReader::new(bytes.clone()); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, steps), None); + assert_eq!((reader.bytes_read, reader.cursor.position()), (end, end as u64)); + } + let mut reader = CountedReader::new(bytes); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, 6), Some(expected)); + } + + #[test] + fn rejects_invalid_headers_and_segment_lengths() { + for bytes in [ + vec![], + vec![0xff], + b"\x89PNG\r\n\x1a\n".to_vec(), + vec![0xff, 0xd8, 0, 0xe1, 0, 2], + vec![0xff, 0xd8, 0xff, 0xe1, 0], + vec![0xff, 0xd8, 0xff, 0xe1, 0, 0], + vec![0xff, 0xd8, 0xff, 0xe1, 0, 1], + vec![0xff, 0xd8, 0xff, 0xe1, 0, 3], + ] { + let len = bytes.len(); + let mut reader = CountedReader::new(bytes); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None); + assert!(reader.bytes_read <= len); + } + } + + #[test] + fn propagates_read_and_seek_failures() { + for at in [0, 6] { + let mut reader = CountedReader::new(jpeg(&packet(4))); + reader.read_error_at = Some(at); + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None); + assert_eq!(reader.bytes_read as u64, at); + } + let mut bytes = vec![0xff, 0xd8]; + bytes.extend(segment(0xe0, &[1])); + bytes.extend(xmp_segment(&packet(4))); + let mut reader = CountedReader::new(bytes); + reader.seek_error = true; + assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None); + assert_eq!((reader.bytes_read, reader.seeks), (6, 1)); + } + + #[test] + fn handles_short_reads_without_bypassing_span_limit() { + let expected = packet(2); + let bytes = jpeg(&expected); + let end = bytes.len(); + let mut exact = CountedReader::new(bytes.clone()); + exact.chunk = 1; + assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected)); + assert_eq!(exact.bytes_read, end); + for (span, read) in [(0, 0), (1, 1), (end - 1, 6)] { + let mut reader = CountedReader::new(bytes.clone()); + reader.chunk = 1; + assert_eq!(read_jpeg_xmp(&mut reader, span as u64, JPEG_XMP_HEADER_STEPS), None); + assert_eq!(reader.bytes_read, read); + assert!(reader.cursor.position() <= span as u64); + } + } +} diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs index 43170f4f6..0d86fe92d 100644 --- a/crates/engine/src/sidecar/tests_stem_owners.rs +++ b/crates/engine/src/sidecar/tests_stem_owners.rs @@ -1,4 +1,5 @@ -//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`). +//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and +//! for the files an import is adding (`ImportStems`). //! //! A library can hold millions of photos; a sidecar that is written for one of them, or a folder //! that is synchronized, must not cost a table of them all. @@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [ "/pics/dots/a.jpg", ]; +/// The kind a file of this name is catalogued as: the `.cr3` ones are raws. +fn kind_of(path: &str) -> MediaKind { + if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image } +} + fn library() -> (Catalog, Vec) { let mut c = Catalog::new(); let mut ids = Vec::new(); @@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec) { let id = c.alloc_photo_id(); let name = path.rsplit('/').next().unwrap_or(path); let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01"); - if path.to_lowercase().ends_with("cr3") { - p.kind = MediaKind::Raw; - } + p.kind = kind_of(path); c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); ids.push(id); } @@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() { assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]); } +/// Given a library where files share stems in every way a name can, when files arrive in an +/// import, then each of them may read the shared stem sidecar exactly when it would own it once +/// it is catalogued: the arriving files are ranked against the library's files and against each +/// other, as the write rule ranks them (issue #1905). +#[test] +fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() { + let (c, _) = library(); + // the arrivals: the same names under an upper-case folder, so each shares its stem with the + // library's files (the key lower-cases the path) without being one of them + let incoming: Vec<(String, MediaKind)> = PATHS + .iter() + .map(|p| { + let (dir, name) = p.rsplit_once('/').unwrap_or(("", p)); + (format!("{}/{name}", dir.to_uppercase()), kind_of(p)) + }) + .collect(); + // the library with these arrivals in it, the ids drawn in the order they arrive + let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog { + let mut cat = base.clone(); + for &k in add { + let (path, kind) = &incoming[k]; + let id = cat.alloc_photo_id(); + let name = path.rsplit('/').next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01"); + p.kind = *kind; + cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + cat + }; + let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool { + let path = &incoming[k].0; + let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap(); + StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem + }; + let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() }; + let all: Vec = (0..incoming.len()).collect(); + for i in 0..incoming.len() { + let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter()); + assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0); + for j in 0..incoming.len() { + // every pair arriving together + if i == j { + continue; + } + let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter()); + let cat = arriving_in(&c, &[i, j]); + for k in [i, j] { + assert_eq!( + pair.owns(&incoming[k].0), + owns_once_catalogued(&cat, k), + "{} with {}", + incoming[k].0, + incoming[if k == i { j } else { i }].0 + ); + } + } + } + // a job prepared twice (a library already looked over) has nothing to rank against: every + // one of them is ranked among the arrivals alone, as it would be in a library of just them + let alone = ImportStems::for_import(None, as_arriving(&all).into_iter()); + let only_arrivals = arriving_in(&Catalog::new(), &all); + for k in &all { + assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0); + } +} + /// The key written into a buffer (no allocation for a plain path) is the key: for every path /// above, for shapes a path from a file can have, and for every string of up to six of the /// characters that decide a key (a letter in each case, a dot, both separators, a letter that is diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 46b18e0c9..9f72e0f48 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -3,15 +3,19 @@ //! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into //! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header //! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied -//! at render time and is not baked into the pixels. +//! at render time and is not baked into the pixels, and the file's other decoder facts (its colour +//! model, gain table map and semantic mattes) ride in a tail after the JPEG, so offline edits +//! interpret the stored pixels the same way the online source did. use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::Photo; use lightcraft_color::transfer::linear_to_srgb; +use lightcraft_pipeline::SourceInfo; +use lightcraft_pipeline::masks::{MatteKind, Mattes}; use lightcraft_pipeline::tone::CameraTone; -use lightcraft_raster::{Rgb32f, Rgba8}; +use lightcraft_raster::{Image, Rgb32f, Rgba8}; const MAGIC: &[u8] = b"LCSP1\n"; @@ -70,15 +74,73 @@ const JPEG_QUALITY: u8 = 97; /// tracks the camera-look fit that is baked into those pixels. const ENC_FIELD: &str = "enc"; -/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG -/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which -/// makes every proxy from before this version stale. Version 2 is the first to record the field at -/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]). +/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping, JPEG +/// settings or stored facts change, so proxies an older build wrote are rebuilt: a missing field +/// reads as 0, which makes every proxy from before this version stale. Version 2 is the first to +/// record the field at all (a version 1 proxy is stored at JPEG quality 92, which this build +/// raises to [`JPEG_QUALITY`]); version 3 is the first to store the decoder facts in the tail. A +/// stale proxy whose original is offline is kept as it is (see [`is_stale`]). /// Crate-visible so the look-marker test pins the marker format to it instead of to a literal. -pub(crate) const ENCODE_VERSION: u64 = 2; +pub(crate) const ENCODE_VERSION: u64 = 3; -/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. -pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { +/// The decoder facts a render interprets its settings against, stored with the proxy's pixels so +/// an offline original edits like it did online (the catalog's header facts cannot carry them). +/// In the proxy's tail, not its header: a real file's gain table map runs far past any header +/// budget (up to 12 336 gains). +#[derive(serde::Serialize, serde::Deserialize)] +struct SmartFacts { + raw: bool, + as_shot_temp: f64, + as_shot_tint: f64, + relative_wb: bool, + legacy_relative_wb: bool, + lens: Option, + camera_color: Option, + local_tone: Option, + /// The semantic mattes whose bytes follow the facts, in this order. + #[serde(default)] + mattes: Vec, +} + +/// One stored matte's shape; its `len` bytes of 0..255 alpha follow the facts. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredMatte { + kind: u8, + w: usize, + h: usize, + len: usize, +} + +/// The facts and mattes a proxy's tail holds: the length-prefixed JSON, then one byte plane per +/// stored matte. `None` for an old proxy or a malformed tail; like a missing curve, the offline +/// render then keeps its header facts. +fn stored_facts(head: &serde_json::Value, bytes: &[u8]) -> Option<(SmartFacts, Mattes)> { + let tail = usize::try_from(head.get("tail")?.as_u64()?).ok()?; + if tail == 0 || tail > bytes.len() { + return None; + } + let t = bytes.get(bytes.len() - tail..)?; + let n = u32::from_le_bytes(t.get(..4)?.try_into().ok()?) as usize; + let facts: SmartFacts = serde_json::from_slice(t.get(4..4 + n)?).ok()?; + let mut mattes = Mattes::default(); + let mut at = 4 + n; + for m in &facts.mattes { + let kind = MatteKind::from_code(m.kind)?; + if m.w == 0 || m.h == 0 || m.w.checked_mul(m.h)? != m.len { + return None; + } + mattes.push(kind, Image { width: m.w, height: m.h, data: t.get(at..at.checked_add(m.len)?)?.to_vec() }); + at += m.len; + } + if at != t.len() { + return None; + } + Some((facts, mattes)) +} + +/// Encode a source image and its decoder facts (the camera tone curve and the [`SourceInfo`] the +/// render interprets settings against, if given) as a smart preview. +pub fn encode(img: &Rgb32f, info: Option<&SourceInfo>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); let scale = if lum.is_empty() { @@ -113,28 +175,80 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String LOOK_FIELD: crate::camera_preview::LOOK_VERSION, ENC_FIELD: ENCODE_VERSION, }); - if let Some(t) = tone { - head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + // The tail: the decoder facts (and the file's semantic mattes) after the JPEG, so the header + // stays small. `tail` records its byte count from the end of the file, so a header rewritten + // in place (an edited look version) keeps pointing at it. + let mut tail: Vec = Vec::new(); + if let Some(i) = info { + if let Some(t) = &i.camera_tone { + head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + } + let matte_list: Vec = i + .mattes + .as_deref() + .map(|m| m.iter().map(|(kind, a)| StoredMatte { kind: kind.code(), w: a.width, h: a.height, len: a.data.len() }).collect()) + .unwrap_or_default(); + let facts = SmartFacts { + raw: i.raw, + as_shot_temp: i.as_shot_temp, + as_shot_tint: i.as_shot_tint, + relative_wb: i.relative_wb, + legacy_relative_wb: i.legacy_relative_wb, + lens: i.lens, + camera_color: i.camera_color.as_deref().cloned(), + local_tone: i.local_tone.as_deref().cloned(), + mattes: matte_list, + }; + let facts = serde_json::to_vec(&facts).map_err(|e| e.to_string())?; + let n = u32::try_from(facts.len()).map_err(|_| "smart preview facts too large")?; + tail.extend_from_slice(&n.to_le_bytes()); + tail.extend_from_slice(&facts); + if let Some(m) = i.mattes.as_deref() { + for (_, a) in m.iter() { + tail.extend_from_slice(&a.data); + } + } + head["tail"] = serde_json::json!(tail.len()); } out.extend_from_slice(head.to_string().as_bytes()); out.push(b'\n'); out.extend_from_slice(&jpg); + out.extend_from_slice(&tail); Ok(out) } -/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid -/// curve is ignored, like a missing one). -pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option), String> { +/// Decode a smart preview back into a source image, its stored camera tone curve and the stored +/// decoder facts (an invalid curve or tail is ignored, like a missing one). +pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option, Option), String> { let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?; let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?; let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?; let scale = head["scale"].as_f64().unwrap_or(1.0) as f32; let tone = head.get("tone").and_then(|t| serde_json::from_value::(t.clone()).ok()); - let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?; + let tail = head.get("tail").and_then(serde_json::Value::as_u64).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + let start = MAGIC.len() + nl + 1; + let end = bytes.len().saturating_sub(tail); + if end <= start { + return Err("bad smart preview".into()); + } + let d = lightcraft_codecs::decode(bytes.get(start..end).ok_or("bad smart preview")?, Default::default()).map_err(|e| e.to_string())?; // the decoder undoes the sRGB encoding; the values are the source's own primaries let mut img = d.image; img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale)); - Ok((img, tone)) + let info = stored_facts(&head, bytes).map(|(f, mattes)| SourceInfo { + raw: f.raw, + as_shot_temp: f.as_shot_temp, + as_shot_tint: f.as_shot_tint, + relative_wb: f.relative_wb, + legacy_relative_wb: f.legacy_relative_wb, + lens: f.lens, + camera_color: f.camera_color.map(Arc::new), + camera_tone: tone, + mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)), + local_tone: f.local_tone.map(Arc::new), + native_long: None, + }); + Ok((img, tone, info)) } /// Where a library keeps its smart previews. @@ -276,15 +390,26 @@ pub fn is_valid(path: &Path) -> bool { } let Some(rest) = head.strip_prefix(MAGIC) else { return false }; let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; - let size_ok = rest - .get(..nl) - .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); + let json = rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()); + let size_ok = json.as_ref().is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) { return false; } + let tail = json.and_then(|h| h.get("tail").and_then(serde_json::Value::as_u64)).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + if tail == 0 { + // no tail: the file ends at the JPEG's end marker + let mut end = [0u8; 2]; + return f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]; + } + // with a tail, the JPEG must end right before it and the tail must be there in full + let Ok(meta) = f.metadata() else { return false }; + let Ok(file_len) = usize::try_from(meta.len()) else { return false }; + let Some(jpeg_end) = file_len.checked_sub(tail) else { return false }; + if jpeg_end <= MAGIC.len() + nl + 3 { + return false; + } let mut end = [0u8; 2]; - f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] + f.seek(SeekFrom::Start((jpeg_end - 2) as u64)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] } /// The fit version a header records: a missing, negative, fractional or otherwise malformed value @@ -299,12 +424,15 @@ fn header_encode_version(head: &serde_json::Value) -> u64 { head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` is out of date for this build: written by an older -/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are -/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG -/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that -/// build's work), as is one that can't be read: those are [`is_valid`]'s business. -pub fn is_stale(path: &Path) -> bool { +/// Whether the complete proxy at `path` is out of date for a photo and must be rebuilt: written by +/// an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG quality) — this +/// reaches every proxy — or, when the photo takes the per-file camera look (`look`, +/// [`lightcraft_catalog::Photo::fitted_look`]), by an older camera-look fit than this build's (so +/// its stored curve, and the colour baked into its pixels, are out of date; the fit only reaches +/// those raws, so a JPEG's or DNG's proxy is never stale for it). A proxy from a newer build is +/// left alone (rebuilding it here would only undo that build's work), as is one that can't be +/// read: those are [`is_valid`]'s business. +pub fn is_stale_for(path: &Path, look: bool) -> bool { use std::io::Read; if !is_valid(path) { return false; @@ -316,9 +444,14 @@ pub fn is_stale(path: &Path) -> bool { } let Some(rest) = head.strip_prefix(MAGIC) else { return false }; let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; - rest.get(..nl) - .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) + rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()).is_some_and(|h| { + header_encode_version(&h) < ENCODE_VERSION || (look && header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) + }) +} + +/// [`is_stale_for`] for a photo that takes the per-file camera look: both conditions apply. +pub fn is_stale(path: &Path) -> bool { + is_stale_for(path, true) } /// The marker file in a smart previews folder: the camera-look fit version and encode version its @@ -356,7 +489,14 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource { + image: Arc::new(image), + info, + camera_tone, + denoised: None, + from_proxy: true, + reduced: false, + }) } #[cfg(test)] @@ -453,7 +593,8 @@ mod tests { })) .unwrap(); let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, Some(&tone)).unwrap(); + let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; + let bytes = encode(&img, Some(&info)).unwrap(); assert_eq!(decode(&bytes).unwrap().1, Some(tone)); let dir = temp("tone"); let path = dir.join("t.lcsp"); @@ -473,15 +614,50 @@ mod tests { let mut hostile = MAGIC.to_vec(); hostile.extend_from_slice(head.to_string().as_bytes()); hostile.extend_from_slice(&bytes[nl..]); - let (pixels, tone) = decode(&hostile).unwrap(); + let (pixels, tone, _) = decode(&hostile).unwrap(); assert_eq!((pixels.width, tone), (img.width, None)); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn facts_and_mattes_travel_with_the_proxy() { + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); + let info = SourceInfo { + raw: true, + as_shot_temp: 4200.0, + as_shot_tint: -5.0, + legacy_relative_wb: true, + mattes: Some(std::sync::Arc::new(mattes)), + ..Default::default() + }; + let bytes = encode(&img, Some(&info)).unwrap(); + let (_, _, back) = decode(&bytes).unwrap(); + let back = back.expect("the facts are read back"); + assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); + assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); + // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) + let dir = temp("facts"); + let path = dir.join("f.lcsp"); + std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); + assert!(!is_valid(&path)); + let _ = std::fs::remove_dir_all(&dir); + // an old proxy (no tail) has no facts, and neither has one with a malformed tail + assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + head["tail"] = serde_json::json!(bytes.len()); + let mut lying = MAGIC.to_vec(); + lying.extend_from_slice(head.to_string().as_bytes()); + lying.extend_from_slice(&bytes[nl..]); + assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap(); + let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); assert_eq!((back.width, back.height), (img.width, img.height)); assert!(tone.is_none()); let mut err = 0.0f64; diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 6f236796e..713c697a7 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -34,8 +34,8 @@ //! - a sidecar is found as Read Metadata from File finds it, including the other naming //! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`). //! -//! The walk for new files stops at the import's limits on depth and entries (logged), and is -//! not cancelled midway. +//! The walk for new files stops at the import's limits on depth and entries (logged), or when the +//! scan is cancelled. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -52,13 +52,19 @@ use crate::{EngineError, Result, Session}; /// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped. const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; -/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the -/// whole scan) and the library's photos checked. +/// How far a scan is: the folders and files listed so far (the walk, which comes first and on a +/// network share can take minutes), the folder's files probed ([`ScanProgress`], whose `cancel` +/// stops the whole scan, walk included) and the library's photos checked. #[derive(Default)] pub struct SyncProgress { pub files: ScanProgress, pub checked: std::sync::atomic::AtomicUsize, pub to_check: std::sync::atomic::AtomicUsize, + /// Folders and files (photos or not) the walk has listed. + pub folders_listed: std::sync::atomic::AtomicUsize, + pub files_listed: std::sync::atomic::AtomicUsize, + /// The walk is over (or there was nothing to walk). + pub listed: std::sync::atomic::AtomicBool, } impl SyncProgress { @@ -68,6 +74,15 @@ impl SyncProgress { let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed)); (done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed))) } + + /// While a scan lists the folder (before any file can be checked): the folders and files + /// listed so far. `None` once the listing is over. + pub fn listing(&self) -> Option<(usize, usize)> { + use std::sync::atomic::Ordering::{Acquire, Relaxed}; + // (Acquire pairs with the Release store of `listed`: once the listing is seen to be + // over, so is what was stored before it, and the counts that follow are the final ones) + (!self.listed.load(Acquire)).then(|| (self.folders_listed.load(Relaxed), self.files_listed.load(Relaxed))) + } } /// What a scan of a folder needs, taken from the session so it can run on a worker thread. @@ -196,6 +211,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { let SyncInput { folder, scan, ids, naming, labels } = input; let seen: Vec> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect(); if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { + progress.listed.store(true, std::sync::atomic::Ordering::Release); return FolderChanges { path: folder, seen, offline: true, ..Default::default() }; } // (how each photo's sidecar is named: a pass over the library, made here and not while the @@ -207,15 +223,30 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { // network share that is one request for a folder's names where it was several for each // photo. Then both halves wait on the disk at once: the new files are probed (on their own // threads) beside the checks of the photos, which ask the disk only what the listing can't say. - // (how many photos there are to check is known before the walk: the count shows while it runs) + // (how many photos there are to check is known before the walk; while the walk runs, the + // folders and files it listed tell how far it is) progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed); let mut listing = Listing::default(); let (files, _) = crate::import::expand_noting( std::slice::from_ref(&folder), scan.skip(), crate::walk::Limits::default(), - Some(&mut |dir, names, complete| listing.note(dir, names, complete)), + Some(&mut |dir, names, complete| { + use std::sync::atomic::Ordering::Relaxed; + progress.folders_listed.fetch_add(1, Relaxed); + let files = names.iter().filter(|(_, kind)| *kind == crate::walk::EntryKind::File).count(); + progress.files_listed.fetch_add(files, Relaxed); + listing.note(dir, names, complete); + // ✕ stops the walk too (a cancelled scan's result is not used) + !progress.files.cancel.load(Relaxed) + }), ); + progress.listed.store(true, std::sync::atomic::Ordering::Release); + // ✕ during the listing: its result is not used, so nothing more is asked of the disk (the files' cached probes + // would each be checked against the file, a round trip per file on a network share) + if progress.files.cancel.load(std::sync::atomic::Ordering::Relaxed) { + return FolderChanges { path: folder, seen, ..Default::default() }; + } let listing = &listing; // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/sync/tests_listing.rs b/crates/engine/src/sync/tests_listing.rs index d4664dd15..4348ca830 100644 --- a/crates/engine/src/sync/tests_listing.rs +++ b/crates/engine/src/sync/tests_listing.rs @@ -146,8 +146,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() { .collect(); // (`dir.jpg` is gone, though a sidecar of its name is there) let mut l = Listing::default(); - let walked = - crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete)); + let walked = crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| { + l.note(d, names, complete); + true + }); assert_eq!(walked.files.len(), 12); let nothing = Listing::default(); for naming in [SidecarNaming::Stem, SidecarNaming::Full] { @@ -167,7 +169,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() { l.asked.load(std::sync::atomic::Ordering::Relaxed) }; let mut with = Listing::default(); - crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete)); + crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| { + with.note(d, names, complete); + true + }); let (with, without) = (asked(&with), asked(&Listing::default())); // (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where // names are found in any case, 21 where they are not) @@ -181,7 +186,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() { // a folder of plain names only: the disk is asked nothing at all let sub = dir.join("sub"); let mut only_plain = Listing::default(); - crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete)); + crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| { + only_plain.note(d, names, complete); + true + }); let mut c2 = Catalog::new(); for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] { let p = photo(&mut c2, &sub.join(name)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 400135ce0..1e8f4b7e3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -519,7 +519,7 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() { assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0); } -/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's +/// A crop of no size (or a hair wide), as a sidecar can set it, made the render's /// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a /// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear /// neighbour past isize::MAX). Such a photo renders, quickly. @@ -533,7 +533,7 @@ fn degenerate_crops_render_with_spots_and_masks() { s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap(); s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap(); - s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap(); + set_crop_unchecked(&mut s, rect, 30.0); let t = std::time::Instant::now(); let r = s.render_now(id, 700, 450).unwrap(); assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}"); @@ -541,13 +541,23 @@ fn degenerate_crops_render_with_spots_and_masks() { } } +/// Store a crop as a sidecar can, without `crop.set`'s minimum size, kept inside the turned photo. +fn set_crop_unchecked(s: &mut Session, [x0, y0, x1, y1]: [f64; 4], angle: f64) { + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + let mut d = active_dev(s); + d.crop.geometry = lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(x0, y0, x1, y1), angle }.constrained(w, h); + s.set_develop(id, d, "Crop").unwrap(); +} + /// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop /// rectangle; it now falls back to the image's own aspect, a finite crop inside the image. #[test] fn rotate_aspect_of_degenerate_crop_stays_finite() { for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] { let mut s = demo(); - s.execute("crop.set", &json!({"rect": rect})).unwrap(); + set_crop_unchecked(&mut s, rect, 0.0); s.execute("crop.rotateAspect", &json!({})).unwrap(); let r = active_dev(&s).crop.geometry.rect; assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}"); @@ -556,17 +566,36 @@ fn rotate_aspect_of_degenerate_crop_stays_finite() { } } -/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. +/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. Their +/// maths overflow, so they fall back to a full-size crop and pass the minimum size. #[test] fn crop_set_huge_rect_stays_finite() { for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] { let mut s = demo(); - let _ = s.execute("crop.set", &json!({"rect": rect})); + s.execute("crop.set", &json!({"rect": rect})).unwrap(); let r = active_dev(&s).crop.geometry.rect; assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}"); } } +/// `crop.set` took a crop of no size (or a sliver), and the next export wrote a 1×1 image (#1952). +/// It now refuses a crop smaller than a drag of the crop tool can make, and keeps the one it had. +/// A rect far wider than the photo is a sliver too once it is shrunk to fit inside it. +#[test] +fn crop_set_refuses_a_crop_smaller_than_the_tool_allows() { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9]})).unwrap(); + let before = active_dev(&s).crop.geometry; + for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.4, 0.8, 0.4], [0.0, 0.0, 1e-9, 1.0], [0.0, 0.0, 1e6, 1.0], [0.0, 0.0, 1.0, 1e6]] + { + let e = s.execute("crop.set", &json!({"rect": rect})).unwrap_err(); + assert!(e.to_string().contains("smaller"), "{rect:?}: {e}"); + assert_eq!(active_dev(&s).crop.geometry, before, "{rect:?}"); + } + s.execute("crop.set", &json!({"rect": [0.45, 0.45, 0.55, 0.55]})).unwrap(); + assert_eq!(active_dev(&s).crop.geometry.rect, lightcraft_geom::Rect::new(0.45, 0.45, 0.55, 0.55)); +} + /// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow /// checks are on); extreme deltas and indices now move the mask to the bottom or the top. #[test] @@ -917,6 +946,119 @@ fn auto_sync_carries_only_the_changed_settings() { assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7); } +#[test] +fn auto_sync_camera_local_tone_off_preserves_other_settings() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + for (id, (profile, amount, contrast)) in ids.iter().zip([("lc.color", 100, 0), ("lc.vivid", 37, 25), ("lc.mono", 63, -20)]) { + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let settings = json!({"profile": {"id": profile, "amount": amount}, "light": {"contrast": contrast}}); + s.execute("develop.merge", &json!({"settings": settings})).unwrap(); + } + let initial: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect(); + assert!(initial.iter().all(|d| !d.profile.camera_local_tone)); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let on = json!({"settings": {"profile": {"camera_local_tone": true}}, "label": "Camera Local Tone Mapping"}); + let off = json!({"settings": {"profile": {"camera_local_tone": false}}, "label": "Camera Local Tone Mapping"}); + + // Positive control: On reaches both selected photos, changing only this setting. + s.execute("develop.merge", &on).unwrap(); + let mut synced = initial.clone(); + for d in synced.iter_mut().take(2) { + d.profile.camera_local_tone = true; + } + for (id, expected) in ids.iter().zip(&synced) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "On: photo {id}"); + } + + let undo_before = s.undo.len(); + let history_before: Vec<_> = ids.iter().map(|id| s.catalog.photo(PhotoId(*id)).unwrap().history.len()).collect(); + s.execute("develop.merge", &off).unwrap(); + assert!(!s.develop_of(PhotoId(ids[0])).unwrap().profile.camera_local_tone); + assert!(!s.develop_of(PhotoId(ids[1])).unwrap().profile.camera_local_tone, "Auto Sync must carry camera local tone Off"); + assert_eq!(s.undo.len(), undo_before + 1, "Off is one undoable edit"); + for (i, (id, expected)) in ids.iter().zip(&initial).enumerate() { + let photo = s.catalog.photo(PhotoId(*id)).unwrap(); + assert_eq!(photo.develop.as_ref(), expected, "Off: photo {id}"); + assert_eq!(photo.history.len(), history_before[i] + usize::from(i < 2), "Off history: photo {id}"); + let stored = photo.develop.to_json(); + assert!(stored["profile"].get("camera_local_tone").is_none(), "false stays omitted: photo {id}"); + assert_eq!(&lightcraft_develop::DevelopSettings::from_json(&stored).unwrap(), expected); + } + + // One Undo/Redo restores the complete settings of both selected photos, not the third. + s.execute("edit.undo", &json!({})).unwrap(); + for (id, expected) in ids.iter().zip(&synced) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Undo: photo {id}"); + } + s.execute("edit.redo", &json!({})).unwrap(); + for (id, expected) in ids.iter().zip(&initial) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Redo: photo {id}"); + } +} + +#[test] +fn auto_sync_camera_local_tone_changes_only_when_edited() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); + let settings = json!({"profile": {"id": "lc.vivid", "amount": 37, "camera_local_tone": true}, "light": {"contrast": 25}}); + s.execute("develop.merge", &json!({"settings": settings})).unwrap(); + let mut expected: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect(); + assert!(!expected[0].profile.camera_local_tone); + assert!(expected[1].profile.camera_local_tone); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + + // An unrelated edit must not copy the active photo's unchanged false over the peer's true. + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap(); + for d in expected.iter_mut().take(2) { + d.light.exposure = 0.7; + } + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unrelated edit: photo {id}"); + } + + let peer_history = s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(); + s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": false}}})).unwrap(); + assert_eq!(s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(), peer_history, "unchanged toggle adds no peer history"); + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unchanged toggle: photo {id}"); + } + + // Direct unchanged edits may still add an active undo step. With Auto Sync off, real + // Boolean changes affect only the active photo; selected and unselected peers stay exact. + s.execute("develop.autoSync", &json!({"on": false})).unwrap(); + for on in [true, false] { + s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": on}}})).unwrap(); + expected[0].profile.camera_local_tone = on; + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Auto Sync disabled: photo {id}"); + } + } +} + +#[test] +fn auto_sync_hdr_off_returns_selected_photos_to_sdr() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + assert_eq!(ids.len(), 2); + for id in &ids { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().hdr, lightcraft_develop::Hdr::default(), "default-HDR fixture"); + } + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let enabled = |s: &Session| ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().hdr.enabled).collect::>(); + s.execute("develop.hdr", &json!({"enabled": true})).unwrap(); + assert_eq!(enabled(&s), vec![true, true], "HDR On must reach both selected photos"); + s.execute("develop.hdr", &json!({"enabled": false})).unwrap(); + assert_eq!(enabled(&s), vec![false, false], "HDR Off must reach both selected photos"); +} + #[test] fn json_delta_keeps_only_changes() { let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1}); @@ -1682,3 +1824,46 @@ fn malformed_photo_targets_are_refused_not_widened() { let preset = s.presets.first().unwrap().id.clone(); s.execute("preset.apply", &json!({"id": preset})).unwrap(); } + +#[test] +fn auto_sync_hdr_enabled_preserves_per_photo_tuning() { + use lightcraft_develop::{DevelopSettings, Hdr, ProcessVersion}; + let mut s = demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + assert_eq!(ids.len(), 2); + s.execute("develop.autoSync", &json!({"on": false})).unwrap(); + // Keep distinct stored processes explicit: the default is now V2; no rendering claim is made. + let mut expected = [DevelopSettings::default(), DevelopSettings { process: ProcessVersion(2), ..DevelopSettings::default() }]; + expected[0].process = ProcessVersion(1); + expected[0].light.exposure = 0.5; + expected[0].light.contrast = 12.0; + expected[1].light.exposure = -0.25; + expected[1].light.contrast = -22.0; + expected[1].hdr = Hdr { + enabled: false, + max_ev: 4.0, + sdr_brightness: 11.0, + sdr_contrast: -12.0, + sdr_highlights: 13.0, + sdr_shadows: -14.0, + sdr_whites: 15.0, + sdr_clarity: -16.0, + }; + for (id, settings) in ids.iter().zip(&expected) { + s.set_develop(*id, settings.clone(), "HDR tuning fixture").unwrap(); + } + s.execute("library.select", &json!({"ids": ids.iter().map(|id| id.0).collect::>(), "active": ids[0].0})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let selection = s.selection.clone(); + for enabled in [true, false] { + s.execute("develop.hdr", &json!({"enabled": enabled})).unwrap(); + for (id, settings) in ids.iter().zip(&mut expected) { + settings.hdr.enabled = enabled; + let actual = s.develop_of(*id).unwrap(); + assert_eq!(actual.hdr, settings.hdr, "HDR tuning must survive enabled={enabled} for photo {id:?}"); + assert_eq!(actual.as_ref(), &*settings, "other per-photo edits and process must survive enabled={enabled}"); + } + assert_eq!(s.selection, selection, "selection and active photo must survive enabled={enabled}"); + assert!(s.auto_sync, "Auto Sync must remain enabled"); + } +} diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs new file mode 100644 index 000000000..9f01ed05d --- /dev/null +++ b/crates/engine/src/tests_constrain_crop.rs @@ -0,0 +1,147 @@ +//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the +//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so +//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop. + +use lightcraft_geom::{CropGeometry, Rect}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::json; + +use crate::Session; + +fn crop(s: &Session) -> CropGeometry { + s.develop_of(s.active().unwrap()).unwrap().crop.geometry +} + +/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)? +fn fits(s: &Session) -> bool { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let mut d = (*p.develop).clone(); + d.geometry.constrain_crop = true; + let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref()); + let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect); + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) +} + +fn smaller(a: Rect, b: Rect) -> bool { + a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3 +} + +fn constrain(s: &mut Session, on: bool) { + s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap(); +} + +fn vertical(s: &mut Session, v: f64) { + s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap(); +} + +#[test] +fn a_geometry_slider_tightens_the_crop_while_constrained() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + constrain(&mut s, true); + assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off"); + vertical(&mut s, -60.0); + assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect); + assert!(fits(&s), "and holds no blank corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider"); +} + +#[test] +fn ticking_constrain_after_the_warp_tightens_the_crop() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + vertical(&mut s, -60.0); + assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners"); + assert!(!fits(&s)); + constrain(&mut s, true); + assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect); + // unticking keeps the tighter crop, as in Lightroom + let tight = crop(&s).rect; + constrain(&mut s, false); + assert_eq!(crop(&s).rect, tight); +} + +#[test] +fn a_crop_drawn_by_hand_is_kept_as_drawn() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted"); +} + +/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's +/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop. +#[test] +fn auto_sync_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + // B: a small crop off-centre; both constrained + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + // A active, both selected, Auto Sync on: a Geometry edit on A + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + vertical(&mut s, -60.0); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!(db.geometry.vertical, -60.0, "the edit reached B"); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}"); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + assert!(fits(&s), "and fits B's warp"); +} + +/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the +/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B. +#[test] +fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap(); + for v in [-20.0, -40.0, -60.0] { + vertical(&mut s, v); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0)); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}"); + for photo in [a, b] { + s.selection = crate::Selection { ids: vec![photo], active: Some(photo) }; + assert!(fits(&s), "each crop fits its photo's warp"); + } +} + +/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning +/// another section's eye off leaves a crop drawn by hand as it is. +#[test] +fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change"); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap(); + assert!(fits(&s), "the Geometry eye is one: the crop fits again"); +} diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs new file mode 100644 index 000000000..560290b7f --- /dev/null +++ b/crates/engine/src/tests_crop_aspect.rs @@ -0,0 +1,86 @@ +//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5", +//! "original") takes the crop's present orientation, landscape or portrait after X, as in +//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop. + +use serde_json::{Value, json}; + +use crate::Session; + +/// The demo photo, landscape, with its crop's width and height in pixels. +fn crop_px(s: &Session) -> (f64, f64) { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = p.develop.crop.geometry.rect_px(w, h); + (r.width(), r.height()) +} + +fn landscape_photo() -> Session { + let mut s = Session::with_demo(); + let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap(); + s.selection = crate::Selection { ids: vec![id], active: Some(id) }; + s +} + +fn aspect(s: &mut Session, p: Value) { + s.execute("crop.aspect", &p).unwrap(); +} + +fn ratio(s: &Session) -> f64 { + let (w, h) = crop_px(s); + w / h +} + +#[test] +fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": "2x3"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s)); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s)); + // another named ratio after X stays portrait (it used to turn back to the image's landscape) + aspect(&mut s, json!({"aspect": "4x5"})); + assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": "16x9"})); + assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s)); +} + +#[test] +fn a_custom_ratio_is_used_as_given() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3]})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": [3, 2]})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); +} + +/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a +/// landscape photo it lands landscape, whatever photo it was chosen on. +#[test] +fn orientation_overrides_and_is_checked() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); + aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s)); + let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{e}"); +} + +/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked +/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes. +#[test] +fn a_bad_orientation_is_refused_before_anything_is_locked() { + let mut s = landscape_photo(); + let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop; + for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] { + for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] { + let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}"); + assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched"); + } + } + // the valid words still lock as is + aspect(&mut s, json!({"aspect": "current", "orientation": "crop"})); + assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some()); +} diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index cc9ad2eed..bf0ae3a71 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -1,7 +1,7 @@ //! AI denoise through the engine: installing a model, making a photo's denoised picture in the background and for an //! export, the Amount slider mixing it in, and the cache. A tiny stand-in model (it halves every sample) takes the //! place of a real one, which is enough to see what the machinery does with its answer; how well real models denoise is -//! measured separately (`crates/denoise/tests/real_model.rs`). +//! measured separately (`crates/denoise/tests/it/real_model.rs`). use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 0ac8df804..4830490b5 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() { let _ = std::fs::remove_dir_all(&dir); } +/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is +/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth +/// gets its own, as stored settings do, while a depth the call itself passes is still refused. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + use crate::export::ExportPreset; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) }); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16))); + assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG"); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap(); + assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg")); + // such a preset can be saved: its depth is judged when it exports; with a format, at once + s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap(); + assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err()); +} + +/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A +/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does, +/// instead of refusing the save; without `format`, the depth waits for the format written. +#[test] +fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() { + let mut s = Session::with_demo(); + s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Old"})).unwrap(); + assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80})); + s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap(); + let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16)); +} + #[test] fn presets_conflicts_and_subfolders() { use crate::export::{Destination, export_batch}; @@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() { let exists = |p: &str| std::path::Path::new(p).exists(); let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); - let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()]; + // `export` joins the subfolder and name with `/` (`join_path`), on Windows too. + let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())]; let mut got: Vec = written.iter().map(|p| p.to_string()).collect(); want.sort(); got.sort(); diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests_forget_local.rs index 229b262a0..9f5d1f572 100644 --- a/crates/engine/src/tests_forget_local.rs +++ b/crates/engine/src/tests_forget_local.rs @@ -259,3 +259,58 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() { assert_eq!(s.catalog.to_snapshot(), expect); let _ = std::fs::remove_dir_all(&root); } + +/// Opens `lib` with a probe that counts the originals it checks. +fn open_counting(lib: &Path, clock: &Arc>) -> (Session, Arc) { + let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut s = Session::new().with_fs(); + let c = clock.clone(); + s.clock = Box::new(move || c.lock().unwrap().clone()); + let n = checks.clone(); + s.media.availability.set_probe(Arc::new(move |p| { + n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Path::new(p).exists() + })); + s.open_library(lib, false).unwrap(); + (s, checks) +} + +/// A library last shown on Missing Photos opens without checking its originals: that view holds +/// no Local records, so forgetting them needn't work it out, and no photo is picked from it (each +/// check can wait for a sleeping NAS; a big library on one froze the window for minutes). +#[test] +fn opening_on_missing_photos_checks_no_originals() { + let root = temp_dir("missing"); + let (a, lib) = (root.join("a"), root.join("lib")); + for i in 0..5 { + png(&a.join(format!("m{i}.png")), i); + } + let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string())); + let mut s = open(&lib, &clock); + s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap(); + s.execute("library.source", &json!({"kind": "missing"})).unwrap(); + // every original is there: the view is empty and nothing is selected + assert!(s.visible().is_empty()); + assert!(s.selection.active.is_none()); + s.close_library().unwrap(); + drop(s); + let (s, checks) = open_counting(&lib, &clock); + assert_eq!(s.source, LibrarySource::Missing, "the view is restored"); + assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening an empty Missing view checked originals"); + assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran"); + drop(s); + + // the originals go: the view lists them, one is active + std::fs::remove_dir_all(&a).unwrap(); + let mut s = open(&lib, &clock); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.execute("library.source", &json!({"kind": "missing"})).unwrap(); + assert_eq!(s.visible().len(), 5, "all five are missing"); + assert!(s.selection.active.is_some()); + s.close_library().unwrap(); + drop(s); + let (s, checks) = open_counting(&lib, &clock); + assert_eq!(s.source, LibrarySource::Missing); + assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening a full Missing view checked originals"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs index ffe300795..2c22c00e3 100644 --- a/crates/engine/src/tests_history_cursor.rs +++ b/crates/engine/src/tests_history_cursor.rs @@ -115,3 +115,95 @@ fn negative_indices_are_refused() { assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err()); assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed"); } + +#[test] +fn fractional_version_delete_preserves_the_version() { + let mut s = Session::with_demo(); + s.execute("version.create", &json!({"name": "A"})).unwrap(); + let before = s.catalog.to_snapshot(); + let result = s.execute("version.delete", &json!({"index": 0.9})); + assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before); + assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged"); +} + +/// Invalid command indices must leave objects, settings, History and undo unchanged. +#[test] +fn invalid_command_indices_leave_the_catalog_and_history_unchanged() { + let (mut s, _) = three_edits(); + for name in ["A", "B"] { + s.execute("version.create", &json!({"name": name})).unwrap(); + } + s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + for x in [0.3, 0.6] { + s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap(); + } + let before = s.catalog.to_snapshot(); + let history = s.execute("history.list", &json!({})).unwrap(); + let undo_len = s.undo.len(); + // The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX. + let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap(); + for command in [ + "version.delete", + "version.rename", + "version.update", + "version.restore", + "pointColor.delete", + "redeye.delete", + "redeye.catchlight", + "history.restore", + ] { + for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] { + let result = s.execute(command, &json!({"index": index, "name": "Changed"})); + assert!(result.is_err(), "{command} accepted {index}: {result:?}"); + assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}"); + assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}"); + assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}"); + } + assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index"); + assert_eq!(s.catalog.to_snapshot(), before); + } +} + +/// Whole floating-point JSON numbers remain valid for existing control and MCP callers. +#[test] +fn whole_command_indices_accept_integer_and_float_numbers() { + let (mut s, n) = three_edits(); + let id = s.active().unwrap(); + s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); + s.execute("history.restore", &json!({"index": n - 1})).unwrap(); + assert_eq!(exposure(&s), 3.0); + for name in ["A", "B", "C"] { + s.execute("version.create", &json!({"name": name})).unwrap(); + } + s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third"); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap(); + s.execute("version.update", &json!({"index": 1.0})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0); + s.execute("version.restore", &json!({"index": 0.0})).unwrap(); + assert_eq!(exposure(&s), 3.0); + s.execute("version.delete", &json!({"index": 1})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::>(), ["A", "Third"]); + s.execute("version.delete", &json!({"index": 1.0})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1); + s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().point_colors.is_empty()); + s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap(); + s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some()); + s.execute("redeye.delete", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().red_eye.is_empty()); +} + +#[test] +fn fractional_history_restore_preserves_the_cursor() { + let (mut s, _) = three_edits(); + let before = s.execute("history.list", &json!({})).unwrap(); + let result = s.execute("history.restore", &json!({"index": 1.9})); + let after = s.execute("history.list", &json!({})).unwrap(); + assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after); + assert_eq!(after, before); + assert_eq!(exposure(&s), 3.0); +} diff --git a/crates/engine/src/tests_ids_params.rs b/crates/engine/src/tests_ids_params.rs new file mode 100644 index 000000000..8ce772ded --- /dev/null +++ b/crates/engine/src/tests_ids_params.rs @@ -0,0 +1,205 @@ +//! `ids` that aren't photo ids are an error, never "the selection": a command that fell back to the +//! selection on `{"ids": 3}` acted on photos the call didn't name. + +use lightcraft_catalog::{AlbumId, PhotoId}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A demo session with the first two photos selected, the third active elsewhere in the grid, and +/// an album holding the selection. +fn session() -> (Session, Vec, AlbumId) { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [vis[0].0, vis[1].0], "active": vis[0].0})).unwrap(); + let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap(); + let album = AlbumId(r["id"].as_u64().unwrap()); + (s, vis.to_vec(), album) +} + +const BAD_IDS: [fn() -> Value; 5] = [|| json!(3), || json!("3"), || json!(["3"]), || json!([3, -1]), || json!({"id": 3})]; + +#[test] +fn album_commands_refuse_ids_that_are_not_photo_ids() { + let (mut s, vis, album) = session(); + let members = |s: &Session| s.catalog.album(album).unwrap().photos.clone(); + let before = members(&s); + assert_eq!(before.len(), 2); + for bad in BAD_IDS.map(|f| f()) { + for cmd in ["album.removePhotos", "album.addPhotos"] { + let e = s.execute(cmd, &json!({"id": album.0, "ids": bad})).unwrap_err(); + assert!(e.to_string().contains("ids"), "{cmd} {bad}: {e}"); + assert_eq!(members(&s), before, "{cmd} {bad} changed the album"); + } + assert!(s.execute("album.toggleTarget", &json!({"ids": bad})).is_err(), "album.toggleTarget {bad}"); + } + // well-formed ids still work, and no ids still means the selection + s.execute("album.removePhotos", &json!({"id": album.0, "ids": [vis[1].0]})).unwrap(); + assert_eq!(members(&s), vec![vis[0]]); + s.execute("album.addPhotos", &json!({"id": album.0, "ids": null})).unwrap(); + assert_eq!(members(&s).len(), 2); +} + +#[test] +fn develop_set_refuses_ids_that_are_not_photo_ids() { + let (mut s, vis, _) = session(); + let exposure = |s: &Session, id: PhotoId| s.develop_of(id).unwrap().light.exposure; + let before: Vec = vis.iter().map(|id| exposure(&s, *id)).collect(); + for bad in BAD_IDS.map(|f| f()) { + let e = s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": bad})).unwrap_err(); + assert!(e.to_string().contains("ids"), "{bad}: {e}"); + assert_eq!(vis.iter().map(|id| exposure(&s, *id)).collect::>(), before, "{bad} edited a photo"); + } + s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": [vis[2].0]})).unwrap(); + assert_eq!((exposure(&s, vis[0]), exposure(&s, vis[2])), (before[0], 2.5)); +} + +#[test] +fn library_select_refuses_ids_that_are_not_photo_ids() { + let (mut s, vis, _) = session(); + let before = s.selection.ids.clone(); + for bad in BAD_IDS.map(|f| f()) { + assert!(s.execute("library.select", &json!({"ids": bad})).is_err(), "{bad}"); + assert_eq!(s.selection.ids, before, "{bad} changed the selection"); + } + s.execute("library.select", &json!({"ids": [vis[2].0]})).unwrap(); + assert_eq!(s.selection.ids, vec![vis[2]]); +} + +/// The same for a single `id`: a value that isn't a photo id used to mean the active photo +/// (`photo.relink` then relinked the active photo to the file, `photo.removeRegion` removed its face +/// box), or, for `denoise.cancel`, every photo. (The stack commands share the helper; `in_stack` +/// keeps them disabled on a photo outside a stack, so they aren't called here.) +#[test] +fn single_photo_commands_refuse_an_id_that_is_not_a_photo_id() { + let (mut s, vis, _) = session(); + let active = s.active().unwrap(); + let mut meta = s.catalog.photo(active).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.3, y1: 0.3 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + s.commit("setup", lightcraft_catalog::Op::SetMeta { id: active, meta: Box::new(meta) }).unwrap(); + let source = |s: &Session| format!("{:?}", s.catalog.photo(active).unwrap().source); + let before = source(&s); + let commits = s.commits(); + for bad in [json!("5"), json!(-1), json!(1.5), json!([vis[2].0]), json!({"id": 1})] { + for (cmd, mut params) in [ + ("photo.relink", json!({"path": "/elsewhere/other.jpg"})), + ("photo.removeRegion", json!({"index": 0})), + ("photo.setRegion", json!({"index": 0, "rect": {"x0": 0.2, "y0": 0.2, "x1": 0.4, "y1": 0.4}})), + ("faces.setName", json!({"index": 0, "name": "Ann"})), + ("library.findSimilar", json!({})), + ("denoise.cancel", json!({})), + ("photo.inspect", json!({})), + ] { + params["id"] = bad.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err(); + assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}"); + } + } + assert_eq!(s.commits(), commits, "nothing was changed"); + assert_eq!(source(&s), before); + assert_eq!(s.catalog.photo(active).unwrap().meta.regions.len(), 1); +} + +/// Mask ids and spot indexes the same way: `mask.delete {"id": "1"}` deleted the selected mask, and an +/// id past `u32` wrapped (`4294967297 as u32` is 1) onto another mask. +#[test] +fn mask_and_spot_commands_refuse_ids_that_are_not_ids() { + let (mut s, _, _) = session(); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("spot.add", &json!({"mode": "heal", "points": [[0.4, 0.5]], "size": 0.02, "source": [0.1, 0.0]})).unwrap(); + let state = |s: &Session| { + let d = s.develop_of(s.active().unwrap()).unwrap(); + (d.masks.iter().map(|m| (m.id, m.name.clone(), m.visible)).collect::>(), d.spots.len()) + }; + let before = state(&s); + assert_eq!((before.0.len(), before.1), (2, 1)); + let first = before.0[0].0; + let commits = s.commits(); + for bad in [json!(u64::from(u32::MAX) + 1 + u64::from(first)), json!("1"), json!(-1), json!(1.5), json!([first])] { + for (cmd, params) in [ + ("mask.delete", json!({})), + ("mask.rename", json!({"name": "renamed"})), + ("mask.visible", json!({"visible": false})), + ("mask.select", json!({})), + ] { + let mut params = params; + params["id"] = bad.clone(); + assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {bad}"); + } + for cmd in ["spot.delete", "spot.select"] { + assert!(s.execute(cmd, &json!({"index": bad})).is_err(), "{cmd} {bad}"); + } + } + assert_eq!(s.commits(), commits, "nothing was changed"); + assert_eq!(state(&s), before); + // well-formed ids and a null still work + s.execute("mask.delete", &json!({"id": first})).unwrap(); + s.execute("spot.select", &json!({"index": null})).unwrap(); + assert_eq!(state(&s).0.len(), 1); +} + +/// `photo.label` without a label, or with one that isn't text, cleared the colour label of every +/// selected photo (a missing `label` read as `none`); now it is an error and only `none` or null clear. +#[test] +fn photo_label_refuses_a_missing_or_non_text_label() { + let (mut s, vis, _) = session(); + s.execute("photo.label", &json!({"label": "red"})).unwrap(); + let labels = |s: &Session| vis.iter().take(2).map(|id| s.catalog.photo(*id).unwrap().label).collect::>(); + let before = labels(&s); + assert!(before.iter().all(Option::is_some)); + for params in [json!({}), json!({"label": 3}), json!({"label": true}), json!({"label": ["red"]}), json!({"label": "crimson"})] { + assert!(s.execute("photo.label", ¶ms).is_err(), "{params}"); + assert_eq!(labels(&s), before, "{params} changed the labels"); + } + s.execute("photo.label", &json!({"label": null})).unwrap(); + assert!(labels(&s).iter().all(Option::is_none)); + s.execute("photo.label", &json!({"label": "green"})).unwrap(); + s.execute("photo.label", &json!({"label": "none"})).unwrap(); + assert!(labels(&s).iter().all(Option::is_none)); +} + +/// The mask-effect commands and a mask's `component` the same way: `mask.saveEffect {"id": "2"}` +/// saved the selected mask's sliders, and `mask.update {"component": "1"}` reshaped component 0. +#[test] +fn mask_effects_and_components_refuse_values_that_are_not_ids() { + let (mut s, _, _) = session(); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Kept"})).unwrap(); + let state = |s: &Session| (format!("{:?}", s.develop_of(s.active().unwrap()).unwrap().masks), s.mask_effects.len()); + let before = state(&s); + let commits = s.commits(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let shape = serde_json::to_value(&d.masks.last().unwrap().components[0].shape).unwrap(); + for bad in [json!("1"), json!(-1), json!(1.5), json!([1]), json!(u64::from(u32::MAX) + 2)] { + for (cmd, params) in [ + ("mask.applyEffect", json!({"name": "Kept"})), + ("mask.saveEffect", json!({"name": "New"})), + ("mask.updateEffect", json!({"name": "Kept"})), + ] { + let mut params = params; + params["id"] = bad.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err(); + assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}"); + } + if bad.is_u64() { + continue; // a well-formed index past the end is "no such component", not a type error + } + for (cmd, params) in + [("mask.update", json!({"shape": shape})), ("mask.component", json!({"action": "invert"})), ("mask.refineDetail", json!({}))] + { + let mut params = params; + params["component"] = bad.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err(); + assert!(e.to_string().contains("`component`"), "{cmd} {bad}: {e}"); + } + } + assert_eq!(s.commits(), commits, "nothing was changed"); + assert_eq!(state(&s), before); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 13f02ce77..191595ebe 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -30,6 +30,25 @@ fn ids(v: &Value, key: &str) -> usize { v[key].as_array().map(Vec::len).unwrap_or(0) } +#[test] +fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() { + let src = temp_dir("sony-discovery"); + std::fs::create_dir_all(src.join("trip/day2")).unwrap(); + let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"]; + let mut expected: Vec = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect(); + for path in &expected { + std::fs::write(path, b"discovery only, not a decoded photo").unwrap(); + } + std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap(); + + let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None); + found.sort(); + expected.sort(); + assert_eq!(found, expected); + assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree"); + std::fs::remove_dir_all(src).unwrap(); +} + #[test] fn recursive_import_with_duplicates() { let src = temp_dir("src"); @@ -479,6 +498,129 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534: the decoder facts a render interprets its settings against (a camera colour model, +/// the file's local tone map, its semantic mattes) must be stored with the smart preview, so an +/// edit made while the original is offline renders the same picture as the online source instead +/// of the header-facts fallback. +#[test] +fn smart_previews_keep_the_decoder_facts_offline_edits_need() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, WbMode}; + use lightcraft_pipeline::SourceInfo; + use lightcraft_pipeline::masks::{MatteKind, Mattes}; + use lightcraft_raster::Rgb32f; + + // A smooth scene: the proxy's 8-bit sRGB round trip stays within a code step, so a mismatch + // left between the offline and the online render is metadata the proxy failed to keep. + let pixels = Rgb32f::from_fn(96, 64, |x, y| { + let t = ((x * 7 + y * 3) % 96) as f32 / 96.0; + [0.02 + t * 1.2, 0.02 + t * 0.6, 0.02 + t * 0.25] + }); + let camera_color = lightcraft_pipeline::CameraColor { + tags: lightcraft_raw::ColorData { + illuminant: [21, 17], + color_matrix: [Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + baseline_exposure: 0.3, + ..Default::default() + }, + developed_for: lightcraft_color::Xy { x: 0.32, y: 0.35 }, + }; + let mut mattes = Mattes::default(); + mattes.push( + MatteKind::Sky, + lightcraft_raster::Image { width: 96, height: 64, data: (0..96 * 64usize).map(|i| if (i % 96) / 24 == 1 { 255 } else { 0 }).collect() }, + ); + let local_tone = lightcraft_pipeline::local_tone::LocalTone { + map: lightcraft_raw::gaintable::GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }, + placement: lightcraft_raw::gaintable::SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal }, + }; + // the facts differ per case; the rest is the raw source's usual shape + let info = |camera_color: Option, + local_tone: Option, + mattes: Option| SourceInfo { + raw: true, + as_shot_temp: 5200.0, + as_shot_tint: 8.0, + camera_color: camera_color.map(std::sync::Arc::new), + local_tone: local_tone.map(std::sync::Arc::new), + mattes: mattes.map(std::sync::Arc::new), + ..Default::default() + }; + + /// Build the proxy of `pixels`+`info`, apply `edit`, then render the proxy's own pixels and + /// stored facts online, and the same proxy offline (the original renamed away): how many + /// pixels differ. Identical stored pixels and identical edits must render identically; what + /// the proxy failed to keep shows up as a difference. + fn offline_diff(tag: &str, pixels: &Rgb32f, info: &SourceInfo, edit: DevelopSettings) -> usize { + let src = temp_dir(&format!("smartmeta-src-{tag}")); + let lib = temp_dir(&format!("smartmeta-lib-{tag}")); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + s.set_develop(id, edit, "reproduction").unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let stored = crate::smart::load(&crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap())).unwrap(); + let mut online = s.render_job(id, 96, 96, false, true).unwrap(); + // the proxy's own (post-JPEG) pixels, with the facts the original source had: the offline + // render must reach the same picture through the facts the proxy stored + online.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(stored.image, Some(info.clone())))); + let online = online.run().rendered.unwrap().image; + // the drive goes away + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.rendered.clear(); + s.media.clear_sources(); + s.media.forget(id); + let offline = s.render_now(id, 96, 96).expect("renders from the smart preview").image; + assert_eq!(online.data.len(), offline.data.len(), "{tag}: same grid"); + let differing = online.data.iter().zip(&offline.data).filter(|(a, b)| a != b).count(); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + let _ = std::fs::remove_dir_all(&lib); + differing + } + + // a Kelvin white balance is evaluated through the camera's colour model + let mut wb = DevelopSettings::default(); + wb.wb.mode = WbMode::Custom; + wb.wb.temp = 3000.0; + wb.wb.tint = 20.0; + let differing = offline_diff("wb", &pixels, &info(Some(camera_color), None, None), wb); + assert_eq!(differing, 0, "the camera colour model is kept offline"); + + // an AI mask uses the mattes that came with the file + let mut sky = DevelopSettings::default(); + sky.masks.push(Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: 2.0, ..Default::default() }, + ..Default::default() + }); + let differing = offline_diff("sky", &pixels, &info(None, None, Some(mattes)), sky); + assert_eq!(differing, 0, "the file's mattes are kept offline"); + + // Camera local tone mapping reads the file's gain table map + let mut tone = DevelopSettings::default(); + tone.profile.camera_local_tone = true; + tone.light.exposure = -3.0; + let differing = offline_diff("tone", &pixels, &info(None, Some(local_tone), None), tone); + assert_eq!(differing, 0, "the local tone map is kept offline"); +} + /// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed /// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not /// reported as present and is rebuilt. @@ -609,6 +751,34 @@ fn cube_luts_become_profiles() { let _ = std::fs::remove_dir_all(&lib); } +/// Without a library on disk an imported LUT lives only in this session: no copy of it is +/// left in the temp folder (#1952). +#[test] +fn cube_import_without_a_library_leaves_no_temp_copy() { + let src = temp_dir("cubemem"); + let mut cube = String::from("TITLE \"Temp Copy Check\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::write(src.join("mem.cube"), &cube).unwrap(); + let leak = std::env::temp_dir().join("lightcraft-imported-temp-copy-check.cube"); + let _ = std::fs::remove_file(&leak); + let mut s = Session::new().with_fs(); + let r = s.execute("profile.import", &json!({"paths": [src.join("mem.cube").to_string_lossy()]})).unwrap(); + let id = r["imported"][0]["id"].as_str().unwrap().to_string(); + assert!(lightcraft_pipeline::lut::get(&id).is_some(), "usable in this session"); + let file = s.lut_profiles.iter().find(|p| p.id == id).unwrap().file.clone(); + assert!(file.is_empty(), "no file kept without a library: {file}"); + drop(s); + assert!(!leak.exists(), "{} left behind", leak.display()); + lightcraft_pipeline::lut::unregister(&id); + let _ = std::fs::remove_dir_all(&src); +} + /// Folder rename / move on disk: files and sidecars go along, the photos are relinked and /// still render; clashes and moving into itself are refused. #[test] @@ -778,6 +948,18 @@ fn assisted_culling_groups_bursts_and_scores_focus() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: a photo listed twice is analysed once and is not a burst with itself. +#[test] +fn assisted_culling_ignores_repeated_ids() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + let r = s.execute("photo.analyze", &json!({"ids": [id.0, id.0]})).unwrap(); + assert_eq!(r["groups"], 0, "a photo listed twice is not a burst of two: {r}"); + assert_eq!(r["photos"].as_array().map(Vec::len), Some(1), "{r}"); + let an = s.catalog.photo(id).unwrap().analysis.unwrap(); + assert!(an.group.is_none() && !an.best, "{an:?}"); +} + /// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of /// every month, including those with fewer than 31 days. #[test] @@ -1132,6 +1314,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() { let _ = std::fs::remove_dir_all(&src); } +/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a +/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it. +#[test] +fn a_fresh_reimport_reads_its_own_stem_sidecar() { + let (mut s, src, id) = trashed_photo("trash-fresh-sidecar"); + std::fs::write( + src.join("a.xmp"), + r#" +"#, + ) + .unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + let new = r["imported"][0].as_u64().unwrap(); + assert_ne!(new, id); + assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}"); + let _ = std::fs::remove_dir_all(&src); +} + #[test] fn unknown_on_deleted_is_an_error() { let mut s = Session::new().with_fs(); @@ -1392,9 +1593,15 @@ fn look_loader( /// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version. #[test] fn smart_previews_from_an_older_look_fit_are_refreshed() { + for format in ["ARW", "DNG"] { + smart_look_refresh_for(format); + } +} + +fn smart_look_refresh_for(format: &str) { use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir("smartlook-src"); - let lib = temp_dir("smartlook-lib"); + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); write_png(&src.join("a.png"), 3); write_png(&src.join("b.png"), 9); let mut s = Session::new().with_fs(); @@ -1404,11 +1611,12 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = "ARW".into(); + raw.format = format.into(); raw.kind = lightcraft_catalog::MediaKind::Raw; s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); + assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!s.catalog.photo(png).unwrap().relative_wb()); s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); let dir = s.media.smart_dir.clone().unwrap(); let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); @@ -1496,6 +1704,99 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file +/// camera look. A DNG keeps an absolute white balance, so it never takes that look; at head its +/// existing proxy was kept at an older encoding even with its original online, so the decoder +/// facts the newer encoding carries never reached it through a normal rebuild. +#[test] +fn a_proxy_with_an_older_encoding_is_refreshed_without_the_camera_look() { + let src = temp_dir("smartenc-src"); + let lib = temp_dir("smartenc-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the test loader decodes the catalogue photo's "DNG" pixels + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "DNG".into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert!(s.catalog.photo(id).unwrap().develops_raw() && !s.catalog.photo(id).unwrap().fitted_look(), "a DNG does not take the camera look"); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION)); + + // an existing proxy from the previous encoding, its original online: the migration must reach + // it through a normal rebuild even though the photo never takes the camera look + edit_smart_header(&file, |h| h["enc"] = json!(crate::smart::ENCODE_VERSION - 1)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the older encoding is stale even without the camera look"); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION), "migrated to the current encoding"); + assert!(!crate::smart::is_stale(&file)); + assert_ne!(std::fs::read(&file).unwrap(), stale_bytes, "the proxy was rebuilt"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + +/// Issue #1534 (review): a cached proxy source is not the original. After an offline edit rendered +/// from a stale proxy, Build Smart Previews read that cached source back and refreshed the proxy +/// from itself, stamping its older look as current. A stale refresh reads the original only, so an +/// offline original leaves the stored look — and the bytes — as they are. +#[test] +fn a_stale_refresh_never_rebuilds_a_proxy_from_itself() { + let src = temp_dir("smartself-src"); + let lib = temp_dir("smartself-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + // a raw that takes the per-file camera look, so an older fit makes its proxy stale + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "ARW".into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert!(s.catalog.photo(id).unwrap().fitted_look()); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + edit_smart_header(&file, |h| h["look_version"] = json!(0)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the stored look is behind this build's fit"); + + // the original goes offline, then a preview-size render loads the proxy into the source cache + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.forget(id); + let _ = s.render_now(id, 600, 600).expect("renders from the smart preview"); + + // Build Smart Previews must not take that cached proxy for the original: it can't refresh a + // stale proxy from itself + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(std::fs::read(&file).unwrap(), stale_bytes, "the offline proxy is preserved"); + assert!(crate::smart::is_stale(&file), "still stale: the original was not read"); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + let _ = std::fs::remove_dir_all(&lib); +} + /// The look marker: missing, damaged or older than this build's fit version counts as unchecked. #[test] fn smart_preview_look_marker_is_read_safely() { @@ -1792,3 +2093,94 @@ fn presets_apply_the_imported_profile_they_name() { assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); let _ = std::fs::remove_dir_all(&src); } + +/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one. +fn write_raw(path: &Path) { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect(); + let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&j); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, f).unwrap(); +} + +/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft. +fn write_tiff(path: &Path, seed: u8) { + use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff}; + let (w, h) = (48usize, 32usize); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect(); + let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first, +/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never +/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating, +/// label, metadata and develop settings. +#[test] +fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() { + let sidecar = |rating: u8| { + format!( + r#" +"# + ) + }; + let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() }; + + // the raw and the edited TIFF of it, same name, in one import + let src = temp_dir("stem-pair"); + write_raw(&src.join("IMG_1.NEF")); + write_tiff(&src.join("IMG_1.TIF"), 7); + std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap(); + std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 2, "{r}"); + assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own"); + assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's"); + let _ = std::fs::remove_dir_all(&src); + + // the TIFF with no sidecar of its own: nothing of the raw's packet comes across + let src = temp_dir("stem-none"); + write_raw(&src.join("IMG_2.NEF")); + write_tiff(&src.join("IMG_2.TIF"), 8); + std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 1, "{r}"); + assert_eq!(read(&s, "IMG_2.NEF"), 4); + assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's"); + let _ = std::fs::remove_dir_all(&src); + + // the raw already in the library, the edited TIFF arriving later + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + let src = temp_dir("stem-after"); + write_raw(&src.join("IMG_3.NEF")); + write_tiff(&src.join("IMG_3.TIF"), 9); + std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap(); + let mut s = Session::new().with_fs(); + let raw = { + let id = s.catalog.alloc_photo_id(); + let path = src.join("IMG_3.NEF").to_string_lossy().into_owned(); + let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48"); + p.kind = MediaKind::Raw; + p + }; + s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + assert_eq!(r["sidecars"], 0, "{r}"); + assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index aaa59acb1..ed524ae3d 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() { assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); } + +/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]` +/// merges two frames rather than holding four in memory. +#[test] +fn a_photo_named_twice_is_merged_once() { + let dir = temp_dir("dup-ids"); + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let (a, b) = (ids[0], ids[1]); + for cmd in ["merge.hdr", "merge.panorama"] { + let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err(); + assert!(e.to_string().contains("at least 2"), "{cmd}: {e}"); + } + let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap(); + let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap(); + assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::>(), vec![a, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 344838432..ea0414256 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -356,3 +356,27 @@ fn rename_huge_start_is_a_bad_param() { let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); assert!(r.is_err(), "{r:?}"); } + +#[test] +fn stack_toggle_by_ids_leaves_the_selection_alone() { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + let (loose, top, member) = (vis[0], vis[1], vis[2]); + s.execute("library.select", &json!({"ids": [top.0, member.0], "active": top.0})).unwrap(); + s.execute("stack.group", &json!({})).unwrap(); + // a loose photo is selected: the stack is named by `ids` (a badge click), not selected + s.execute("library.select", &json!({"ids": [loose.0]})).unwrap(); + let r = s.execute("stack.toggle", &json!({"ids": [member.0]})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert!(!s.catalog.stack_of(top).unwrap().collapsed); + assert_eq!((s.selection.ids.clone(), s.selection.active), (vec![loose], Some(loose)), "the selection is untouched"); + s.execute("stack.toggle", &json!({"ids": [top.0]})).unwrap(); + assert!(s.catalog.stack_of(top).unwrap().collapsed); + // naming only loose photos is refused without a commit, as is an unknown id + let commits = s.commits(); + assert!(s.execute("stack.toggle", &json!({"ids": [loose.0]})).is_err()); + assert!(s.execute("stack.toggle", &json!({"ids": [999_999]})).is_err()); + assert_eq!(s.commits(), commits); + // without `ids` the selection still decides (a loose photo selected: not available) + assert!(matches!(s.execute("stack.toggle", &json!({})), Err(crate::EngineError::Disabled(..)))); +} diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index 4fb3f0234..7ebf8464b 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() { s.close_library().unwrap(); assert_eq!(s.unsaved(), None); } + +/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as +/// unsaved (the top bar warns, the web backup refuses) until a later save writes it. +#[test] +fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() { + let files = Flaky::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert!(s.unsaved().is_none()); + files.set_failing(true); + s.execute("preset.create", &json!({"name": "Mine"})).unwrap(); + let (n, why) = s.unsaved().expect("the preset is unsaved"); + assert_eq!(n, 1); + assert!(why.contains("presets") && why.contains("disk full"), "{why}"); + let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default(); + assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine")); + // still failing: still unsaved + s.persist().unwrap(); + assert!(s.unsaved().is_some()); + files.set_failing(false); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once the disk works again"); + assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine")); +} diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs index f7a899c43..9d3e9c807 100644 --- a/crates/engine/src/tests_slide_template.rs +++ b/crates/engine/src/tests_slide_template.rs @@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() { } std::fs::create_dir_all(folder.join("folder.json")).unwrap(); std::fs::write(folder.join("notes.txt"), b"ignored").unwrap(); + // written by hand, not through LightCraft: the folder's time may not have moved since the + // save's listing (same clock tick), so the cached listing is dropped as a write would drop it + s.slide_templates_cache = None; let r = run(&mut s, "slideshow.templates.list", json!({})); let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect(); assert_eq!(user.len(), 1, "only the good one: {r}"); diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs index 988e7aac1..28a9fca0b 100644 --- a/crates/engine/src/tests_spots.rs +++ b/crates/engine/src/tests_spots.rs @@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() { assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill"); assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err()); } + +fn target_session() -> Session { + let mut s = Session::with_demo(); + s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap(); + s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap(); + for x in [0.3, 0.7] { + s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap(); + } + // A rejected command must preserve a redo branch as well as existing undo steps. + s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s +} + +fn target_state(s: &Session) -> serde_json::Value { + let photo = s.catalog.photo(s.active().unwrap()).unwrap(); + json!({ + "develop": photo.develop, + "history": photo.history, + "selection": s.selection, + "activeMask": s.active_mask, + "activeSpot": s.active_spot, + "undo": format!("{:?}", s.undo), + "redo": format!("{:?}", s.redo), + "commits": s.commits(), + "effects": s.mask_effects, + "rememberedEffects": s.mask_tool_effects, + "journal": s.journal, + "revision": s.catalog.revision, + }) +} + +fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) { + let before = target_state(s); + let result = s.execute(command, params); + let after = target_state(s); + assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after); + assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection"); + for field in ["id", "component", "index"] { + if let Some(value) = params.get(field) + && !value.is_null() + && value.as_u64().is_none() + { + assert!( + result.unwrap_err().to_string().contains(&format!("`{field}`")), + "{command} must reject the malformed {field} before doing other work" + ); + break; + } + } +} + +fn malformed_targets() -> Vec { + vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)] +} + +#[test] +fn malformed_mask_ids_are_rejected_without_changes() { + let mut s = target_session(); + let commands = [ + ("mask.delete", json!({})), + ("mask.rename", json!({"name": "Wrong mask"})), + ("mask.invert", json!({})), + ("mask.visible", json!({"visible": false})), + ("mask.duplicate", json!({})), + ("mask.move", json!({"to": 0})), + ("mask.adjust", json!({"values": {"exposure": 1.0}})), + ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})), + ("mask.addComponent", json!({"kind": "brush"})), + ("mask.component", json!({"component": 0, "action": "invert"})), + ("mask.brushStroke", json!({"points": [[0.5, 0.5]]})), + ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})), + ("mask.objectPoint", json!({"x": 0.5, "y": 0.5})), + ("mask.refineDetail", json!({})), + ]; + for (command, mut params) in commands { + for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) { + params["id"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_mask_selection_is_rejected_without_changes() { + let mut s = target_session(); + for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) { + rejects_target(&mut s, "mask.select", &json!({"id": target})); + } +} + +#[test] +fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() { + let mut s = target_session(); + rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64})); +} + +#[test] +fn malformed_mask_effect_targets_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [ + ("mask.saveEffect", json!({"name": "Wrong effect"})), + ("mask.updateEffect", json!({"name": "Existing"})), + ("mask.applyEffect", json!({"name": "Existing"})), + ] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) { + params["id"] = target; + rejects_target(&mut s, command, ¶ms); + } + } + for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) { + rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target})); + } +} + +#[test] +fn mask_effect_targets_preserve_explicit_defaults_and_lists() { + let mut s = target_session(); + s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap(); + assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0); + for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] { + let mut params = target; + params["name"] = json!(name); + s.execute("mask.saveEffect", ¶ms).unwrap(); + assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0); + params["name"] = json!("Existing"); + s.execute("mask.updateEffect", ¶ms).unwrap(); + assert_eq!(s.mask_effects[0].adjust.exposure, 1.0); + } + s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap(); + assert_eq!(s.mask_effects[0].adjust.exposure, 0.0); + for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] { + s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap(); + params["name"] = json!("Existing"); + s.execute("mask.applyEffect", ¶ms).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0); + } + s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap(); + s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap(); + assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0)); + rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []})); +} + +#[test] +fn malformed_mask_components_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [ + ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})), + ("mask.component", json!({"action": "invert"})), + ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})), + ("mask.refineDetail", json!({})), + ] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) { + params["component"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_spot_indices_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) { + params["index"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_spot_selection_is_rejected_without_changes() { + let mut s = target_session(); + for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) { + rejects_target(&mut s, "spot.select", &json!({"index": target})); + } +} + +#[test] +fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() { + use lightcraft_develop::MaskShape; + let mut s = target_session(); + for target in [json!({}), json!({"id": null})] { + let mut params = target; + params["name"] = json!("Selected mask"); + s.execute("mask.rename", ¶ms).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask"); + } + s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask"); + assert_eq!(s.active_mask, Some(2)); + for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] { + s.execute("mask.update", ¶ms).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. })); + assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. })); + } + s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap(); + assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky)); + for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] { + s.execute("mask.brushStroke", ¶ms).unwrap(); + } + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2); + for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] { + s.execute("mask.sampleColor", ¶ms).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") }; + assert_eq!(samples.len(), 1); + } + for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] { + s.execute("spot.update", ¶ms).unwrap(); + assert_eq!(spots(&s)[1].size, 0.04); + } + s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap(); + assert_eq!(spots(&s)[0].size, 0.03); + assert_eq!(s.active_spot, Some(1)); + rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"})); + for params in [json!({"id": null}), json!({})] { + s.execute("mask.select", &json!({"id": 1})).unwrap(); + assert_eq!(s.active_mask, Some(1)); + s.execute("mask.select", ¶ms).unwrap(); + assert_eq!(s.active_mask, None); + } + for params in [json!({"index": null}), json!({})] { + s.execute("spot.select", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + s.execute("spot.select", ¶ms).unwrap(); + assert_eq!(s.active_spot, None); + } + rejects_target(&mut s, "mask.delete", &json!({})); + rejects_target(&mut s, "spot.delete", &json!({})); + // An omitted mask still creates a brush mask when no mask is selected. + s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3); +} diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 39378b100..88c184335 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -314,6 +314,41 @@ fn a_cancelled_scan_stops_checking_files() { assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}"); } +/// While the folder is listed, before a single photo can be checked, the scan says how far the +/// listing is: a folder tree on a network share takes minutes to list, and "0 of 64,085" all +/// that while looks like a scan that has stopped. +#[test] +fn a_scan_says_how_many_folders_and_files_it_has_listed() { + let dir = Scratch::new("listed"); + let mut s = library(&dir); + write_png(&dir.path("trip/day2/c.png"), 3); + std::fs::write(dir.path("trip/day2/notes.txt"), "not a photo").unwrap(); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); + let progress = crate::sync::SyncProgress::default(); + assert_eq!(progress.listing(), Some((0, 0)), "listing is the first thing a scan does"); + crate::sync::scan_with(input, &progress); + assert_eq!(progress.listing(), None, "the listing is done once the scan is"); + let (folders, files) = + (progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), progress.files_listed.load(std::sync::atomic::Ordering::Relaxed)); + // trip, trip/day1, trip/day2; a.png, b.png, c.png and the note (every file is listed, photo or not) + assert_eq!((folders, files), (3, 4)); +} + +/// ✕ on a scan's row stops the listing too, not only the checks after it. +#[test] +fn a_cancelled_scan_stops_listing_the_folders() { + let dir = Scratch::new("cancel-listing"); + let mut s = library(&dir); + for i in 0..20u8 { + write_png(&dir.path(&format!("trip/more/{i}/x.png")), i); + } + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); + let progress = crate::sync::SyncProgress::default(); + progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + crate::sync::scan_with(input, &progress); + assert_eq!(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), 1, "the walk stopped after the folder it was in"); +} + #[test] fn many_photos_are_checked_in_parallel_with_the_same_answer() { let dir = Scratch::new("many"); @@ -685,3 +720,22 @@ fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() { assert_eq!(s.sidecar_namings(&ids), one_by_one); assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there"); } + +/// ✕ set after the listing, while the scan goes through the files: the probes a preceding scan +/// kept are not checked against the disk one by one (a round trip per file on a network share); +/// the files not reached come back as errors. +#[test] +fn a_cancelled_scan_does_not_check_the_cached_probes_against_the_disk() { + let dir = Scratch::new("cancel-cached"); + let mut s = library(&dir); + write_png(&dir.path("trip/new.png"), 9); + let found = crate::import::scan(&mut s, &[dir.path("trip")]); + assert!(found.iter().any(|c| c.name == "new.png" && c.error.is_none()), "{found:?}"); + assert!(!s.import_probes.is_empty(), "the scan kept the new file's probe"); + let (input, paths) = crate::import::ScanInput::new(&mut s, &[dir.path("trip")]); + let progress = crate::import::ScanProgress::default(); + progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let out = crate::import::scan_with(input, &paths, &progress); + let new = out.candidates.iter().find(|c| c.name == "new.png").expect("new.png is a candidate"); + assert!(new.error.is_some(), "the cached probe was used after ✕: {new:?}"); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 231a7bee3..1a4d86fa3 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -621,6 +621,214 @@ fn dng_embedded_crs_settings_are_read_on_import() { let _ = std::fs::remove_dir_all(&src); } +fn jpeg_xmp_packet(rating: i8, title: &str) -> String { + lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), title: Some(title.into()), ..Default::default() }, None) +} + +fn write_jpeg_xmp(path: &Path, packet: &str) -> Vec { + let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]); + let meta = lightcraft_codecs::EncodeMeta { xmp: Some(packet), ..Default::default() }; + let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap(); + assert_eq!(lightcraft_meta::embedded(&bytes).xmp.as_deref(), Some(packet), "the procedural JPEG carries the requested packet"); + std::fs::write(path, &bytes).unwrap(); + bytes +} + +#[test] +fn embedded_jpeg_xmp_reread_refreshes_metadata_and_undoes_once() { + let src = temp_dir("jpeg-xmp-refresh"); + let file = src.join("photo.jpg"); + let initial = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Initial embedded title")); + let mut s = Session::new().with_fs(); + let imported = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + assert_eq!(imported["imported"].as_array().unwrap().len(), 1, "{imported}"); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().kind, lightcraft_catalog::MediaKind::Image); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap(); + let before = s.catalog.photo(id).unwrap().clone(); + assert!(!s.xmp.auto_write); + + // Change the packet after import: rereading must use the file, not the imported metadata. + let changed = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Changed embedded title")); + assert_ne!(initial, changed); + assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists()); + let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(read["read"], json!([{"id": id.0, "from": file.to_string_lossy()}]), "embedded JPEG XMP must be reread: {read}"); + assert_eq!(read["failed"], json!([]), "{read}"); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, photo.meta.title.as_str()), (4, "Changed embedded title")); + assert_eq!(photo.develop, before.develop); + + s.execute("edit.undo", &json!({})).unwrap(); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title), "one undo restores both fields"); + assert_eq!(std::fs::read(&file).unwrap(), changed, "reading and undo never rewrite the original"); + assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn embedded_jpeg_xmp_reread_keeps_sidecar_precedence() { + let src = temp_dir("jpeg-xmp-sidecar"); + let file = src.join("photo.jpg"); + let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title")); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = only(&s); + let sidecar = src.join("photo.xmp"); + let packet = jpeg_xmp_packet(5, "Sidecar title"); + std::fs::write(&sidecar, &packet).unwrap(); + let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(read["read"], json!([{"id": id.0, "from": sidecar.to_string_lossy()}]), "{read}"); + assert_eq!(read["failed"], json!([]), "{read}"); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title")); + assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet); + + // A malformed sidecar must report its failure, not fall back to the valid embedded packet. + let malformed = "".repeat(1000); + assert!(lightcraft_meta::parse_xmp(&malformed).is_err()); + std::fs::write(&sidecar, &malformed).unwrap(); + let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(read["read"], json!([]), "{read}"); + assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}"); + assert_eq!(read["failed"][0]["id"], id.0); + assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty()); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title")); + assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), malformed); + assert_eq!(std::fs::read(&file).unwrap(), original); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn embedded_jpeg_xmp_reread_malformed_input_preserves_state_and_undo() { + let src = temp_dir("jpeg-xmp-malformed"); + let file = src.join("photo.jpg"); + write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Original title")); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = only(&s); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + let previous_title = s.catalog.photo(id).unwrap().meta.title.clone(); + s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap(); + let before = s.catalog.photo(id).unwrap().clone(); + let malformed = "".repeat(1000); + assert!(lightcraft_meta::parse_xmp(&malformed).is_err()); + let broken_packet = write_jpeg_xmp(&file, &malformed); + let truncated_container = vec![0xff, 0xd8, 0xff, 0xe1, 0, 40, b'x']; + for bytes in [broken_packet, truncated_container] { + std::fs::write(&file, &bytes).unwrap(); + let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(read["read"], json!([]), "{read}"); + assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}"); + assert_eq!(read["failed"][0]["id"], id.0); + assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty()); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title)); + assert_eq!(photo.develop, before.develop); + assert_eq!(std::fs::read(&file).unwrap(), bytes); + assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists()); + } + // Failed reads do not add an undo entry: the last successful title edit is still next. + s.execute("edit.undo", &json!({})).unwrap(); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title)); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn embedded_jpeg_xmp_reread_non_utf8_sidecar_preserves_state_and_undo() { + let src = temp_dir("jpeg-xmp-non-utf8-sidecar"); + let file = src.join("photo.jpg"); + let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title")); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = only(&s); + assert!(!s.xmp.auto_write); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + let previous_title = s.catalog.photo(id).unwrap().meta.title.clone(); + s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap(); + let before = s.catalog.photo(id).unwrap().clone(); + let sidecar = src.join("photo.xmp"); + let invalid = [0xff, 0xfe, 0x80]; + std::fs::write(&sidecar, invalid).unwrap(); + assert_eq!(std::fs::read_to_string(&sidecar).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(crate::sidecar::find_sidecar(file.to_str().unwrap(), s.sidecar_naming(id)), Some(sidecar.clone())); + + let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(read["read"], json!([]), "an existing non-UTF-8 sidecar must not fall back to embedded JPEG XMP: {read}"); + assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}"); + assert_eq!(read["failed"][0]["id"], id.0); + assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty()); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title)); + assert_eq!(photo.develop, before.develop); + + s.execute("edit.undo", &json!({})).unwrap(); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title), "failed reads must not add an undo entry"); + assert_eq!(photo.develop, before.develop); + assert_eq!(std::fs::read(&file).unwrap(), original); + assert_eq!(std::fs::read(&sidecar).unwrap(), invalid); + assert!(!src.join("photo.jpg.xmp").exists()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn embedded_jpeg_xmp_reread_all_metadata_uses_supplied_bytes() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let src = temp_dir("jpeg-xmp-all-metadata"); + let file = src.join("photo.jpg"); + let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "On-disk title")); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = only(&s); + assert!(!s.xmp.auto_write); + let before = s.catalog.photo(id).unwrap().clone(); + let supplied = write_jpeg_xmp(&src.join("supplied.jpg"), &jpeg_xmp_packet(4, "Supplied title")); + let reads = Arc::new(AtomicUsize::new(0)); + let counted = reads.clone(); + let expected_path = file.to_string_lossy().into_owned(); + s.media.file_bytes = Some(Arc::new(move |path| { + assert_eq!(path, expected_path); + counted.fetch_add(1, Ordering::SeqCst); + Ok(supplied.clone()) + })); + + let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 1); + let xmp = query["xmp"].as_array().unwrap(); + assert!( + xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Supplied title"), + "All Metadata must use the bytes already supplied: {query}" + ); + assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "4"), "{query}"); + assert!(!xmp.iter().any(|x| x["value"] == "On-disk title"), "{query}"); + + let sidecar = src.join("photo.xmp"); + let packet = jpeg_xmp_packet(5, "Sidecar title"); + std::fs::write(&sidecar, &packet).unwrap(); + let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 2); + let xmp = query["xmp"].as_array().unwrap(); + assert!(xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Sidecar title"), "{query}"); + assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "5"), "{query}"); + assert!(!xmp.iter().any(|x| x["value"] == "Supplied title"), "{query}"); + let photo = s.catalog.photo(id).unwrap(); + assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title)); + assert_eq!(photo.develop, before.develop); + assert_eq!(std::fs::read(&file).unwrap(), original); + assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet); + let _ = std::fs::remove_dir_all(&src); +} + #[test] fn demo_photos_have_no_sidecar() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index f8a3ba251..12dbadbd6 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -58,8 +58,9 @@ pub enum EntryKind { /// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind /// of every entry in it (hidden ones too), and whether that is all of them (not when the walk -/// stopped inside the folder, or an entry could not be read). -pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool); +/// stopped inside the folder, or an entry could not be read). It answers whether to go on: `false` +/// ends the walk there (a scan cancelled; [`Walked::truncated`] is left alone: who stopped it knows). +pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool) -> bool; /// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`) /// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops @@ -154,8 +155,10 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(& }; children.push((p, dir)); } - if let (Some(note), Some(listed)) = (note.as_mut(), listed) { - note(&path, listed, complete); + if let (Some(note), Some(listed)) = (note.as_mut(), listed) + && !note(&path, listed, complete) + { + break; } children.sort(); let below = depth.saturating_add(1); @@ -193,6 +196,7 @@ mod tests { let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); noted.push((dir.to_path_buf(), entries, complete)); + true }); assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not"); let named = |n: &str, k: EntryKind| (n.to_string(), k); @@ -205,7 +209,8 @@ mod tests { // stopped inside the first folder: its listing is not all of it let mut cut = Vec::new(); let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| { - cut.push((dir.to_path_buf(), entries.len(), complete)) + cut.push((dir.to_path_buf(), entries.len(), complete)); + true }); assert!(walked.truncated); assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false))); diff --git a/crates/engine/tests/denoise_cpu.rs b/crates/engine/tests/it/denoise_cpu.rs similarity index 97% rename from crates/engine/tests/denoise_cpu.rs rename to crates/engine/tests/it/denoise_cpu.rs index d01b7c5a5..1da6da78d 100644 --- a/crates/engine/tests/denoise_cpu.rs +++ b/crates/engine/tests/it/denoise_cpu.rs @@ -1,10 +1,9 @@ //! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw. //! LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -//! -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture +//! -p lightcraft-engine --features denoise --test it denoise_cpu:: -- --ignored --nocapture //! LC_DENOISE_ONLY selects a file (default arw-sony-a7m3-compressed.arw). //! The whole model stage includes packing, scale/clip protection, blending and normalization; //! file decode, model loading and GPU setup are outside the timer. It uses the application's tile pool convention. -#![cfg(feature = "denoise")] use lightcraft_denoise::{ TileRunner, diff --git a/crates/engine/tests/denoise_real.rs b/crates/engine/tests/it/denoise_real.rs similarity index 98% rename from crates/engine/tests/denoise_real.rs rename to crates/engine/tests/it/denoise_real.rs index 8d8581753..cb045cbd5 100644 --- a/crates/engine/tests/denoise_real.rs +++ b/crates/engine/tests/it/denoise_real.rs @@ -4,12 +4,11 @@ //! //! ```text //! LC_DENOISE_MODEL= LC_DENOISE_RAW= \ -//! cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture +//! cargo test --release -p lightcraft-engine --features denoise --test it denoise_real:: -- --ignored --nocapture //! ``` //! //! `LC_DENOISE_RAW` is a folder of CC0 raw files (`cargo xtask corpus --download` → `corpus/raw`); every file in it is //! tried. Optional: `LC_DENOISE_ONLY` (a file name to try alone), `LC_DENOISE_RUN_ON=cpu|gpu|auto` (where the model runs; `cpu` to compare with the card). -#![cfg(feature = "denoise")] use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..2c52d87e9 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,7 @@ +//! The integration tests, one module per file, linked into one test binary. + +#[cfg(feature = "denoise")] +mod denoise_cpu; +#[cfg(feature = "denoise")] +mod denoise_real; +mod preview_read; diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/it/preview_read.rs similarity index 95% rename from crates/engine/tests/preview_read.rs rename to crates/engine/tests/it/preview_read.rs index eedebc2c1..98eb64d27 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/it/preview_read.rs @@ -2,7 +2,7 @@ //! network share, which is where reading only the header instead of the whole raw matters: //! //! LC_PREVIEW_RAW="/run/user/1000/gvfs/smb-share:.../DSCF0001.RAF" \ -//! cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture +//! cargo test --release -p lightcraft-engine --test it preview_read:: -- --ignored --nocapture //! //! It reports what the preview cost and fails when the read is not the small part of the file the //! header path promises. Skipped without `LC_PREVIEW_RAW` (there is no committed raw fixture: media diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 0b1b0ae98..c95478388 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -58,6 +58,43 @@ pub struct Url { pub path: String, } +/// Remove `.` and `..` segments from the path part of `path` (RFC 3986 section 5.2.4); the query is kept as is. +fn remove_dot_segments(path: &str) -> String { + let (p, query) = match path.find('?') { + Some(i) => (path.get(..i).unwrap_or_default(), path.get(i..).unwrap_or_default()), + None => (path, ""), + }; + let mut out: Vec<&str> = Vec::new(); + let mut segments = p.split('/').skip(1).peekable(); + let mut trailing_slash = false; + while let Some(seg) = segments.next() { + let last = segments.peek().is_none(); + match seg { + "." => trailing_slash = last, + ".." => { + out.pop(); + trailing_slash = last; + } + _ => { + out.push(seg); + trailing_slash = false; + } + } + } + let mut result = format!("/{}", out.join("/")); + if trailing_slash && !result.ends_with('/') { + result.push('/'); + } + result.push_str(query); + result +} + +/// `s` without the leading `prefix`, matched ASCII case-insensitively (RFC 3986 §3.1). +fn strip_scheme<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..).unwrap_or_default()) +} + impl Url { pub fn parse(s: &str) -> Result { let bad = |why: &str| HttpError::BadUrl(why.to_string()); @@ -68,9 +105,9 @@ impl Url { if s.chars().any(|c| c.is_control() || c == ' ') { return Err(bad("contains spaces or control characters")); } - let (tls, rest) = if let Some(r) = s.strip_prefix("https://") { + let (tls, rest) = if let Some(r) = strip_scheme(s, "https://") { (true, r) - } else if let Some(r) = s.strip_prefix("http://") { + } else if let Some(r) = strip_scheme(s, "http://") { (false, r) } else { return Err(bad("only http:// and https:// are supported")); @@ -81,6 +118,7 @@ impl Url { None => (rest, ""), }; let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; + let path = remove_dot_segments(&path); if authority.contains('@') { return Err(bad("user names and passwords in URLs are not supported")); } @@ -112,7 +150,7 @@ impl Url { /// `Location` of a redirect, resolved against this URL. pub fn join(&self, location: &str) -> Result { let l = location.trim(); - if l.starts_with("http://") || l.starts_with("https://") { + if strip_scheme(l, "http://").is_some() || strip_scheme(l, "https://").is_some() { let url = Url::parse(l)?; if self.tls && !url.tls { return Err(HttpError::BadUrl("redirect from https to http refused".into())); @@ -135,8 +173,16 @@ impl Url { if l.starts_with('/') { return Url::parse(&format!("{base}{l}")); } + // empty or fragment-only: the same document + if l.is_empty() || l.starts_with('#') { + return Ok(self.clone()); + } // relative to the current directory let dir = self.path.split('?').next().unwrap_or("/"); + // a query-only reference keeps the whole path, file name included (RFC 3986 §5.2.2) + if l.starts_with('?') { + return Url::parse(&format!("{base}{dir}{l}")); + } let dir = dir.rsplit_once('/').map_or("", |(d, _)| d); Url::parse(&format!("{base}{dir}/{l}")) } @@ -612,12 +658,46 @@ mod tests_connect { mod tests { use super::*; + #[test] + fn fragment_only_or_empty_location_keeps_file_name_and_query() { + let base = Url::parse("https://a.example/x/y/file?q").unwrap(); + for l in ["#top", "", " "] { + let u = base.join(l).unwrap(); + assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/x/y/file?q"), "{l:?}"); + } + } + #[test] fn rejects_oversized_and_control_character_urls() { assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err()); assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err()); } + #[test] + fn join_query_only_keeps_file_name() { + let u = Url::parse("https://a.example/x/y/file?q=1").unwrap(); + assert_eq!(u.join("?page=2").unwrap().path, "/x/y/file?page=2"); + } + + #[test] + fn join_removes_dot_segments() { + let base = Url::parse("https://a.example/x/y/file").unwrap(); + for (loc, want) in + [("../z", "/x/z"), ("./z", "/x/y/z"), ("../../../z", "/z"), ("/a/./b/../c", "/a/c"), ("../z?q=1", "/x/z?q=1"), ("..", "/x/")] + { + assert_eq!(base.join(loc).unwrap().path, want, "{loc}"); + } + } + + #[test] + fn url_scheme_is_matched_case_insensitively() { + let u = Url::parse("HTTPS://A.example/z").unwrap(); + assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/z")); + assert!(Url::parse("Http://a.example/").is_ok()); + let j = Url::parse("https://a.example/start").unwrap().join("HTTPS://cdn.example/file").unwrap(); + assert_eq!((j.tls, j.host.as_str(), j.path.as_str()), (true, "cdn.example", "/file")); + } + #[test] fn parses_urls() { let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap(); diff --git a/crates/geom/src/crop_drag.rs b/crates/geom/src/crop_drag.rs index f600de6d3..3e8f2484f 100644 --- a/crates/geom/src/crop_drag.rs +++ b/crates/geom/src/crop_drag.rs @@ -6,7 +6,7 @@ //! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app, //! CLI, control channel and MCP agree. -use crate::{CropGeometry, Point, Rect}; +use crate::{Affine, CropGeometry, Point, Rect, Vec2}; /// Smallest crop side, as a fraction of the image's shorter side. pub const MIN_CROP_FRACTION: f64 = 0.02; @@ -109,11 +109,21 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: let out = match (handle, aspect) { (CropHandle::Move, _) => { let slide = |cur: Rect, ax: f64, ay: f64| { - let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t)))); - cur.translate(crate::Vec2::new(ax * t, ay * t)) + let t = bisect(0.0, 1.0, |t| feasible(cur.translate(Vec2::new(ax * t, ay * t)))); + cur.translate(Vec2::new(ax * t, ay * t)) }; - let cur = slide(sr, dx, 0.0); - slide(cur, 0.0, dy) + // straightened, go to the nearest place that fits, so the frame slides along tilted edges (#2299) + let nearest = if base.angle != 0.0 { + let from = |r: Rect| nearest_move(r, base.angle, w, h, Vec2::new(dx, dy)).map(|v| r.translate(v)).filter(|r| feasible(*r)); + // a frame at the tolerance's edge on opposite sides moves only once it is inside it + from(sr).or_else(|| shrunk_inside(sr, base.angle, w, h).and_then(from)) + } else { + None + }; + match nearest { + Some(r) => r, + None => slide(slide(sr, dx, 0.0), 0.0, dy), + } } (hd, None) => { let (mx0, mx1, my0, my1) = sides(hd); @@ -202,6 +212,70 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start } } +/// The translation (pixels) nearest `want` that keeps the frame `r` inside a `w × h` image turned by `angle` degrees. +fn nearest_move(r: Rect, angle: f64, w: f64, h: f64, want: Vec2) -> Option { + let back = Affine::rotate_about(-angle.to_radians(), Point::new(w / 2.0, h / 2.0)); + let (ex, ey) = (back.apply_vec(Vec2::new(1.0, 0.0)), back.apply_vec(Vec2::new(0.0, 1.0))); + let (nx, ny) = (Vec2::new(ex.x, ey.x), Vec2::new(ex.y, ey.y)); + // never tighter than where the frame is, plus a slack so a zero-width strip survives rounding + let (sx, sy) = (1e-9 * w, 1e-9 * h); + let mut planes = Vec::with_capacity(16); + for c in r.corners() { + let q = back.apply(c); + for (n, b, s) in [(nx, w - q.x, sx), (-nx, q.x, sx), (ny, h - q.y, sy), (-ny, q.y, sy)] { + planes.push((n, b.max(0.0) + s)); + } + } + if planes.iter().all(|(n, b)| n.dot(want) <= *b) { + return Some(want); + } + let lim = 4.0 * w.max(h); + let mut poly = vec![Vec2::new(-lim, -lim), Vec2::new(lim, -lim), Vec2::new(lim, lim), Vec2::new(-lim, lim)]; + for (n, b) in &planes { + poly = clip_half_plane(&poly, *n, *b); + } + poly.iter() + .zip(poly.iter().cycle().skip(1)) + .map(|(a, b)| { + let ab = *b - *a; + let l2 = ab.dot(ab); + let t = if l2 > 0.0 { ((want - *a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; + *a + ab * t + }) + .filter(|v| v.x.is_finite() && v.y.is_finite()) + .min_by(|a, b| (*a - want).len().total_cmp(&(*b - want).len())) +} + +/// `r` scaled about its centre by the largest factor down to `1 - 1e-5` that puts it exactly inside a `w × h` image +/// turned by `angle` degrees. +fn shrunk_inside(r: Rect, angle: f64, w: f64, h: f64) -> Option { + let back = Affine::rotate_about(-angle.to_radians(), Point::new(w / 2.0, h / 2.0)); + let scaled = |k: f64| Rect::from_center(r.center(), r.width() * k, r.height() * k); + let inside = |k: f64| { + scaled(k).corners().iter().all(|c| { + let q = back.apply(*c); + q.x >= 0.0 && q.y >= 0.0 && q.x <= w && q.y <= h + }) + }; + let lo = 1.0 - 1e-5; + inside(lo).then(|| scaled(bisect_range(lo, 1.0, inside))) +} + +/// The part of the convex polygon `poly` where `n · v <= b`. +fn clip_half_plane(poly: &[Vec2], n: Vec2, b: f64) -> Vec { + let mut out = Vec::with_capacity(poly.len() + 1); + for (p, q) in poly.iter().zip(poly.iter().cycle().skip(1)) { + let (dp, dq) = (n.dot(*p) - b, n.dot(*q) - b); + if dp <= 0.0 { + out.push(*p); + } + if (dp <= 0.0) != (dq <= 0.0) { + out.push(*p + (*q - *p) * (dp / (dp - dq))); + } + } + out +} + /// Which sides a handle moves: (left, right, top, bottom). fn sides(h: CropHandle) -> (bool, bool, bool, bool) { use CropHandle::*; @@ -480,4 +554,87 @@ mod tests { } } } + + // Scenario: moving a frame that fills a straightened photo's height (issue #2299) + #[test] + fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() { + for angle in [3.0, -10.0] { + let a = 2.0 / 3.0; + let start = crate::crop_fit_angle(W, H, angle, Some(a)); + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect); + assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size"); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect); + let gc = g.rect.center(); + let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a)); + assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect); + let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a)); + assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect); + } + } + + // Scenario: a straightened move that fits goes exactly where the pointer says + #[test] + fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() { + let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 }; + let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None); + assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect); + } + + // Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299) + #[test] + fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() { + let a = 2.0 / 3.0; + let fit = crate::crop_fit_angle(W, H, 3.0, Some(a)); + let k = 1.0 + 5e-7 / fit.rect.height(); + let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 }; + assert!(start.is_within_image(W, H)); + let c = start.rect.center(); + let z = drag(start, Move, c, c, Some(a)).rect; + let s = start.rect; + assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}"); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect); + assert!(g.is_within_image(W, H)); + } + + // Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299) + #[test] + fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() { + let a = 2.0 / 3.0; + for angle in [3.0, 6.0, -10.0, 12.0] { + let fit = crate::crop_fit_angle(W, H, angle, Some(a)); + let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle }; + // the second fills the photo's height, at the tolerance's edge top and bottom + let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a)); + for start in [small, shrunk] { + let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a)); + let c = big.rect.center(); + let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect); + assert!( + g.is_within_image(W, H) + && (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5 + && (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5, + "{angle}: {:?}", + g.rect + ); + } + } + } + + // Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299) + #[test] + fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() { + let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 }; + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.2, c.y), None); + assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect); + let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None)); + for g in [a, b] { + assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect); + } + assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect); + } } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index fb000c031..33e02fbe4 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,7 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use wgpu::util::DeviceExt; @@ -118,7 +118,9 @@ impl Drop for Buf { let mut b = Some(b); let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); match b { - Some(b) => std::mem::forget(b), + // Leaking is deliberate during thread teardown — dropping a wgpu buffer whose device + // thread-locals have already gone can abort the process. The device owns it. + Some(b) => std::mem::forget(b), // NOSONAR None => { RETIRED_BYTES.fetch_add(size, Ordering::Relaxed); } @@ -191,6 +193,12 @@ impl Drop for Tracked { } } +#[cfg(test)] +thread_local! { + /// Polls on this thread panic as wgpu's do once a Vulkan or DX12 device is lost (#1858). + static SIMULATED_LOST_POLL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + thread_local! { static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } @@ -199,6 +207,9 @@ thread_local! { static ALLOCATED: AtomicU64 = AtomicU64::new(0); /// Bytes waiting in per-thread retired lists. static RETIRED_BYTES: AtomicU64 = AtomicU64::new(0); +/// The render device is lost: wgpu's `poll` panics from then on, so [`Gpu::poll`] stops calling it. +/// Other failures that retire the GPU leave the device usable for releasing its buffers. +static DEVICE_LOST: AtomicBool = AtomicBool::new(false); /// Bytes in the free pool. static POOLED: AtomicU64 = AtomicU64::new(0); /// Most bytes kept in the free pool (see [`crate::set_pool_limit`]). @@ -324,6 +335,7 @@ impl Gpu { // wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an // error — so without this callback a lost device went unnoticed. device.set_device_lost_callback(|reason, msg| { + DEVICE_LOST.store(true, Ordering::Relaxed); log::error!("gpu: device lost ({reason:?}): {msg}"); crate::mark_broken(&format!("device lost ({reason:?}): {msg}")); }); @@ -446,7 +458,33 @@ impl Gpu { if !dropped.is_empty() { drop(dropped); // let wgpu release the memory now rather than at its next maintenance - let _ = self.device.poll(wgpu::PollType::Poll); + let _ = self.poll(wgpu::PollType::Poll); + } + } + + /// Process finished GPU work: `device.poll`, with its error as text. Never panics (#1858): once a + /// device is lost, wgpu 30 panics in `poll` rather than returning an error (a device error is + /// not a `PollError`), and the buffer pool polls from destructors ([`Gpu::trim`]), where a + /// second panic while a failed render unwinds aborts the process. A lost device is not polled + /// again; a poll that panics counts as one and stops the GPU. + pub(crate) fn poll(&self, t: wgpu::PollType) -> Result { + if DEVICE_LOST.load(Ordering::Relaxed) { + return Err("the device is lost".into()); + } + let polled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + #[cfg(test)] + if SIMULATED_LOST_POLL.with(std::cell::Cell::get) { + panic!("Error in Device::poll: Validation Error\n\nCaused by:\n Parent device is lost (simulated)"); + } + self.device.poll(t) + })); + match polled { + Ok(r) => r.map_err(|e| e.to_string()), + Err(_) => { + DEVICE_LOST.store(true, Ordering::Relaxed); + crate::mark_broken("device lost: Device::poll failed"); + Err("the device is lost (Device::poll failed)".into()) + } } } @@ -558,10 +596,11 @@ impl Gpu { fail(FailKind::Fatal, format!("readback failed: {why}")); zeros() }; - // A lost device makes the wait fail and the map callback never run: checking both keeps - // this from blocking forever (or handing back an unwritten buffer as the image). - if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) { - return failed(format!("{e}")); + // A lost device makes the wait fail ([`Gpu::poll`] turns wgpu's panic into the error) and + // the map callback never run: checking both keeps this from blocking forever (or handing + // back an unwritten buffer as the image). + if let Err(e) = self.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) { + return failed(e); } // another thread's poll may be the one that runs the callback: allow it a moment match rx.recv_timeout(std::time::Duration::from_secs(10)) { @@ -734,6 +773,147 @@ mod tests { assert_eq!(super::parse_limits("lots"), None); } + /// Run `child` (an ignored test in this module) in its own process: it loses the device, which + /// no other test running beside it may see. Returns its stdout, or `None` without a GPU. + fn run_child(child: &str) -> Option { + let exe = std::env::current_exe().unwrap(); + // output goes to files: a pipe nobody reads while we wait could fill and stall the child + let log = |stream: &str| std::env::temp_dir().join(format!("lc-gpu-{child}-{}.{stream}", std::process::id())); + let (out_path, err_path) = (log("out"), log("err")); + let mut proc = std::process::Command::new(exe) + .args(["--exact", &format!("ctx::tests::{child}"), "--ignored", "--nocapture", "--test-threads=1"]) + .stdout(std::fs::File::create(&out_path).unwrap()) + .stderr(std::fs::File::create(&err_path).unwrap()) + .spawn() + .unwrap(); + // a driver that stalls must fail the test, not hang CI + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = proc.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = proc.kill(); + let _ = proc.wait(); + panic!("{child}: still running after 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let stdout = std::fs::read_to_string(&out_path).unwrap_or_default(); + let stderr = std::fs::read_to_string(&err_path).unwrap_or_default(); + let _ = (std::fs::remove_file(&out_path), std::fs::remove_file(&err_path)); + if stdout.contains("CHILD: no GPU") { + return None; + } + // an abort (SIGABRT, "panic in a destructor during cleanup") has no exit code + assert!(status.success(), "{child}: {status:?}\n{stdout}\n{stderr}"); + assert!(stdout.contains("CHILD: survived"), "{child}: {stdout}"); + Some(stdout) + } + + /// Issue #1858: once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of + /// returning an error. A render's readback panicked; while it unwound, a buffer's way back to + /// the pool polls again from a destructor, and that second panic aborted the app. + #[test] + fn a_poll_on_a_lost_device_neither_panics_nor_aborts() { + if run_child("lost_poll_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + } + } + + /// A Metal device really lost (its command buffers exhausted) is never polled again. + #[test] + fn a_really_lost_device_is_not_polled_again() { + if run_child("really_lost_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device, or one that can't be lost on purpose here"); + } + } + + /// A GPU retired by another failure (a validation error) keeps a live device: its buffers are + /// still released, which takes a poll. + #[test] + fn a_retired_but_live_device_is_still_polled() { + if run_child("retired_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + } + } + + #[test] + #[ignore = "run in its own process by a_retired_but_live_device_is_still_polled"] + fn retired_child() { + let Some(g) = crate::device() else { + println!("CHILD: no GPU"); + return; + }; + crate::mark_broken("device error: simulated validation error"); + assert!(!crate::available()); + drop(g.upload(&[1u32; 1024])); + g.poll(wgpu::PollType::Poll).unwrap(); + println!("CHILD: survived"); + } + + /// Empties the buffer pool when dropped, as the UI does (`trim_pool(0)`) and as returning a + /// buffer to a full pool does. + struct TrimOnDrop(&'static super::Gpu); + + impl Drop for TrimOnDrop { + fn drop(&mut self) { + self.0.trim(0); + } + } + + #[test] + #[ignore = "run in its own process by a_poll_on_a_lost_device_neither_panics_nor_aborts"] + fn lost_poll_child() { + let Some(g) = crate::device() else { + println!("CHILD: no GPU"); + return; + }; + let src = g.upload(&[1u32; 1024]); + drop(g.upload(&[2u32; 1024])); // pooled: trimming it polls + super::SIMULATED_LOST_POLL.with(|c| c.set(true)); + // a render (caught as `try_render` catches it) whose readback finds the device lost + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _trim = TrimOnDrop(g); + let v: Vec = g.finish_and_read(g.encoder(), &src, 1024); + assert!(v.iter().all(|x| *x == 0), "a failed readback hands back zeros"); + })); + assert!(r.is_ok(), "the lost device fails the render without a panic"); + assert!(super::failed(), "the render failed, so it is redone on the CPU"); + assert!(!crate::available(), "the GPU is not used again"); + // a render that panics for another reason still unwinds through a destructor that polls + drop(g.upload(&[3u32; 1024])); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _trim = TrimOnDrop(g); + panic!("a render panics (expected in this test)"); + })); + assert!(r.is_err()); + println!("CHILD: survived"); + } + + #[test] + #[ignore = "run in its own process by a_really_lost_device_is_not_polled_again"] + fn really_lost_child() { + let Some(g) = crate::device().filter(|g| g.info.backend == wgpu::Backend::Metal) else { + println!("CHILD: no GPU"); + return; + }; + // wgpu-hal's Metal backend treats a command buffer past its 4096 unsubmitted as a lost device + let b = g.upload(&[1u32; 16]); + let held: Vec = (0..4200) + .map(|_| { + let mut e = g.encoder(); + e.clear_buffer(b.raw(), 0, None); + e.finish() + }) + .collect(); + assert!(crate::unavailable_reason().is_some_and(|r| r.contains("device lost")), "{:?}", crate::unavailable_reason()); + let e = g.poll(wgpu::PollType::Poll).unwrap_err(); + assert!(e.contains("lost"), "{e}"); + drop(held); + println!("CHILD: survived"); + } + #[test] fn dispatches_stay_within_the_workgroup_limit() { // 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b0409962d..7efae6a57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -75,12 +75,8 @@ fn record_fallback(reason: String) { /// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …". /// Never blocks. pub fn unavailable_reason() -> Option { - if env_disabled() { - #[cfg(not(target_arch = "wasm32"))] - if backend::env_off() { - return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()); - } - return Some("disabled by LIGHTCRAFT_GPU=0".into()); + if let Some(why) = env_off_reason() { + return Some(why.into()); } if !ENABLED.load(Ordering::Relaxed) { return Some("disabled by the GPU rendering preference (app.gpu)".into()); @@ -245,12 +241,26 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool { /// environment switches and the rendering preference, but not a render-device failure (the devices are separate). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn switched_off() -> Option { - if env_disabled() { - return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); + if let Some(why) = env_off_reason() { + return Some(why.into()); } (!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into()) } +/// Why the environment switches the GPU off for the whole process (`LIGHTCRAFT_GPU=0` or +/// `LIGHTCRAFT_GPU_BACKEND=off`), or `None`. Code with a GPU path of its own (the SAM 3 model on +/// Metal) follows it too. +pub fn env_off_reason() -> Option<&'static str> { + if !env_disabled() { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + if backend::env_off() { + return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off"); + } + Some("disabled by LIGHTCRAFT_GPU=0") +} + /// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process. fn env_disabled() -> bool { static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 5872bfaa9..55420598b 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -161,7 +161,8 @@ struct ConvParams { cout: u32, leaky: u32, alpha: f32, - pad: u32, + /// Always 0: the kernel steps its output stores by `1 + zero`, so FXC cannot merge them (`nn_conv.wgsl`). + zero: u32, } #[repr(C)] @@ -349,7 +350,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec) cout: c.cout as u32, leaky: u32::from(c.leaky.is_some()), alpha: c.leaky.unwrap_or(0.0), - pad: 0, + zero: 0, }; let src = get(&place, c.src)?; let src2 = c.src2.map(|t| get(&place, t)).transpose()?; @@ -416,7 +417,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec) cout: c.cout as u32, leaky: u32::from(c.leaky.is_some()), alpha: c.leaky.unwrap_or(0.0), - pad: 0, + zero: 0, }; let src = get(&place, c.src)?; let slot = alloc((4 * m * c.cout * 4) as u64, &mut slots, &mut free); @@ -920,6 +921,44 @@ mod tests { a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale } + /// A 1 × 1 convolution from 4 channels to `cout` that copies input channel `co % 4` to each output channel `co`. + fn copy(src: usize, cout: usize) -> Op { + let mut weight = vec![0f32; cout * 4]; + for co in 0..cout { + weight[co * 4 + co % 4] = 1.0; + } + Op::Conv(lightcraft_denoise::net::Conv { src, src2: None, cin: 4, cout, k: 1, weight, bias: vec![0.0; cout], leaky: None }) + } + + /// Two 1 × 1 layers that only copy channels, so the right answer is exact: the first layer's stores (mode 0) went + /// wrong on DX12 with FXC on an NVIDIA GTX 1070, where every second pixel came out with its channels shifted. + #[test] + fn a_layer_stores_every_pixels_channels_where_the_next_reads_them() { + let n = Net::new(4, vec![copy(0, 4), copy(1, 12), Op::DepthToSpace2 { src: 2 }]).unwrap(); + let Some(g) = gpu(&n, 3) else { return }; + let x: Vec = (1..=36).map(|i| i as f32).collect(); + assert_eq!(g.run(&x).unwrap(), reference::run(&n, 3, &x).unwrap()); + } + + /// The same for a transposed convolution's stores (mode 1): a max-pool halves the tile, and the 2 × 2 transposed + /// convolution copies each pooled pixel's channels to the four pixels it becomes. + #[test] + fn a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them() { + use lightcraft_denoise::net::ConvT2; + // [cin][cout][2][2]: channel c to channel c at all four places + let mut weight = vec![0f32; 4 * 4 * 4]; + for c in 0..4 { + for q in 0..4 { + weight[(c * 4 + c) * 4 + q] = 1.0; + } + } + let up = Op::ConvT2(ConvT2 { src: 1, cin: 4, cout: 4, weight, bias: vec![0.0; 4], leaky: None }); + let n = Net::new(4, vec![Op::MaxPool2 { src: 0 }, up, copy(2, 12), Op::DepthToSpace2 { src: 3 }]).unwrap(); + let Some(g) = gpu(&n, 6) else { return }; + let x: Vec = (1..=144).map(|i| i as f32).collect(); + assert_eq!(g.run(&x).unwrap(), reference::run(&n, 6, &x).unwrap()); + } + #[test] fn the_gpu_gives_what_the_reference_gives() { // (tile, channels, depth): a tile that is not a multiple of the block size, odd channel counts, a deeper net diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index b747e0d32..0c5bd19f0 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -180,7 +180,7 @@ impl<'a> Cx<'a> { if let Some(enc) = self.enc.take() { self.gpu.submit([enc.finish()]); } - let _ = self.gpu.device.poll(wgpu::PollType::wait_indefinitely()); + let _ = self.gpu.poll(wgpu::PollType::wait_indefinitely()); } /// A zero-filled buffer (the clear is recorded). diff --git a/crates/gpu/src/wgsl/nn_conv.wgsl b/crates/gpu/src/wgsl/nn_conv.wgsl index 20a0ce996..ee35e88ef 100644 --- a/crates/gpu/src/wgsl/nn_conv.wgsl +++ b/crates/gpu/src/wgsl/nn_conv.wgsl @@ -27,7 +27,8 @@ struct P { cout: u32, leaky: u32, alpha: f32, - pad: u32, + // always 0: the output stores step by 1u + zero, see the end of main + zero: u32, } @group(0) @binding(0) var p: P; @@ -122,6 +123,11 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t return; } let bv = bias[n / 4u]; + // Four channels that follow each other are stored one by one, `one` apart. With a literal 1u, FXC + // (DX12) merges them into one 16-byte store, and NVIDIA's DX12 driver ran mode 0's wrong on a + // GTX 1070: every second pixel's channels came out shifted. Mode 1's merged store ran right + // there, but it is the same instruction, so it steps the same way. FXC cannot see that `one` is 1. + let one = 1u + p.zero; for (var i = 0u; i < 4u; i++) { let m = m0 + ty * 4u + i; if (m >= hw) { @@ -135,9 +141,9 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t if (p.mode == 0u) { let o = m * p.n + n; dst[o] = v.x; - dst[o + 1u] = v.y; - dst[o + 2u] = v.z; - dst[o + 3u] = v.w; + dst[o + one] = v.y; + dst[o + 2u * one] = v.z; + dst[o + 3u * one] = v.w; } else if (p.mode == 1u) { let q = n / p.cout; let co = n % p.cout; @@ -146,9 +152,9 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t let opix = (2u * y + q / 2u) * (2u * p.w) + 2u * x + q % 2u; let o = opix * p.cout + co; dst[o] = v.x; - dst[o + 1u] = v.y; - dst[o + 2u] = v.z; - dst[o + 3u] = v.w; + dst[o + one] = v.y; + dst[o + 2u * one] = v.z; + dst[o + 3u * one] = v.w; } else { let c = n / 4u; let y = m / p.w; diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/it/equivalence.rs similarity index 100% rename from crates/gpu/tests/equivalence.rs rename to crates/gpu/tests/it/equivalence.rs diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/it/fallback.rs similarity index 100% rename from crates/gpu/tests/fallback.rs rename to crates/gpu/tests/it/fallback.rs diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/it/gpu_probe.rs similarity index 97% rename from crates/gpu/tests/gpu_probe.rs rename to crates/gpu/tests/it/gpu_probe.rs index e006fbc80..4bed20d1b 100644 --- a/crates/gpu/tests/gpu_probe.rs +++ b/crates/gpu/tests/it/gpu_probe.rs @@ -1,7 +1,7 @@ //! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of //! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; //! xtask runs it after the workspace tests, so it costs no extra build: -//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! `cargo test --workspace --test it -- --ignored --exact gpu_probe::gpu_probe --nocapture`. //! //! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, //! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build @@ -11,7 +11,6 @@ //! a driver that crashes while it is enumerated (issue #136) costs only its own line. //! //! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. -#![cfg(not(target_arch = "wasm32"))] use lightcraft_gpu::backend::{self, Backends}; diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs new file mode 100644 index 000000000..d26d1efc9 --- /dev/null +++ b/crates/gpu/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The integration tests, one module per file, linked into one test binary. +//! Binaries of their own: `tests/shutdown.rs` and `tests/shutdown_first.rs` close the GPU for the whole +//! process, and `tests/memory.rs` checks the process's device-memory counters, which renders on other test +//! threads would change. + +mod equivalence; +mod fallback; +#[cfg(not(target_arch = "wasm32"))] +mod gpu_probe; diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs index 185ae09cc..93b75f68b 100644 --- a/crates/lensdb/src/lib.rs +++ b/crates/lensdb/src/lib.rs @@ -169,13 +169,24 @@ impl LensDb { /// The best profile for an EXIF lens model (and lens make, when the file has one), preferring lenses for the /// camera's mount. `None` when no name is a convincing match. pub fn find_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>) -> Option<&Profile> { + self.best_lens(lens_make, lens_model, camera, |_| true) + } + + /// [`find_lens`](Self::find_lens) among the profiles one of whose names is exactly `name` + /// ([`Profile::names_exactly`]), for names that carry no maker, such as [`spec_name`]s: another maker's lens with + /// the same specification scores as high, and must neither win nor, by coming first, hide the profile named so. + pub fn find_lens_named(&self, lens_make: Option<&str>, name: &str, camera: Option<&Camera>) -> Option<&Profile> { + self.best_lens(lens_make, name, camera, |l| l.names_exactly(name)) + } + + fn best_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>, eligible: impl Fn(&Profile) -> bool) -> Option<&Profile> { let exif = tokens(lens_model); if exif.is_empty() { return None; } let make = lens_make.map(tokens).unwrap_or_default(); let mut best: Option<(f64, &Profile)> = None; - for lens in &self.lenses { + for lens in self.lenses.iter().filter(|l| eligible(l)) { let maker = tokens(&lens.maker); if !make.is_empty() && !maker.is_empty() && !maker_matches(&maker, &make) { continue; @@ -372,7 +383,30 @@ fn by_focal(points: &[(f64, T)], focal: f64, eval: impl Fn(&T) -> f64) Some(a + (b - a) * t) } +/// A lens name built from a lens specification (min / max focal length, f-number at each), for files that name +/// no lens model, only its specification (Nikon DSLRs' maker note): "24-70mm f/2.8", "18-55mm f/3.5-5.6", +/// "85mm f/1.8". Such a name says nothing about the maker, so [`Profile::names_exactly`] must confirm a match. +pub fn spec_name(spec: [f64; 4]) -> String { + fn num(v: f64) -> String { + let t = format!("{v:.1}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() + } + fn range(a: f64, b: f64) -> String { + if (a - b).abs() < 0.05 { num(a) } else { format!("{}-{}", num(a), num(b)) } + } + format!("{}mm f/{}", range(spec[0], spec[1]), range(spec[2], spec[3])) +} + impl Profile { + /// Whether one of the profile's names is exactly `name` (same words and numbers, ignoring case and + /// punctuation): the check for names that carry no maker, such as [`spec_name`]s, where the best partial match + /// could be another maker's lens with the same focal range. The maker's words count too, so "Samyang 85mm f/1.4" + /// is not "85mm f/1.4"; only a name given for the purpose (a user's alias) matches. + pub fn names_exactly(&self, name: &str) -> bool { + let want = tokens(name); + !want.is_empty() && self.models.iter().any(|m| tokens(m) == want) + } + /// Distortion ratio `r_d / r_u` at lensfun radius `r` for `focal` (1 when the profile has none). pub fn distortion_ratio(&self, focal: f64, r: f64) -> f64 { if !self.rectilinear { diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs index d15106931..435882381 100644 --- a/crates/lensdb/src/tests.rs +++ b/crates/lensdb/src/tests.rs @@ -243,3 +243,47 @@ fn tokens_split_names_like_exif_writes_them() { assert_eq!(tokens("FE 24-105mm F4.0 G OSS"), ["fe", "24", "105", "mm", "f", "4", "g", "oss"]); assert_eq!(tokens("f/2.8"), ["f", "2.8"]); } + +#[test] +fn spec_names_read_like_lens_names() { + assert_eq!(spec_name([24.0, 70.0, 2.8, 2.8]), "24-70mm f/2.8"); + assert_eq!(spec_name([18.0, 55.0, 3.5, 5.6]), "18-55mm f/3.5-5.6"); + assert_eq!(spec_name([85.0, 85.0, 1.8, 1.8]), "85mm f/1.8"); + assert_eq!(spec_name([10.5, 10.5, 2.8, 2.8]), "10.5mm f/2.8"); +} + +#[test] +fn spec_names_need_an_exact_profile_name() { + let db = db(); + let lens = db.find_lens(None, "24-70mm f/2.8", None).expect("a partial match"); + // the profile's names carry more words than the specification, so the maker-less name is not confirmed + assert!(!lens.names_exactly("24-70mm f/2.8")); + assert!(lens.names_exactly("testcam 24-70MM F/2.8 zoom"), "case and punctuation don't matter"); + assert!(!lens.names_exactly("24-70mm f/2.8 Zoom"), "the maker's words count: another maker's lens isn't this one"); + let mut aliased = lens.clone(); + aliased.models.push("24-70mm f/2.8".into()); + assert!(aliased.names_exactly(&spec_name([24.0, 70.0, 2.8, 2.8]))); + assert!(!aliased.names_exactly("24-105mm f/4")); +} + +/// Another maker's lens with the same specification scores as high as the aliased one; coming first in the +/// database it must not hide the profile named for the specification. +#[test] +fn a_named_profile_is_found_behind_a_tie() { + let lens = |maker: &str, models: &str| { + format!( + r#"{maker}{models}Test Mount1 + "# + ) + }; + let xml = format!( + r#"{}{}"#, + lens("Otherco", "Otherco 18-55mm f/3.5-5.6"), + lens("Testcam", "Testcam 18-55mm f/3.5-5.6 VR18-55mm f/3.5-5.6"), + ); + let db = LensDb::parse(&xml).unwrap(); + let name = spec_name([18.0, 55.0, 3.5, 5.6]); + assert_eq!(db.find_lens(None, &name, None).unwrap().maker, "Otherco", "a plain lookup keeps the first of a tie"); + assert_eq!(db.find_lens_named(None, &name, None).unwrap().maker, "Testcam"); + assert!(db.find_lens_named(None, "70-200mm f/4", None).is_none()); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0c59afa6a..6a2c2efb9 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -193,6 +193,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; fn with_reply(reply: &[u8], check: impl FnOnce(&mut Remote, Result)) { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let reply = reply.to_vec(); @@ -307,6 +308,7 @@ mod tests { #[test] fn consecutive_replies_match_their_requests_on_one_connection() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let worker = std::thread::spawn(move || { @@ -331,6 +333,7 @@ mod tests { #[test] fn lost_reply_is_not_resent() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(AtomicUsize::new(0)); @@ -353,6 +356,7 @@ mod tests { #[test] fn connection_closed_by_app_reconnects_before_sending() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(AtomicUsize::new(0)); @@ -376,6 +380,53 @@ mod tests { assert_eq!(seen.load(Ordering::SeqCst), 2); } + #[test] + fn idle_close_reconnects_without_replaying_a_command() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let (closed, expired) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut ids = Vec::new(); + for _ in 0..2 { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(e) => panic!("accept: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + ids.push(request["id"].as_u64().unwrap()); + writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ids.len()})).unwrap(); + writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap(); + line.clear(); + let error = reader.read_line(&mut line).unwrap_err(); + assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut)); + assert!(line.is_empty(), "no command is resent while idle"); + writeln!(writer, "{}", json!({"ok": false, "error": "control connection idle read timed out; closing the connection"})).unwrap(); + drop(reader); + drop(writer); + closed.send(()).unwrap(); + } + ids + }); + let mut remote = Remote::lazy(&addr); + assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(1)); + expired.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(2)); + expired.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(server.join().unwrap(), [1, 2], "each command ran once on a separate connection"); + } + #[test] fn unreachable_app_is_reported() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 7c9af704d..d62a9a707 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -61,16 +61,13 @@ impl Headless { /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch}; - let p = &self.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; + let p = &self.session.export_params_or(p, ExportFormat::Png)?; + // PNG for a `path` that names no format; `bitDepth` is checked against the format written + let mut opts = ExportOptions::from_params_or(p, ExportFormat::Png).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); } let exact = p.get("path").and_then(Value::as_str); - if let (Some(path), None) = (exact, p.get("format")) { - let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png); - } let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), // like the desktop app: the selection (in grid order), else the given / active photo @@ -135,7 +132,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } @@ -206,6 +203,28 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + #[test] + fn folder_discovery_matches_engine_extensions() { + let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let nested = dir.join("trip/day2"); + std::fs::create_dir_all(&nested).unwrap(); + let mut expected = Vec::new(); + for ext in lightcraft_engine::import::EXTENSIONS { + for (folder, name) in [(&dir, format!("lower.{ext}")), (&nested, format!("upper.{}", ext.to_ascii_uppercase()))] { + let path = folder.join(name); + std::fs::write(&path, b"discovery only, not a decoded photo").unwrap(); + expected.push(path.to_string_lossy().into_owned()); + } + } + std::fs::write(nested.join("notes.txt"), b"not a photo").unwrap(); + let mut found = expand_paths(&[dir.to_string_lossy().into_owned()]); + expected.sort(); + found.sort(); + assert_eq!(found, expected); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619). #[test] fn headless_sessions_get_the_sam3_model_folder() { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f632f145b..4f9251bcb 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -33,3 +33,14 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7980"; #[cfg(test)] mod tests; + +/// Held by tests that start a child process (a copy of the test binary) and by tests that run a +/// loopback socket server, so the two never overlap. On Windows the child can inherit a socket the +/// server just accepted; when the child exits, Windows closes its copy abortively and the test's +/// connection is reset ("An existing connection was forcibly closed"), which failed the +/// `backend` reply tests whenever they ran alongside a child-process test. +#[cfg(test)] +pub(crate) fn serial_sockets_and_children() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/progress_tests.rs index fb7859dc5..29db30a89 100644 --- a/crates/mcp/src/progress_tests.rs +++ b/crates/mcp/src/progress_tests.rs @@ -100,6 +100,7 @@ fn progress_flush_failure_does_not_wait_for_input_eof() { } fn run_shutdown_case(mode: &str) { + let _serial = crate::serial_sockets_and_children(); let mut child = Command::new(std::env::current_exe().unwrap()) .args(["--exact", "server::progress::shutdown_tests::output_failure_child", "--ignored", "--nocapture"]) .env("LIGHTCRAFT_MCP_SHUTDOWN_CASE", mode) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 99193b480..59e7ba3eb 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -147,6 +147,43 @@ fn export_refuses_unknown_params_and_bad_values() { assert!(e.contains("did you mean `quality`"), "{e}"); } +/// Review of #1966: a `path` whose extension names no format exports PNG through the headless +/// backend, so its `bitDepth` is checked against PNG's, not let through to an 8-bit file. +#[test] +fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + for name in ["output", "output.xyz"] { + let e = b.call("app.export", json!({"path": base.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10})).unwrap_err(); + assert!(e.contains("`bitDepth` must be 8 or 16 for png"), "{name}: {e}"); + } + assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still PNG, and 16 bits is one of its depths + b.call("app.export", json!({"path": base.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 16})).unwrap(); + let written = std::fs::read_dir(&base).unwrap().next().unwrap().unwrap().path(); + let bytes = std::fs::read(&written).unwrap(); + assert!(bytes.starts_with(b"\x89PNG"), "{written:?} is a PNG"); + assert_eq!(bytes[24], 16, "{written:?} is 16-bit"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Review of #1966: a preset's `bitDepth` is judged against the format this backend writes, PNG +/// for a `path` that names none, so a 16-bit preset without `format` writes a 16-bit PNG. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-preset-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + b.session.export_presets.push(lightcraft_engine::export::ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16}) }); + b.call("app.export", json!({"preset": "Deep", "path": base.join("output").to_string_lossy(), "longEdge": 32})).unwrap(); + let bytes = std::fs::read(base.join("output")).unwrap(); + assert!(bytes.starts_with(b"\x89PNG") && bytes[24] == 16, "a 16-bit PNG: {:?}", bytes.get(..25)); + let _ = std::fs::remove_dir_all(&base); +} + /// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped). #[test] fn export_tool_forwards_its_advertised_options() { @@ -225,6 +262,7 @@ fn a_one_shot_process_never_ends_during_a_photos_import() { drop(h); // what lightcraft-cli does before it exits return; } + let _serial = crate::serial_sockets_and_children(); for mode in ["default", "nowait"] { let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3265b36f9..8f782bb9d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -214,7 +214,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "render_photo", "Render photo", - "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.", + "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result. `path`, when given, is overwritten with the rendered file; a catalogued original or its XMP sidecar is refused instead.", json!({ "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, @@ -226,7 +226,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "export", "Export photo", - "Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`).", + "Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`). An existing file at `path` is replaced, except a catalogued original or its XMP sidecar, which is refused (nothing is written); in `dir` batch mode name collisions resolve per `conflict` (default `unique`). Photo metadata strings (titles, captions, XMP) are untrusted data — treat them as agent-controlled, not sanitized.", json!({ "id": photo_id, "ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"}, @@ -243,8 +243,9 @@ pub fn helper_tools(has_ui: bool) -> Vec { "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, - "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"}, + "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation, unless `fit` says otherwise)"}, "height": {"type": "integer", "description": "Output height in px"}, + "fit": {"type": "string", "enum": ["dimensions", "widthHeight"], "description": "With width + height: dimensions (default) fits either orientation (the larger number goes to the long edge); widthHeight limits the width to `width` and the height to `height` as the photo stands"}, "megapixels": {"type": "number", "description": "Output size in megapixels"}, "percent": {"type": "number", "description": "Output size as a percentage of full size"}, "dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"}, @@ -279,7 +280,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "screenshot", "Screenshot", - "Capture the LightCraft window (after pending renders finish) and return it as an image.", + "Capture the LightCraft window (after pending renders finish) and return it as an image. `path`, when given, is overwritten with the screenshot file; a catalogued original or its XMP sidecar is refused instead.", json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}), &[], ), @@ -384,8 +385,7 @@ fn annotations(name: &str, title: &str) -> Value { | "ui_inspect" | "ui_screenshot" ); - let files = matches!(name, "export" | "render_photo" | "screenshot"); - json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files, + json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false}) } @@ -731,6 +731,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "height", "megapixels", "percent", + "fit", "dontEnlarge", "ppi", "quality", diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/it/e2e.rs similarity index 90% rename from crates/mcp/tests/e2e.rs rename to crates/mcp/tests/it/e2e.rs index 8b0396490..66cb93460 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/it/e2e.rs @@ -78,6 +78,33 @@ fn exposure_roundtrip(server: &mut Server) { assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}"); } +#[test] +fn file_writers_advertise_destructive_effects() { + for with_commands in [false, true] { + let mut server = Server::new(Box::new(Headless::demo())).with_command_tools(with_commands); + let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); + let tools = replies[0]["result"]["tools"].as_array().unwrap(); + for name in ["export", "render_photo"] { + let tool = tools.iter().find(|t| t["name"] == name).unwrap(); + assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}"); + assert_eq!(tool["annotations"]["destructiveHint"], true, "{name} can replace a file"); + } + let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap(); + assert_eq!(preview["annotations"]["readOnlyHint"], true); + assert_eq!(preview["annotations"]["destructiveHint"], false); + } + let addr = fake_app(); + let mut server = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); + let tools = replies[0]["result"]["tools"].as_array().unwrap(); + let screenshot = tools.iter().find(|t| t["name"] == "screenshot").unwrap(); + assert_eq!(screenshot["annotations"]["readOnlyHint"], false); + assert_eq!(screenshot["annotations"]["destructiveHint"], true); + let image_only = tools.iter().find(|t| t["name"] == "ui_screenshot").unwrap(); + assert_eq!(image_only["annotations"]["readOnlyHint"], true); + assert_eq!(image_only["annotations"]["destructiveHint"], false); +} + #[test] fn headless_set_exposure_and_render() { exposure_roundtrip(&mut Server::new(Box::new(Headless::demo()))); diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs new file mode 100644 index 000000000..7fce2464b --- /dev/null +++ b/crates/mcp/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod e2e; diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index c977c8b53..34a606155 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -13,13 +13,49 @@ use crate::ransac::{self, Model}; /// Long edge of the images features are detected on. pub const WORK_EDGE: usize = 1200; +/// The luminance merges align and weigh by (camera or linear RGB; not a colorimetric one). +#[inline] +pub(crate) fn luma(c: [f32; 3]) -> f32 { + 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2] +} + +/// The motion models a fit chooses among, simplest first: (model, name, fewest inliers). +pub type Models = [(Model, &'static str, usize)]; + +/// Of `models` fitted by RANSAC to `a` → `b` (`thresh` px; at least `share` of the points and each +/// model's minimum as inliers; plausible for a `w × h` frame), the simplest that explains (almost) as +/// many points as the one explaining the most: extra degrees of freedom fitted to clustered points +/// extrapolate badly. +pub(crate) fn choose_model( + models: &Models, + a: &[Point], + b: &[Point], + thresh: f64, + share: f64, + (w, h): (f64, f64), + seed: u64, +) -> Option<(&'static str, ransac::Fit)> { + let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new(); + for &(model, name, min) in models { + let need = ((a.len() as f64 * share).ceil() as usize).max(min); + if let Some(f) = ransac::ransac(model, a, b, thresh, need, seed) + && plausible(&f.h, w, h, 0.12) + { + fits.push((name, f)); + } + } + let most = fits.iter().map(|(_, f)| f.inliers.len()).max()?; + // the fit with `most` inliers always qualifies + fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) +} + /// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and /// the scale from full-resolution pixels to work pixels. pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) { let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() }; let scale = small.width as f64 / img.width as f64; let p = small.map(|c| { - let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain; + let y = luma(c) * gain; y.clamp(0.0, 1.0).powf(1.0 / 2.2) }); (p, scale) @@ -29,8 +65,7 @@ pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) { /// without EXIF. pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 { let step = (img.data.len() / 100_000).max(1); - let mut v: Vec = - img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect(); + let mut v: Vec = img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| luma(*c)).collect(); if v.is_empty() { return clip; } @@ -86,24 +121,13 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz let a: Vec = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect(); let b: Vec = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect(); let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale); - // Fit every model; keep the simplest one that explains (almost) as many matches as the most - // general one — extra degrees of freedom fitted to clustered points extrapolate badly. - let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new(); - for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] { - if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed) - && plausible(&f.h, ww, wh, 0.12) - { - fits.push((name, f)); - } - } - let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() }; - // the fit with `most` inliers always qualifies - let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() }; + let models = [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)]; + let Some((name, f)) = choose_model(&models, &a, &b, 1.5, 0.0, (ww, wh), seed) else { return Alignment::identity() }; Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale } } -/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and -/// a validity mask (false where `h(p)` falls outside `img`). +/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped +/// image and a validity mask (false where `h(p)` falls outside `img`). pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { let (w, ht) = (img.width, img.height); if *h == Homography::IDENTITY { @@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); let (sx, sy) = (p.x as f32, p.y as f32); if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { - row[x] = img.sample_bilinear(sx, sy); + row[x] = img.sample_lanczos3(sx, sy); vrow[x] = true; } } diff --git a/crates/merge/src/pano/finish.rs b/crates/merge/src/pano/finish.rs index ac57a18d0..8dc89e536 100644 --- a/crates/merge/src/pano/finish.rs +++ b/crates/merge/src/pano/finish.rs @@ -76,7 +76,7 @@ fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f, let src = lo[i] + t * (hi[i] - lo[i]); let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src; let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) }; - row[x] = img.sample_bilinear(sx, sy); + row[x] = img.sample_lanczos3(sx, sy); arow[x] = crate::features::bilinear(alpha, sx, sy); } }); diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 4211f6503..5ce439681 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -298,7 +298,7 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - let wx = 1.0 - (2.0 * px / fw - 1.0).abs(); let wy = 1.0 - (2.0 * py / fh - 1.0).abs(); let w = (wx * wy).max(1e-6) as f32; - row[tx] = fr.image.sample_bilinear(px as f32, py as f32); + row[tx] = fr.image.sample_lanczos3(px as f32, py as f32); wrow[tx] = w; } }); diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/it/hdr.rs similarity index 100% rename from crates/merge/tests/hdr.rs rename to crates/merge/tests/it/hdr.rs diff --git a/crates/merge/tests/it/main.rs b/crates/merge/tests/it/main.rs new file mode 100644 index 000000000..0859919cd --- /dev/null +++ b/crates/merge/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod hdr; +mod pano; +mod white_balance; diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/it/pano.rs similarity index 100% rename from crates/merge/tests/pano.rs rename to crates/merge/tests/it/pano.rs diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/it/white_balance.rs similarity index 100% rename from crates/merge/tests/white_balance.rs rename to crates/merge/tests/it/white_balance.rs diff --git a/crates/meta/Cargo.toml b/crates/meta/Cargo.toml index 0ef8903d2..22538aee8 100644 --- a/crates/meta/Cargo.toml +++ b/crates/meta/Cargo.toml @@ -13,6 +13,7 @@ lightcraft-tiff = { workspace = true } lightcraft-geom = { workspace = true } serde = { workspace = true } quick-xml = { workspace = true } +chrono = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 8dda6b55d..3ac60907f 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,7 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. @@ -36,6 +37,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded { _ => Embedded::default(), }; } + if let Some(m) = crate::mrw::parse(bytes) { + // Minolta MRW: the `TTW` block is a complete TIFF with the Exif data + return m + .block(b"TTW") + .filter(|t| Tiff::parse(t).is_ok()) + .map(|t| Embedded { exif: Some(t.to_vec()), ..Default::default() }) + .unwrap_or_default(); + } if let Some(c) = crate::cr3::parse_cr3(bytes) { return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; } diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index ffd2edd76..4b7d09634 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -28,21 +28,10 @@ impl DateTime { // A timestamp must represent a real Gregorian calendar day, not just // numbers in the range 1..=31. Invalid dates would otherwise be // accepted from EXIF/XMP and silently normalized during time arithmetic. - let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400)); - let max_day = match self.month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if leap => 29, - 2 => 28, - _ => 0, - }; - let ok = self.day >= 1 - && self.day <= max_day - && self.hour < 24 - && self.minute < 60 - && self.second < 61 - && self.year > 0 - && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); + // chrono rejects impossible days (leap years and all); the time is checked + // by hand so a leap second (`second == 60`) stays acceptable. + let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some(); + let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); ok.then_some(self) } @@ -153,13 +142,12 @@ impl DateTime { /// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds). pub fn unix_seconds(&self) -> i64 { - // days from civil (proleptic Gregorian), Howard Hinnant's public-domain algorithm - let (y, m, d) = (self.year as i64 - if self.month <= 2 { 1 } else { 0 }, self.month as i64, self.day as i64); - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146097 + doe - 719468; + // Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers); + // an out-of-range one (built by hand) reads as the epoch rather than panicking. + let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)) + .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) + .map(|(d, epoch)| (d - epoch).num_days()) + .unwrap_or(0); days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60 } } diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index 2b2ea1d13..cb45274d1 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -127,20 +127,32 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata { m } +/// Degrees/minutes/seconds triplet as decimal degrees. A component that is present but not a number (a rational +/// with a zero denominator, as in the placeholder GPS block phones write without a fix) makes the whole value unusable. fn dms(ifd: &Ifd, tag: u16) -> Option { - let v = ifd.f64s(tag)?; - let d = *v.first()?; - let deg = d + v.get(1).copied().unwrap_or(0.0) / 60.0 + v.get(2).copied().unwrap_or(0.0) / 3600.0; + let v = ifd.value(tag)?; + let part = |i: usize| if i < v.count() { v.get_f64(i) } else { Some(0.0) }; + let d = v.get_f64(0)?; + let deg = d + part(1)? / 60.0 + part(2)? / 3600.0; deg.is_finite().then_some(deg) } +/// `Some(true)` for the negative hemisphere (`neg`), `Some(false)` for `pos`, `None` when the ref is missing or invalid. +fn hemisphere(g: &Ifd, tag: u16, pos: char, neg: char) -> Option { + match g.string(tag)?.trim_start().chars().next()?.to_ascii_uppercase() { + c if c == neg => Some(true), + c if c == pos => Some(false), + _ => None, + } +} + fn read_gps(g: &Ifd) -> Option { let mut lat = dms(g, t::GPS_LATITUDE)?; let mut lon = dms(g, t::GPS_LONGITUDE)?; - if g.string(t::GPS_LATITUDE_REF).is_some_and(|s| s.starts_with('S')) { + if hemisphere(g, t::GPS_LATITUDE_REF, 'N', 'S')? { lat = -lat; } - if g.string(t::GPS_LONGITUDE_REF).is_some_and(|s| s.starts_with('W')) { + if hemisphere(g, t::GPS_LONGITUDE_REF, 'E', 'W')? { lon = -lon; } if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { @@ -382,6 +394,28 @@ pub(crate) mod tests { } } + #[test] + fn placeholder_gps_block_is_no_location() { + // Android phones without a fix write NUL refs and 0/0 rationals (#1617). + let read = |lat_ref: &str, lat: Vec<(u32, u32)>| { + let mut gps = IfdBuilder::new(); + gps.set(t::GPS_LATITUDE_REF, Value::Ascii(lat_ref.into())); + gps.set(t::GPS_LATITUDE, Value::Rational(lat)); + gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into())); + gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set_child(t::GPS_IFD, gps); + read_exif(&TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()).gps + }; + let valid = vec![(33, 1), (51, 1), (3564, 100)]; + assert!(read("N", valid.clone()).is_some()); + assert_eq!(read("N", vec![(0, 0), (0, 0), (0, 0)]), None, "0/0 rationals"); + assert_eq!(read("N", vec![(33, 1), (0, 0), (0, 0)]), None, "0/0 minutes"); + assert_eq!(read("", valid.clone()), None, "NUL ref"); + assert_eq!(read("", valid.clone()), None, "empty ref"); + assert_eq!(read("X", valid), None, "invalid ref"); + } + #[test] fn exif_prefix_and_garbage() { let mut b = b"Exif\0\0".to_vec(); @@ -453,6 +487,7 @@ pub fn write_exif(meta: &Metadata) -> Vec { (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]))), + (t::ISO_SPEED_RATINGS_EXT, meta.iso.filter(|&i| i > 65_534).map(|i| Value::Long(vec![i]))), (t::LENS_MAKE, ascii(&meta.lens_make)), (t::LENS_MODEL, ascii(&meta.lens_model)), (t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))), @@ -518,4 +553,12 @@ mod write_tests { let g = back.gps.unwrap(); assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}"); } + + #[test] + fn iso_above_65534_round_trips() { + for iso in [65_534, 65_535, 102_400, 409_600] { + let back = read_exif(&write_exif(&Metadata { iso: Some(iso), ..Default::default() })); + assert_eq!(back.iso, Some(iso)); + } + } } diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs index abfc9ebd4..204e39116 100644 --- a/crates/meta/src/gpx.rs +++ b/crates/meta/src/gpx.rs @@ -147,7 +147,7 @@ pub fn parse_gpx(s: &str) -> Result { match name.as_slice() { b"ele" if parent == Some(b"trkpt") => { if let Some(p) = pt.as_mut() { - p.ele = text.trim().parse().ok(); + p.ele = text.trim().parse().ok().filter(|v: &f64| v.is_finite()); } } b"time" if parent == Some(b"trkpt") => { @@ -351,6 +351,18 @@ mod tests { assert_eq!(log.points[1].time - log.points[0].time, 60.0); } + #[test] + fn non_finite_elevation_is_dropped() { + for ele in ["inf", "-inf", "NaN", "infinity"] { + let s = format!( + r#"{ele}"# + ); + let log = parse_gpx(&s).unwrap(); + assert_eq!(log.points.len(), 1, "{ele}"); + assert_eq!(log.points[0].elevation, None, "{ele}"); + } + } + #[test] fn interpolation_across_the_antimeridian() { let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 }; diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index a8c043153..59062dfda 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -19,6 +19,7 @@ pub mod focus; mod focus_read; mod gpx; mod iptc; +pub mod mrw; pub mod tags; mod xmp; mod xmp_merge; diff --git a/crates/meta/src/mrw.rs b/crates/meta/src/mrw.rs new file mode 100644 index 000000000..ef641c44d --- /dev/null +++ b/crates/meta/src/mrw.rs @@ -0,0 +1,93 @@ +//! Minolta / Konica Minolta MRW block container. +//! +//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box +//! analysis of the CC0 samples on raw.pixls.us. The file starts with `\0MRM` and a big-endian `u32` length; the +//! sensor data begins right after that many bytes (at `8 + length`). In between is a sequence of blocks, each a +//! four-byte name (`\0PRD`, `\0TTW`, `\0WBG`, `\0RIF`, `\0PAD`), a big-endian `u32` length and that many bytes. +//! `\0TTW` holds a complete big-endian TIFF (IFD0, Exif, maker note) whose offsets are relative to the block. + +/// The blocks and the offset of the sensor data in an MRW file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Mrw<'a> { + /// `(name without the leading NUL, contents)`, in file order. + pub blocks: Vec<([u8; 3], &'a [u8])>, + /// Absolute offset of the sensor data. + pub data_offset: usize, +} + +impl<'a> Mrw<'a> { + /// The first block called `name` (e.g. `b"PRD"`). + pub fn block(&self, name: &[u8; 3]) -> Option<&'a [u8]> { + self.blocks.iter().find(|(n, _)| n == name).map(|(_, d)| *d) + } +} + +/// At most this many blocks are walked (real files have four or five). +const MAX_BLOCKS: usize = 64; + +fn be32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize) +} + +/// Parse the block list of an MRW file. `None` when the magic is missing or the header length points past the +/// file; a block that runs past the header is dropped together with everything after it. +pub fn parse(bytes: &[u8]) -> Option> { + if !bytes.starts_with(b"\0MRM") { + return None; + } + let data_offset = be32(bytes, 4)?.checked_add(8).filter(|&o| o <= bytes.len())?; + let mut blocks = Vec::new(); + let mut at = 8usize; + while blocks.len() < MAX_BLOCKS && at.saturating_add(8) <= data_offset { + let (Some(name), Some(len)) = (bytes.get(at..at + 4), be32(bytes, at + 4)) else { break }; + let start = at + 8; + let Some(end) = start.checked_add(len).filter(|&e| e <= data_offset) else { break }; + let Some(data) = bytes.get(start..end) else { break }; + if name[0] == 0 { + blocks.push(([name[1], name[2], name[3]], data)); + } + at = end; + } + Some(Mrw { blocks, data_offset }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(name: &[u8; 3], data: &[u8]) -> Vec { + let mut v = vec![0]; + v.extend_from_slice(name); + v.extend_from_slice(&(data.len() as u32).to_be_bytes()); + v.extend_from_slice(data); + v + } + + #[test] + fn walks_blocks_and_finds_the_data() { + let mut body = block(b"PRD", &[1, 2, 3]); + body.extend(block(b"WBG", &[4; 12])); + let mut f = b"\0MRM".to_vec(); + f.extend_from_slice(&(body.len() as u32).to_be_bytes()); + f.extend_from_slice(&body); + f.extend_from_slice(&[9, 9]); + let m = parse(&f).unwrap(); + assert_eq!(m.block(b"PRD"), Some(&[1u8, 2, 3][..])); + assert_eq!(m.block(b"WBG").map(<[u8]>::len), Some(12)); + assert_eq!(m.block(b"TTW"), None); + assert_eq!(m.data_offset, f.len() - 2); + } + + #[test] + fn hostile_lengths_never_panic() { + assert_eq!(parse(b"\0MRM"), None); + assert_eq!(parse(b"\0MRM\xff\xff\xff\xff"), None); + // a block claiming more than the header holds is dropped + let f = b"\0MRM\0\0\0\x0c\0PRD\xff\xff\xff\xff\0\0\0\0"; + assert_eq!(parse(f).map(|m| m.blocks.len()), Some(0)); + for n in 0..f.len() { + let _ = parse(&f[..n]); + } + } +} diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 3cbab5cc2..526348b15 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -145,7 +145,14 @@ fn rational(n: f64, d: f64) -> String { return "—".into(); } let v = n / d; - if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() } + if (v - v.round()).abs() < 1e-9 { + let r = v.round(); + if r == 0.0 { "0".to_string() } else { format!("{}", r) } + } else { + let s = format!("{:.4}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + } } fn list(v: &[T]) -> String { @@ -370,6 +377,16 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn rational_has_no_trailing_dot() { + assert_eq!(rational(1.0, 100000.0), "0"); + assert_eq!(rational(-1.0, 100000.0), "0"); + assert_eq!(rational(-1.0, 1e10), "0"); + assert_eq!(rational(1000001.0, 100000.0), "10"); + assert_eq!(rational(1.0, 3.0), "0.3333"); + assert_eq!(rational(5.0, 2.0), "2.5"); + } + #[test] fn rows_from_written_exif() { let m = crate::Metadata { diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index dbfdd5458..1fb789164 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -457,8 +457,12 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option String { let a = v.abs(); - let d = a.trunc(); - let m = (a - d) * 60.0; + let mut d = a.trunc(); + let mut m = ((a - d) * 60.0 * 1e6).round() / 1e6; + if m >= 60.0 { + d += 1.0; + m = 0.0; + } format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos }) } @@ -866,6 +870,8 @@ mod tests { assert_eq!(parse_gps_coord(""), None); assert_eq!(parse_gps_coord("é"), None); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); + assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); + assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); } /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. diff --git a/crates/meta/tests/it/main.rs b/crates/meta/tests/it/main.rs new file mode 100644 index 000000000..f56fd47f0 --- /dev/null +++ b/crates/meta/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod robust; diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/it/robust.rs similarity index 100% rename from crates/meta/tests/robust.rs rename to crates/meta/tests/it/robust.rs diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 455cec022..34df1f8a9 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -59,7 +59,7 @@ pub use tone::ToneMap; /// A raw source's own colour model, for white balance: like Lightroom, a white balance /// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG /// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]). -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CameraColor { /// The file's colour tags (its profile look left out: it is applied at load). pub tags: lightcraft_raw::ColorData, diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 679acabfe..ba0298226 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -490,9 +490,13 @@ pub struct PlaneSigmas { pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { let ppl = px_per_long as f32; - let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); - let tone_active = - s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); + // A hidden mask, or one with no components, does not change the picture. Its sliders must + // not pick the tone base Clarity reads (#1214). + let live = |m: &&lightcraft_develop::Mask| m.visible && !m.components.is_empty(); + let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().filter(live).any(|m| f(&m.adjust) != 0.0); + let tone_active = s.light.highlights != 0.0 + || s.light.shadows != 0.0 + || s.masks.iter().filter(live).any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved) // From V2 it is taken of the median at 0.15%: lines and texture finer than that are detail, // not tone (#632). @@ -507,10 +511,10 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS || local_any(|a| a.texture) || local_any(|a| a.sharpness) || local_any(|a| a.noise) - || s.masks.iter().any(|m| m.adjust.defringe > 0.0)) + || s.masks.iter().filter(live).any(|m| m.adjust.defringe > 0.0)) .then(|| (0.0018 * ppl).max(0.6)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); - let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); + let chroma = (local_any(|a| a.moire) || s.masks.iter().filter(live).any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; PlaneSigmas { base, base_detail, clarity, texture, dark, chroma } } @@ -689,6 +693,32 @@ mod tests { assert_eq!(frame_airlight(&hazy, &info, &s, &cropped, 48, 32).to_bits(), fresh(&hazy, &cropped).to_bits()); } + #[test] + fn a_hidden_or_empty_mask_does_not_request_the_tone_base() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shape = MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }; + let component = MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + visible: false, + components: vec![component], + adjust: lightcraft_develop::LocalAdjustments { highlights: 40.0, ..Default::default() }, + ..Default::default() + }); + assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none()); + s.masks[0].visible = true; + s.masks[0].components.clear(); + assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none()); + s.masks[0].components.push(MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, + }); + assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_some()); + } + #[test] fn exposure_doubles() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/local_tone.rs b/crates/pipeline/src/local_tone.rs index c38a25c9c..bb7adfe51 100644 --- a/crates/pipeline/src/local_tone.rs +++ b/crates/pipeline/src/local_tone.rs @@ -19,7 +19,7 @@ use crate::{Plan, SourceInfo}; use lightcraft_develop::DevelopSettings; /// A raw's gain table map and where its developed source sits in the active area. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LocalTone { pub map: GainTableMap, pub placement: SourcePlacement, diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index f2aac5881..36cc29192 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -25,6 +25,33 @@ pub enum MatteKind { Glasses, } +impl MatteKind { + /// A stable code for serialization (e.g. a smart preview's stored mattes); never renumber. + pub fn code(self) -> u8 { + match self { + MatteKind::Sky => 0, + MatteKind::Person => 1, + MatteKind::Skin => 2, + MatteKind::Hair => 3, + MatteKind::Teeth => 4, + MatteKind::Glasses => 5, + } + } + + /// [`MatteKind::code`]'s inverse (`None` for a code this build doesn't know). + pub fn from_code(code: u8) -> Option { + match code { + 0 => Some(MatteKind::Sky), + 1 => Some(MatteKind::Person), + 2 => Some(MatteKind::Skin), + 3 => Some(MatteKind::Hair), + 4 => Some(MatteKind::Teeth), + 5 => Some(MatteKind::Glasses), + _ => None, + } + } +} + /// Segmentation mattes that came with the source: 0..255 alpha planes, each covering the whole /// (EXIF-oriented) source image at its own resolution. Several mattes of one kind (e.g. one per /// person) are combined with max. @@ -56,6 +83,12 @@ impl Mattes { self.mattes.iter().map(|(_, m)| m.data.len()).sum() } + /// The stored mattes, in insertion order (kind and plane), e.g. for storing them with a + /// photo's proxy source. + pub fn iter(&self) -> impl Iterator)> { + self.mattes.iter().map(|(k, m)| (*k, m)) + } + fn of(&self, kinds: &[MatteKind]) -> Vec<&Image> { self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect() } @@ -484,6 +517,11 @@ pub fn push_pressures(pressure: &mut Vec, added: usize, from: f32, to: f32) /// pointer is held still (the same screen position mapped again, up to float rounding). const STATIONARY_PX: f64 = 1e-6; +/// Most dabs one stroke holds (32 MiB of centres). A segment is capped on its own, but a stroke of +/// many hostile segments (an agent, a crafted XMP or catalog) would otherwise add up without +/// bound; past this the rest of the stroke lays nothing (#1520). +pub const MAX_STROKE_DABS: usize = 2_000_000; + /// The dabs of stroke `s` in a `w × h` output. pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs { let to_out = frame.norm_to_out(w, h); @@ -537,11 +575,21 @@ pub fn dab_spacing(r: f64) -> f64 { /// their dabs with this, so they cover the same pixels (issue #517). A sample where the pointer /// hasn't moved (a stationary hold records one per frame) or that is not a finite point lays /// nothing, so holding the brush still adds no coverage (#518). Returns whether `q` was stamped; -/// callers move `prev` on only then. +/// callers move `prev` on only then. A stroke holding [`MAX_STROKE_DABS`] takes no more. pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) -> bool { - if !(q.x.is_finite() && q.y.is_finite()) { + let mut emitted = dabs.len(); + push_dabs_counted(dabs, &mut emitted, prev, q, r) +} + +/// Append one segment using a persistent count of all dabs emitted for this stroke, including +/// dabs no longer held in `dabs`. Incremental callers retain `emitted` when clearing their buffer; +/// it advances only by the number actually appended. Acceptance and spacing match [`push_dabs`]. +pub fn push_dabs_counted(dabs: &mut Vec, emitted: &mut usize, prev: Option, q: Point, r: f64) -> bool { + let room = MAX_STROKE_DABS.saturating_sub(*emitted); + if room == 0 || !(q.x.is_finite() && q.y.is_finite()) { return false; } + let before = dabs.len(); if let Some(prev) = prev { let dist = prev.dist(q); if dist.is_nan() || dist <= STATIONARY_PX { @@ -551,11 +599,14 @@ pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) - // a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge // allocation: 1 M dabs is far more than any on-screen stroke needs let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 }; + // the in-between dabs and `q` together fit what is left of the stroke's budget + let n = n.min(room); for k in 1..n { dabs.push(prev.lerp(q, k as f64 / n as f64)); } } dabs.push(q); + *emitted += dabs.len() - before; true } @@ -838,6 +889,17 @@ mod tests { } assert_eq!(committed.dabs, again); assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); // a hostile segment places no in-between dabs let mut v = Vec::new(); assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); @@ -846,6 +908,33 @@ mod tests { assert!(v.len() <= 1_000_002, "{}", v.len()); } + /// A temporary output buffer must spend the same stroke budget as a retained one. + #[test] + fn incremental_dab_buffers_share_the_stroke_budget() { + let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; + let mut retained = Vec::new(); + let mut streamed = Vec::new(); + let mut retained_count = MAX_STROKE_DABS - 3; + let mut streamed_count = retained_count; + let mut prev = None; + for q in points { + let mut chunk = Vec::new(); + let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); + assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); + streamed.extend(chunk); + if accepted { + prev = Some(q); + } + } + assert_eq!(retained, streamed); + assert_eq!(retained.len(), 3); + assert_eq!(retained_count, MAX_STROKE_DABS); + assert_eq!(streamed_count, retained_count); + assert_eq!(prev, Some(points[1])); + assert!(!retained.contains(&points[2])); + assert!(!retained.contains(&points[3])); + } + /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. #[test] diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 910705497..d7949315e 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -137,7 +137,8 @@ fn perf_report() { #[test] fn unedited_rendered_source_is_passthrough() { - // An sRGB ramp decoded to linear Rec.2020 must render back to (almost) the same 8-bit values. + // An sRGB ramp decoded to linear Rec.2020 must render back to the same 8-bit values, white + // included (issue #1945: neutral 255, 254 and 253 came back one level darker). let src8 = lightcraft_raster::Rgba8::from_fn(256, 4, |x, y| [x as u8, (x as u8).wrapping_add(y as u8 * 40), 255 - x as u8, 255]); let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020); let src = src8.to_linear().map(|c| m.apply_f32(c)); @@ -148,7 +149,24 @@ fn unedited_rendered_source_is_passthrough() { worst = worst.max((a[c] as i32 - b[c] as i32).abs()); } } - assert!(worst <= 3, "max error {worst}"); + assert_eq!(worst, 0, "max error {worst}"); +} + +#[test] +fn unedited_neutral_near_whites_are_passthrough() { + // Issue #1945: neutral 255, 254 and 253 rendered one level darker (the display tone map's + // shoulder ran at neutral settings), so a white background never exported as 255. + let vals = [255u8, 254, 253, 252, 250, 240, 200, 128, 0]; + let src8 = lightcraft_raster::Rgba8::from_fn(vals.len(), 1, |x, _| { + let v = vals[x]; + [v, v, v, 255] + }); + let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020); + let src = src8.to_linear().map(|c| m.apply_f32(c)); + let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(vals.len(), 1)).image; + let got: Vec<[u8; 3]> = r.data.iter().map(|p| [p[0], p[1], p[2]]).collect(); + let want: Vec<[u8; 3]> = vals.iter().map(|&v| [v, v, v]).collect(); + assert_eq!(got, want); } fn typical_edits() -> DevelopSettings { diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index d4d2d129a..a248ada59 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -186,6 +186,9 @@ impl ToneMap { let w = (whites / 100.0) as f32; let b = (blacks / 100.0) as f32; let m = GREY.powf(1.0 / 2.2); + // Neutral sliders are the identity up to white (issue #1945): the SDR shoulder below would + // pull 1.0 down to 0.9875 (8-bit 255 to 254), so it only shapes an edited curve. + let neutral = c == 0.0 && w == 0.0 && b == 0.0; let lut = (0..LUT_N) .map(|i| { let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32; @@ -203,12 +206,12 @@ impl ToneMap { p += w * 0.12 * 0.5; } let mut o = p.max(0.0).powf(2.2); - // short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05 + // short shoulder (SDR, edited curves): slope 1 at 0.95, reaching 1.0 at 1.05 if peak > 1.0 && o > 0.95 { // exponential shoulder: slope 1 at 0.95, asymptote at `peak` let r = peak - 0.95; o = 0.95 + r * (1.0 - (-(o - 0.95) / r).exp()); - } else if o > 0.95 { + } else if o > 0.95 && !neutral { let d = (o - 0.95).min(0.1); o = 0.95 + d - d * d / 0.2; } @@ -411,10 +414,13 @@ mod tests { #[test] fn display_identity_and_monotone() { let t = ToneMap::display(0.0, 0.0, 0.0); - for i in 1..=95 { + // up to and including white (issue #1945: 1.0 came out as 0.9875, 8-bit 255 as 254); the + // tolerance is the LUT's interpolation next to the clip at 1.0, far below an 8-bit level + for i in 1..=100 { let y = i as f32 / 100.0; - assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y)); + assert!((t.apply(y) - y).abs() < 1e-3, "{y} -> {}", t.apply(y)); } + assert_eq!(t.apply(4.0), 1.0, "neutral values pushed above white clip"); for (c, w, b) in [(100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (60.0, -40.0, 30.0)] { let t = ToneMap::display(c, w, b); let mut prev = -1.0; @@ -428,6 +434,17 @@ mod tests { assert!(c.apply(0.05) < 0.05 && c.apply(0.7) > 0.7); } + #[test] + fn display_shoulder_stays_on_edited_curves() { + // The short shoulder is part of the V1 look of edited curves (docs/process-versions.md); + // only neutral settings are the identity. + for (c, w, b) in [(20.0, 0.0, 0.0), (0.0, 10.0, 0.0), (0.0, 0.0, -5.0)] { + let t = ToneMap::display(c, w, b); + let o = t.apply(1.0); + assert!(o < 0.999 && o > 0.95, "{c} {w} {b}: {o}"); + } + } + #[test] fn grey_stays_near_grey_and_highlights_roll_off() { let t = ToneMap::new(0.0, 0.0, 0.0); diff --git a/crates/pipeline/tests/it/main.rs b/crates/pipeline/tests/it/main.rs new file mode 100644 index 000000000..95fc179bf --- /dev/null +++ b/crates/pipeline/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod preview_save; diff --git a/crates/pipeline/tests/it/preview_save.rs b/crates/pipeline/tests/it/preview_save.rs new file mode 100644 index 000000000..b9f83a69d --- /dev/null +++ b/crates/pipeline/tests/it/preview_save.rs @@ -0,0 +1,149 @@ +//! Preview/save approximation checks, not CPU/GPU equivalence or pixel parity. +//! +//! All inputs are procedural, scene-linear Rec.2020. The save reference is the +//! full-size U8 sRGB output, decoded to linear light, Box reduced, then encoded +//! back to U8 sRGB. This models one explicit display reduction, not every export +//! codec/viewer's resampler. Averaging before nonlinear tone mapping cannot in +//! general equal averaging afterwards, especially on unresolved bright texture. + +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo, local, render}; +use lightcraft_raster::{ + Rgb32f, Rgba8, + resample::{Filter, resize}, +}; + +fn scene(w: usize, h: usize) -> Rgb32f { + Rgb32f::from_fn(w, h, |x, y| { + let u = x as f32 / w as f32; + // Smooth shadows through scene highlights, hard boundaries, and fine + // twigs/checkers that straddle the shoulder after a strong exposure push. + let ramp = 0.012 * 2.0_f32.powf(7.0 * u); + let texture = if (x + 3 * y) % 7 < 2 { 0.35 } else { 1.15 }; + let v = ramp * texture * if y < h / 2 { 1.0 } else { 0.3 }; + [v * 1.05, v, v * 0.85] + }) +} + +fn edits() -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.light.exposure = 1.5; + s.light.highlights = -90.0; + s.light.shadows = 75.0; + s.light.contrast = 35.0; + s.light.whites = 20.0; + s.light.blacks = -25.0; + s.effects.clarity = 40.0; + s +} + +#[derive(Debug)] +struct Error { + mean: f64, + p99: u8, +} + +impl Error { + fn acceptable(&self) -> bool { + // Observed maxima on these fixtures: mean 6.057, pixel-max p99 37. Leave + // headroom for numerical variation, while bounding both widespread + // drift and localized tails. These are fixture bounds, not a promise + // for arbitrary photographs, settings, resamplers, or output depths. + self.mean < 8.0 && self.p99 <= 40 + } +} + +fn error(a: &Rgba8, b: &Rgba8) -> Error { + assert_eq!((a.width, a.height), (b.width, b.height)); + let mut sum = 0_u64; + let mut pixel_errors: Vec = a + .data + .iter() + .zip(&b.data) + .map(|(a, b)| { + let deltas = std::array::from_fn::<_, 3, _>(|c| a[c].abs_diff(b[c])); + sum += deltas.iter().map(|&v| u64::from(v)).sum::(); + deltas.into_iter().max().unwrap_or(0) + }) + .collect(); + // Mean remains over RGB samples; the tail uses each pixel's worst channel + // so a thin artifact in just one channel is not diluted by the other two. + let mean = sum as f64 / (pixel_errors.len() * 3) as f64; + pixel_errors.sort_unstable(); + Error { mean, p99: pixel_errors[(pixel_errors.len() - 1) * 99 / 100] } +} + +fn reduced_save(save: &Rgba8, w: usize, h: usize) -> Rgba8 { + resize(&save.to_linear(), w, h, Filter::Box).to_srgb8() +} + +fn measure(w: usize, h: usize, edges: &[usize], inject: bool) { + let src = scene(w, h); + let s = edits(); + let info = SourceInfo::default(); + let save = render(&src, &info, &s, &RenderRequest::fit(w, h)).image; + for &edge in edges { + let req = RenderRequest::fit(edge, edge); + let preview = render(&src, &info, &s, &req).image; + let reference = reduced_save(&save, preview.width, preview.height); + let actual = error(&preview, &reference); + eprintln!("{w}x{h} -> {edge}: direct {actual:?}"); + assert!(actual.acceptable(), "{w}x{h} -> {edge}: {actual:?}"); + + // A separately reduced source models a proxy decode. Preserve native + // size metadata and use the geometry stage's Mitchell prefilter here. + let proxy = resize(&src, preview.width, preview.height, Filter::Mitchell); + let proxy_info = SourceInfo { native_long: Some(w.max(h)), ..info.clone() }; + let from_proxy = render(&proxy, &proxy_info, &s, &req).image; + let proxy_error = error(&from_proxy, &reference); + eprintln!("{w}x{h} -> {edge}: proxy {proxy_error:?}"); + assert!(proxy_error.acceptable(), "{w}x{h} -> {edge} proxy: {proxy_error:?}"); + + if inject { + for control in ["exposure", "highlights"] { + let mut broken = s.clone(); + match control { + "exposure" => broken.light.exposure = 0.0, + _ => broken.light.highlights = 0.0, + } + let omitted = render(&src, &info, &broken, &req).image; + let omitted_error = error(&omitted, &reference); + eprintln!("{w}x{h} -> {edge}: omitted {control}: {omitted_error:?}"); + // Exercise the very same acceptance predicate as the real + // preview. A test-only omitted control must be rejected; this + // guards against silently loosening the bounds into a no-op. + assert!(!omitted_error.acceptable(), "omitted {control} escaped: {omitted_error:?}"); + } + } + } +} + +#[test] +fn tail_bound_rejects_a_thin_single_channel_artifact() { + let reference = Rgba8::filled(800, 48, [0, 0, 0, 255]); + let mut corrupted = reference.clone(); + for pixel in &mut corrupted.data[..800] { + pixel[0] = 255; + } + let actual = error(&corrupted, &reference); + // One red row is only 0.69% of RGB samples (and mean < 2), but is 2.08% + // of pixels. The tail must reject it using the preview's own predicate. + assert!(actual.mean < 2.0); + assert_eq!(actual.p99, 255); + assert!(!actual.acceptable(), "single-channel row escaped: {actual:?}"); +} + +#[test] +fn preview_tracks_reduced_save_with_strong_edits() { + // 3200 reaches the guided-filter subsample cap (sigma = 1.5% long edge), + // unlike 800/1440. A narrow strip keeps this regression affordable in CI. + assert_eq!(local::guided_fast_step(0.015 * 3200.0), 16); + assert!(local::guided_fast_step(0.015 * 1440.0) < 16); + measure(3200, 192, &[800, 1440], true); +} + +#[test] +#[ignore = "larger preview/save stress measurement; run with --ignored --nocapture"] +fn large_preview_save_stress() { + measure(6400, 768, &[800, 1440], true); +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 0347b8fcc..76d48872d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -2,7 +2,8 @@ //! //! - [`Image`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`, //! `Plane = Image`). -//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel. +//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps +//! ([`sample`]: Lanczos-3 held to its neighbours' range). //! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius). //! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius). //! - [`Histogram`] of display-encoded RGB + luminance. @@ -13,6 +14,7 @@ pub mod blur; pub mod histogram; pub mod morph; pub mod resample; +pub mod sample; pub use histogram::{HdrBins, Histogram}; diff --git a/crates/raster/src/resample.rs b/crates/raster/src/resample.rs index a57595838..afe0c10d7 100644 --- a/crates/raster/src/resample.rs +++ b/crates/raster/src/resample.rs @@ -19,7 +19,8 @@ impl Filter { Filter::Lanczos3 => 3.0, } } - fn eval(self, x: f32) -> f32 { + /// The filter's weight at distance `x` (pixels of the source). + pub(crate) fn eval(self, x: f32) -> f32 { let x = x.abs(); match self { Filter::Box => { diff --git a/crates/raster/src/sample.rs b/crates/raster/src/sample.rs new file mode 100644 index 000000000..99ebc0abe --- /dev/null +++ b/crates/raster/src/sample.rs @@ -0,0 +1,154 @@ +//! Point sampling at continuous coordinates for geometric warps (alignment, projections). +//! +//! [`Rgb32f::sample_lanczos3`] interpolates with a 6 × 6 Lanczos-3 kernel. Bilinear sampling (the +//! [`Rgb32f::sample_bilinear`] next to it) averages two pixels at a half-pixel offset: a [½ ½] +//! filter that passes nothing at the Nyquist frequency, so a frame warped by a fraction of a pixel +//! loses its finest detail. Lanczos-3 keeps most of it. Its negative lobes would overshoot next to +//! a hard edge (a clipped highlight, a star), so each sample is held to the range of the 4 × 4 +//! pixels around it, channel by channel: in smooth areas and fine texture that range holds the +//! interpolated value anyway (a 2 × 2 range would cut the peaks of texture near Nyquist that fall +//! between pixels), and at an edge the overshoot goes. + +use std::sync::OnceLock; + +use crate::Rgb32f; +use crate::resample::Filter; + +/// Phases of a pixel the kernel's taps are tabulated for. +const PHASES: usize = 1024; + +/// The six Lanczos-3 weights (summing to 1) for a sample `t` (0..1) past the third of the six +/// pixels it reads: pixel `k` (0..6) is at distance `t + 2 − k`. +pub fn lanczos3_taps(t: f32) -> [f32; 6] { + static TABLE: OnceLock> = OnceLock::new(); + let table = TABLE.get_or_init(|| { + (0..=PHASES) + .map(|i| { + let t = i as f32 / PHASES as f32; + let w: [f32; 6] = std::array::from_fn(|k| Filter::Lanczos3.eval(t + 2.0 - k as f32)); + let s: f32 = w.iter().sum(); + w.map(|v| v / s) + }) + .collect() + }); + let i = if t.is_finite() { (t.clamp(0.0, 1.0) * PHASES as f32).round() as usize } else { 0 }; + table.get(i).copied().unwrap_or([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) +} + +impl Rgb32f { + /// Lanczos-3 sample at continuous pixel coordinates (pixel centres at +0.5), held to the range + /// of the 4 × 4 pixels around it (see the module docs). Past the edges the edge pixels repeat; + /// a coordinate that isn't finite reads the nearest edge, as [`Rgb32f::sample_bilinear`] does. + pub fn sample_lanczos3(&self, x: f32, y: f32) -> [f32; 3] { + let (w, h) = (self.width, self.height); + if w == 0 || h == 0 { + return [0.0; 3]; + } + let fx = x.clamp(-1.0, w as f32 + 1.0) - 0.5; + let fy = y.clamp(-1.0, h as f32 + 1.0) - 0.5; + let (fx, fy) = (if fx.is_finite() { fx } else { 0.0 }, if fy.is_finite() { fy } else { 0.0 }); + let (x0, y0) = (fx.floor(), fy.floor()); + let (wx, wy) = (lanczos3_taps(fx - x0), lanczos3_taps(fy - y0)); + let (x0, y0) = (x0 as isize, y0 as isize); + let col: [usize; 6] = std::array::from_fn(|k| (x0 + k as isize - 2).clamp(0, w as isize - 1) as usize); + let row: [usize; 6] = std::array::from_fn(|k| (y0 + k as isize - 2).clamp(0, h as isize - 1) as usize * w); + let px = |r: usize, c: usize| self.data.get(r + c).copied().unwrap_or_default(); + let mut acc = [0f32; 3]; + // and the range of the 4 × 4 pixels around the sample (taps 1 to 4 on each axis) + let mut lo = [f32::INFINITY; 3]; + let mut hi = [f32::NEG_INFINITY; 3]; + for (j, (r, &wr)) in row.iter().zip(&wy).enumerate() { + let mut line = [0f32; 3]; + for (i, (&c, &wc)) in col.iter().zip(&wx).enumerate() { + let p = px(*r, c); + for ch in 0..3 { + line[ch] += wc * p[ch]; + } + if (1..5).contains(&i) && (1..5).contains(&j) { + for ch in 0..3 { + lo[ch] = lo[ch].min(p[ch]); + hi[ch] = hi[ch].max(p[ch]); + } + } + } + for ch in 0..3 { + acc[ch] += wr * line[ch]; + } + } + // no finite pixel in the 4 × 4 range (NaN all around): nothing to hold the sample to + std::array::from_fn(|ch| if lo[ch] <= hi[ch] { acc[ch].clamp(lo[ch], hi[ch]) } else { acc[ch] }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// High-pass energy of a row-wise pattern: the mean squared second difference. + fn hp(img: &Rgb32f) -> f64 { + let w = img.width; + let mut s = 0.0; + let mut n = 0; + for y in 4..img.height - 4 { + for x in 4..w - 4 { + let v = |dx: isize| img.get((x as isize + dx) as usize, y)[1] as f64; + s += (2.0 * v(0) - v(-1) - v(1)).powi(2); + n += 1; + } + } + s / n as f64 + } + + #[test] + fn taps_sum_to_one_and_hit_pixels_exactly() { + for t in [0.0, 0.1, 0.5, 0.77, 1.0] { + let w = lanczos3_taps(t); + assert!((w.iter().sum::() - 1.0).abs() < 1e-5); + } + let w = lanczos3_taps(0.0); + assert!((w[2] - 1.0).abs() < 1e-5 && w.iter().enumerate().all(|(k, v)| k == 2 || v.abs() < 1e-5)); + assert_eq!(lanczos3_taps(f32::NAN), lanczos3_taps(0.0)); + } + + /// A fine pattern moved by half a pixel: bilinear keeps cos²(π f) of its high-pass energy (a + /// third at 0.3 cycles per pixel), Lanczos-3 twice that, and nearly all of it at 0.2. + #[test] + fn a_half_pixel_shift_keeps_fine_detail() { + for (f, lanczos_min, over_bilinear) in [(0.2f32, 0.9, 1.3), (0.3, 0.65, 1.9)] { + let img = Rgb32f::from_fn(64, 16, |x, _| [0.5 + 0.4 * (x as f32 * f * std::f32::consts::TAU).sin(); 3]); + let shifted = |s: &dyn Fn(f32, f32) -> [f32; 3]| Rgb32f::from_fn(64, 16, |x, y| s(x as f32 + 1.0, y as f32 + 0.5)); + let bil = shifted(&|x, y| img.sample_bilinear(x, y)); + let lan = shifted(&|x, y| img.sample_lanczos3(x, y)); + let (e0, eb, el) = (hp(&img), hp(&bil), hp(&lan)); + assert!(el > lanczos_min * e0, "{f} c/px: lanczos {el} vs {e0}"); + assert!(el > over_bilinear * eb, "{f} c/px: lanczos {el} vs bilinear {eb}"); + } + } + + /// Next to a hard edge Lanczos-3 would overshoot; the sample stays within its neighbours. + #[test] + fn no_overshoot_at_a_hard_edge() { + let img = Rgb32f::from_fn(16, 4, |x, _| if x < 8 { [0.0; 3] } else { [1.0; 3] }); + for i in 0..160 { + let v = img.sample_lanczos3(i as f32 * 0.1, 2.0); + assert!((0.0..=1.0).contains(&v[0]), "{v:?} at {}", i as f32 * 0.1); + } + // pixel centres come back exactly + assert_eq!(img.sample_lanczos3(8.5, 1.5), [1.0; 3]); + assert_eq!(img.sample_lanczos3(7.5, 1.5), [0.0; 3]); + } + + #[test] + fn edges_and_odd_coordinates_never_panic() { + let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]); + for (x, y) in [(f32::INFINITY, f32::INFINITY), (f32::NEG_INFINITY, 0.0), (f32::NAN, f32::NAN), (f32::MAX, -1e30), (-5.0, 9.0)] { + let v = img.sample_lanczos3(x, y); + assert!(v.iter().all(|c| c.is_finite()), "{v:?}"); + } + assert_eq!(Rgb32f::new(0, 0).sample_lanczos3(1.0, 1.0), [0.0; 3]); + // pixels that are all NaN (a damaged float file) give a NaN sample, never a panic + let nan = Rgb32f::from_fn(8, 8, |_, _| [f32::NAN; 3]); + assert!(nan.sample_lanczos3(4.3, 4.6).iter().all(|v| v.is_nan())); + assert_eq!(img.sample_lanczos3(0.5, 0.5), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index e9fc0999d..bbea2c8c1 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -194,18 +194,74 @@ pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool { /// anchored at the sensor origin. pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result { let tiff = Tiff::parse(bytes)?; - let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?; + decode_ifd(bytes, &tiff, raw, raw, mode, format) +} + +/// The smallest reduced-resolution copy of a DNG's LinearRaw image whose default crop still has a long edge of +/// `min_long` px: Lightroom keeps 256, 512 and 2048 px copies beside the full image of its HDR and panorama +/// merges, so a thumbnail decodes a 512 px copy instead of a 24k × 7k image (about 19 s). Only a copy that the main +/// image's tags describe as well: same samples per pixel and aspect ratio (within 1 %), and a main image without +/// opcode lists, a linearization table or black-level deltas (all in full-size pixel coordinates). +fn reduced_ifd<'a>(tiff: &'a Tiff, main: &Ifd, min_long: usize) -> Option<&'a Ifd> { + if main.u16(t::PHOTOMETRIC) != Some(photometric::LINEAR_RAW) { + return None; + } + let full_size_tags = + [t::OPCODE_LIST_1, t::OPCODE_LIST_2, t::OPCODE_LIST_3, t::LINEARIZATION_TABLE, t::BLACK_LEVEL_DELTA_H, t::BLACK_LEVEL_DELTA_V]; + if full_size_tags.iter().any(|&tag| main.contains(tag)) { + return None; + } + let (mw, mh) = (main.u64(t::IMAGE_WIDTH)?, main.u64(t::IMAGE_LENGTH)?); + if mw == 0 || mh == 0 { + return None; + } + let crop_long = match main.f64s(t::DEFAULT_CROP_SIZE).as_deref() { + Some([w, h]) if w.is_finite() && h.is_finite() && *w >= 1.0 && *h >= 1.0 => w.max(*h).min(mw.max(mh) as f64), + _ => mw.max(mh) as f64, + }; + let cpp = main.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1); + tiff.all_ifds() + .into_iter() + .filter(|i| i.u32(t::NEW_SUBFILE_TYPE) == Some(1) && i.u16(t::PHOTOMETRIC) == Some(photometric::LINEAR_RAW)) + .filter(|i| i.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1) == cpp) + .filter_map(|i| { + let (w, h) = (i.u64(t::IMAGE_WIDTH)?, i.u64(t::IMAGE_LENGTH)?); + let (a, b) = (w.checked_mul(mh)?, h.checked_mul(mw)?); + let same_aspect = w > 0 && h > 0 && a.abs_diff(b).saturating_mul(100) <= a; + let covers = crop_long * w as f64 / mw as f64 >= min_long as f64; + (same_aspect && covers && w < mw && h < mh).then_some((i, w)) + }) + .min_by_key(|&(_, w)| w) + .map(|(i, _)| i) +} + +/// [`decode`] from the smallest reduced-resolution copy of the raw image that covers `min_long` ([`reduced_ifd`]): +/// the main image's levels, colour and metadata, its active area and default crop scaled to the copy. `None` when the +/// file keeps no such copy. +pub(crate) fn decode_reduced(bytes: &[u8], min_long: usize) -> Result> { + let tiff = Tiff::parse(bytes)?; + let Some(main) = raw_ifd(&tiff) else { return Ok(None) }; + let Some(small) = reduced_ifd(&tiff, main, min_long) else { return Ok(None) }; + decode_ifd(bytes, &tiff, small, main, Mode::Full, RawFormat::Dng).map(Some) +} + +/// [`decode_as`] of the samples in `pixels`, described by the raw IFD `main`: the same IFD, or (see +/// [`decode_reduced`]) a reduced-resolution copy of it, whose own level tags win and whose geometry is `main`'s +/// scaled to its size. +fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, format: RawFormat) -> Result { + let ifd0 = &tiff.ifds[0]; + let raw = main; // GoPro's GPR: a DNG whose raw data is VC-5 coded, which is not decoded (yet). Named here, before // the generic "TIFF compression 9" of the chunk decoder. Header mode still describes the file // (a GPR has no embedded preview, and a library keeps the photo with this reason shown, as it // does for any file whose pixels fail to decode, rather than refusing to import it). if mode == Mode::Full - && let Some(c) = raw.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5) + && let Some(c) = pixels.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5) { return Err(RawError::Unsupported(format!("DNG compression {c} (GoPro VC-5) is not decoded yet"))); } - let info = raw.image()?; + let info = pixels.image()?; let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize); // one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511) if !matches!(cpp, 1 | 3 | 4) { @@ -217,22 +273,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result = table.iter().map(|&x| x.min(65535) as u16).collect(); let last = table.len() - 1; v.iter_mut().for_each(|s| *s = table[(*s as usize).min(last)]); linearized = true; } + // a reduced copy's own levels, where it states them + let levels = |tag| if pixels.contains(tag) { pixels } else { raw }; let float = matches!(data, RawData::F32(_)); let default_white = if float { 1.0 } else { ((1u64 << bits.min(16)) - 1) as f32 }; - let mut white: Vec = raw.f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect(); + let mut white: Vec = + levels(t::WHITE_LEVEL).f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect(); if white.is_empty() { white = vec![default_white]; } @@ -247,14 +306,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result (*r as usize, *c as usize), _ => (1, 1), }; // (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place) let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0); - let mut values: Vec = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); + let mut values: Vec = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); if values.len() != br * bc * cpp { values = vec![values.first().copied().unwrap_or(0.0)]; } @@ -263,11 +322,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result top && right > left && (*bottom as usize) <= h && (*right as usize) <= w => { + Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= full_h && (*right as usize) <= full_w => { Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize) } - _ => Rect::new(0, 0, w, h), + _ => Rect::new(0, 0, full_w, full_h), }; let crop = match (raw.f64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.f64s(t::DEFAULT_CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw >= 1.0 && *ch >= 1.0 && *x >= 0.0 && *y >= 0.0 => { @@ -275,6 +343,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Rect::new(0, 0, active_area.width, active_area.height), }; + // the developed size, which the metadata reports whatever copy the samples come from + let developed = (crop.width, crop.height); + let (active_area, crop) = if (full_w, full_h) == (w, h) { + (active_area, crop) + } else { + let (sx, sy) = (w as f64 / full_w as f64, h as f64 / full_h as f64); + let scale = |r: Rect| { + let (x, y) = ((r.x as f64 * sx).round() as usize, (r.y as f64 * sy).round() as usize); + let (x1, y1) = (((r.x + r.width) as f64 * sx).round() as usize, ((r.y + r.height) as f64 * sy).round() as usize); + Rect::new(x, y, x1.saturating_sub(x).max(1), y1.saturating_sub(y).max(1)) + }; + let area = scale(active_area).clipped(w, h); + (area, scale(crop).clipped(area.width, area.height)) + }; let cfa = if info.photometric == photometric::CFA { if raw.u16(t::CFA_LAYOUT).unwrap_or(1) != 1 { @@ -319,9 +401,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Matrix { std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j])) } +/// A declared transfer curve, applied to linear values (linear → coded). Mirrored for negative values +/// and not clipped above 1.0, so lossy undershoot and raw values above white survive. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Curve { + Linear, + /// IEC 61966-2-1 (sRGB). + Srgb, + /// ITU-R BT.709. + Bt709, + /// A pure power: coded = linear^exponent. + Power(f32), +} + +impl Curve { + fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + Curve::Linear => return v, + Curve::Srgb if a <= 0.003_130_8 => 12.92 * a, + Curve::Srgb => 1.055 * a.powf(1.0 / 2.4) - 0.055, + Curve::Bt709 if a < 0.018 => 4.5 * a, + Curve::Bt709 => 1.099 * a.powf(0.45) - 0.099, + Curve::Power(p) => a.powf(p), + }; + e.copysign(v) + } +} + /// How a chunk is rendered by jxl-oxide. #[derive(Debug, Clone, Copy, PartialEq)] enum Output { /// In the codestream's own colour encoding (non-XYB: the identity). AsCoded, - /// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared - /// primaries (`None` when those are sRGB's). - FromXyb(Option<[[f32; 3]; 3]>), + /// XYB (lossy) data: linear sRGB primaries from the decoder, then `matrix` to the declared + /// primaries (`None` when those are sRGB's), then the declared transfer `curve`. + FromXyb { matrix: Option<[[f32; 3]; 3]>, curve: Curve }, } /// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour -/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is a -/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would -/// apply, otherwise the samples cannot be restored faithfully. +/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB +/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG +/// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored +/// faithfully. fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { if !xyb { return Ok(Output::AsCoded); @@ -104,8 +140,26 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane let ColourEncoding::Enum(e) = encoding else { return Err(unsupported("an ICC profile instead of a colour encoding")); }; - if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear { - return Err(unsupported("a declared encoding that is not linear RGB")); + if e.colour_space != ColourSpace::Rgb { + return Err(unsupported("a declared encoding that is not RGB")); + } + let curve = match e.tf { + TransferFunction::Linear => Curve::Linear, + TransferFunction::Srgb => Curve::Srgb, + TransferFunction::Bt709 => Curve::Bt709, + TransferFunction::Dci => Curve::Power(1.0 / 2.6), + // as jxl-oxide reads it: a codestream's gamma is the encoding exponent (×1e7) with `inverted` + TransferFunction::Gamma { g, inverted } => { + let g = g as f32 / 1e7; + Curve::Power(if inverted { g } else { 1.0 / g }) + } + TransferFunction::Pq | TransferFunction::Hlg => return Err(unsupported("an HDR transfer curve (PQ or HLG)")), + TransferFunction::Unknown => return Err(unsupported("an unknown transfer curve")), + }; + if let Curve::Power(p) = curve + && !(p.is_finite() && p > 0.0) + { + return Err(unsupported("an invalid gamma")); } if e.white_point != WhitePoint::D65 { return Err(unsupported("a white point other than D65")); @@ -114,7 +168,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane return Err(unsupported("an intensity target above 255 nits")); } let primaries = match e.primaries { - Primaries::Srgb => return Ok(Output::FromXyb(None)), + Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }), Primaries::Bt2100 => BT2100, Primaries::P3 => P3, Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)), @@ -126,7 +180,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane if m.iter().flatten().any(|v| !v.is_finite()) { return Err(unsupported("degenerate primaries")); } - Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32))))) + Ok(Output::FromXyb { matrix: Some(m.map(|r| r.map(|v| v as f32))), curve }) } fn corrupt(e: impl std::fmt::Display) -> RawError { @@ -177,7 +231,7 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))), }; let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?; - if matches!(output, Output::FromXyb(_)) { + if matches!(output, Output::FromXyb { .. }) { img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); } Ok((img, max, output)) @@ -205,18 +259,23 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp } let n = cw.saturating_mul(ch).saturating_mul(cpp); let rows = fb.buf().chunks_exact(jw * fc); - // lossy data: restore the declared primaries (see the module documentation) - let matrix = match output { - Output::FromXyb(m) => m, - Output::AsCoded => None, + // lossy data: restore the declared primaries and transfer curve (see the module documentation) + let (matrix, curve) = match output { + Output::FromXyb { matrix, curve } => (matrix, curve), + Output::AsCoded => (None, Curve::Linear), }; - let restore = |s: &[f32], d: &mut [f32]| match matrix { - Some(m) => { - for (d, row) in d.iter_mut().zip(&m) { - *d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2]; + let restore = |s: &[f32], d: &mut [f32]| { + match matrix { + Some(m) => { + for (d, row) in d.iter_mut().zip(&m) { + *d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2]; + } } + None => d.copy_from_slice(&s[..cpp]), + } + if curve != Curve::Linear { + d.iter_mut().for_each(|v| *v = curve.encode(*v)); } - None => d.copy_from_slice(&s[..cpp]), }; Ok(if float { let mut out = vec![0f32; n]; @@ -257,7 +316,7 @@ mod tests { fn matrix(o: Output) -> [[f32; 3]; 3] { match o { - Output::FromXyb(Some(m)) => m, + Output::FromXyb { matrix: Some(m), .. } => m, other => panic!("expected a matrix, got {other:?}"), } } @@ -275,7 +334,47 @@ mod tests { #[test] fn lossy_data_with_srgb_primaries_needs_no_matrix() { - assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None)); + assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear }); + } + + #[test] + fn lossy_data_keeps_the_declared_transfer_curve() { + // Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual); + e.primaries = Primaries::Bt2100; + e.tf = TransferFunction::Srgb; + let curve = |tf| { + let mut e = e.clone(); + e.tf = tf; + match output_for(true, &ColourEncoding::Enum(e), 255.0, 3).unwrap() { + Output::FromXyb { matrix: Some(_), curve } => curve, + other => panic!("expected a matrix and a curve, got {other:?}"), + } + }; + assert_eq!(curve(TransferFunction::Srgb), Curve::Srgb); + assert_eq!(curve(TransferFunction::Bt709), Curve::Bt709); + assert_eq!(curve(TransferFunction::Dci), Curve::Power(1.0 / 2.6)); + // a codestream's gamma (as read: inverted) is the encoding exponent; jxl-oxide's own γ 2.2 is not + assert_eq!(curve(TransferFunction::Gamma { g: 4_545_455, inverted: true }), Curve::Power(0.454_545_5)); + assert_eq!(curve(TransferFunction::Gamma { g: 22_000_000, inverted: false }), Curve::Power(1.0 / 2.2)); + } + + #[test] + fn transfer_curves_encode_unclipped_and_mirrored() { + let close = |a: f32, b: f32| (a - b).abs() < 1e-4; + // sRGB: linear toe, 18 % grey, white, and the toe/power junction is continuous + assert!(close(Curve::Srgb.encode(0.002), 0.002 * 12.92)); + assert!(close(Curve::Srgb.encode(0.18), 0.461_356)); + assert!(close(Curve::Srgb.encode(1.0), 1.0)); + assert!(close(Curve::Srgb.encode(0.003_130_8), Curve::Srgb.encode(0.003_130_9))); + assert!(close(Curve::Bt709.encode(1.0), 1.0)); + assert!((Curve::Bt709.encode(0.017_999) - Curve::Bt709.encode(0.018)).abs() < 1e-3); + assert!(close(Curve::Power(0.5).encode(0.25), 0.5)); + // raw values above white keep growing, lossy undershoot keeps its sign, linear is the identity + assert!(Curve::Srgb.encode(2.0) > 1.3); + assert!(close(Curve::Srgb.encode(-0.18), -0.461_356)); + assert_eq!(Curve::Linear.encode(-3.5), -3.5); + assert_eq!(Curve::Power(0.5).encode(0.0), 0.0); } #[test] @@ -301,13 +400,80 @@ mod tests { assert!(srgb_green_in_p3.iter().all(|v| (0.0..=1.0).contains(v)), "sRGB green lies inside P3"); } + // 16 × 16 RGB tiles of 16-bit samples coded lossily (XYB) by libjxl at distance 0.5, each declaring BT.2020 + // primaries, a D65 white point and the named transfer curve. The samples are [`gradient`]; generated for these + // tests (original work). + const SRGB_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xc7, 0x8d, 0x00, 0x13, 0x08, 0x00, 0xf0, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x33, 0x70, 0xde, 0x90, 0x97, 0xbd, 0x79, 0x64, 0xe4, 0x3b, 0x62, 0xaf, 0x1b, 0xb2, 0x74, 0xf6, 0x36, 0x43, 0x17, 0x72, 0xe7, + 0x31, 0x86, 0xb6, 0x41, 0x1c, 0x00, 0x15, 0x52, 0x20, 0x11, 0x70, 0xcb, 0x46, 0x32, 0x92, 0x65, 0xcd, 0xc8, 0x18, 0xc9, 0x48, 0xe6, 0x49, + 0xe6, 0x4f, 0x94, 0x35, 0xc7, 0x5c, 0x91, 0xb0, 0x5d, 0xdf, 0x2b, 0x9b, 0x93, 0x3b, 0xb6, 0x70, 0x7d, 0x24, 0xf0, 0xdd, 0x26, 0xb2, 0x26, + 0xdf, 0x49, 0x5f, 0xd4, 0xaa, 0x1c, 0x8f, 0x7d, 0xc4, 0xf2, 0x6a, 0xd2, 0x27, 0x86, 0x93, 0xe7, 0x15, 0x9a, 0x01, 0x60, 0x84, 0xc0, 0x09, + ]; + const BT709_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xa7, 0x08, 0x00, 0x13, 0x08, 0x00, 0xe8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x33, 0xd8, 0x9c, 0x32, 0xe2, 0x85, 0x6f, 0x3e, 0x6c, 0xa2, 0x1c, 0xaa, 0xdf, 0x1b, 0x32, 0xf4, 0xef, 0x36, 0xe3, 0x96, 0xf2, + 0xe0, 0x65, 0xe8, 0x15, 0xc4, 0x01, 0x50, 0x21, 0x05, 0xd2, 0x00, 0xd8, 0x8c, 0x8c, 0x14, 0x59, 0xc9, 0x31, 0x32, 0x46, 0x52, 0x60, 0x82, + 0x71, 0x03, 0xd9, 0xff, 0x8a, 0x3e, 0x7c, 0xb4, 0xc1, 0xa8, 0x49, 0xfa, 0x83, 0xa8, 0x74, 0xd7, 0x1d, 0xa8, 0xb4, 0x25, 0x67, 0xc1, 0x83, + 0x93, 0x3a, 0xf3, 0x81, 0xb5, 0x2f, 0x44, 0x50, 0x35, 0x36, 0x68, 0x1c, 0xf9, 0x20, 0x17, 0x98, 0x87, 0x1d, 0x83, 0xb0, 0x02, + ]; + const GAMMA22_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xf7, 0x75, 0xab, 0x28, 0x02, 0x00, 0x13, 0x08, 0x00, 0xf8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, + 0xa1, 0x32, 0xca, 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, + 0xdb, 0x0b, 0x83, 0x84, 0x84, 0x33, 0xd8, 0x9c, 0x02, 0xed, 0x85, 0x6f, 0x3e, 0x2c, 0x72, 0x1e, 0xb8, 0xe3, 0x3b, 0x12, 0x75, 0xf8, 0x4e, + 0xa3, 0x97, 0x71, 0xea, 0x31, 0x43, 0xa7, 0x20, 0x0e, 0x80, 0x0a, 0x29, 0x90, 0x08, 0xc0, 0x8a, 0x8c, 0x8a, 0x64, 0x74, 0x59, 0x45, 0xc6, + 0x48, 0x46, 0x32, 0x4c, 0x32, 0x4c, 0xa0, 0xa5, 0x39, 0x23, 0xff, 0xdf, 0xcd, 0xf9, 0x33, 0x93, 0x5c, 0xb4, 0xec, 0xc1, 0x06, 0xf5, 0xcc, + 0x92, 0x12, 0xf4, 0xbb, 0xef, 0x15, 0x84, 0xc7, 0xf2, 0xf1, 0x48, 0xfc, 0x80, 0xb9, 0x7a, 0xab, 0x58, 0x3e, 0x79, 0x3a, 0xcf, 0x67, 0xc7, + 0xd3, 0xfc, 0x80, 0x60, 0x00, + ]; + const LINEAR_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0x47, 0x8b, 0x00, 0x13, 0x08, 0x00, 0x14, 0x02, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x43, 0xd8, 0x36, 0xe1, 0xbe, 0x88, 0x9b, 0x27, 0x48, 0x71, 0x04, 0xe2, 0xeb, 0x4e, 0xf9, 0x74, 0x9d, 0xe7, 0x00, 0x8d, 0x74, + 0xcb, 0xc0, 0xe0, 0x5d, 0x58, 0x10, 0x80, 0x0a, 0x29, 0x90, 0x06, 0xd8, 0xac, 0x18, 0x19, 0x45, 0xc5, 0x28, 0x89, 0x8a, 0x24, 0xd3, 0xff, + 0xff, 0x1c, 0xc4, 0x2f, 0xd8, 0x26, 0x80, 0x6c, 0xe1, 0xc3, 0xbe, 0xef, 0xab, 0xe1, 0x79, 0x1e, 0xff, 0x1b, 0x2e, 0x57, 0xf7, 0x2c, 0x1f, + 0x1f, 0xf8, 0xec, 0xd3, 0x33, 0x21, 0x9e, 0x12, 0x22, 0x43, 0xc2, 0xfd, 0xb3, 0xf2, 0xe0, 0x40, 0xdf, 0x25, 0x0f, 0x67, 0xe7, 0xca, 0x7e, + 0xa5, 0x7f, 0x1f, 0x21, 0xa4, 0x6a, 0x1e, 0xa0, 0x0e, + ]; + + /// The samples the fixture tiles were coded from: `(x, y)` → RGB. + fn gradient(x: usize, y: usize) -> [f32; 3] { + let (x, y) = (x as f32, y as f32); + [4000.0 + x * 3500.0, 6000.0 + y * 3200.0, 30000.0 + (x - y) * 1500.0] + } + + #[test] + fn lossy_tiles_decode_to_their_samples_whatever_sdr_curve_they_declare() { + for (name, tile) in [("sRGB", SRGB_TILE), ("BT.709", BT709_TILE), ("gamma 2.2", GAMMA22_TILE), ("linear", LINEAR_TILE)] { + let ChunkPx::U16(px) = decode(tile, 16, 16, 16, 16, 3, 16, false).unwrap() else { panic!("{name}: integer samples") }; + let (mut sum, mut worst) = (0.0f32, 0.0f32); + for (i, p) in px.as_chunks::<3>().0.iter().enumerate() { + for (got, want) in p.iter().zip(gradient(i % 16, i / 16)) { + let err = (f32::from(*got) - want).abs(); + sum += err; + worst = worst.max(err); + } + } + // the reference decoder (libjxl) lands within 0.5 % on average and 6 % at worst on these tiles + let mean = sum / px.len() as f32; + assert!(mean < 0.01 * 65535.0, "{name}: mean error {mean}"); + assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}"); + } + } + #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); - assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear"); let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] { + e.tf = tf; + assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "{tf:?}"); + } + e.tf = TransferFunction::Linear; e.white_point = WhitePoint::Dci; assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point"); e.white_point = WhitePoint::D65; diff --git a/crates/raw/src/kelvin_builtin.rs b/crates/raw/src/kelvin_builtin.rs new file mode 100644 index 000000000..1ce4ddfc4 --- /dev/null +++ b/crates/raw/src/kelvin_builtin.rs @@ -0,0 +1,92 @@ +//! Built-in white-balance presets for cameras whose raws carry neither colour matrices nor a preset table, feeding +//! the Kelvin scale of [`crate::kelvin_presets`]. +//! +//! Nikon NEFs store only the as-shot multipliers (`WB_RBLevels`) and, in the maker note, the colour temperature the +//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x004f`: tag name from the +//! ExifTool Nikon tag documentation, read with exiftool run as a black-box tool on the files). A body's points are +//! the as-shot multipliers of its own photos shot at one preset each, placed at the temperature the camera itself +//! wrote for that preset. Presets off the daylight locus (fluorescent, flash) and shots with a fine-tune shift are +//! left out. +//! +//! Sources: own photos of each body listed (a reporter's samples, never committed), the camera's own stated +//! temperatures, and the ExifTool Nikon tag-name documentation (prose). No matrices or third-party data. +use crate::kelvin_presets::KelvinPresets; + +/// One body: its make's first word, its Exif model without the make, and five (Kelvin, R/G, B/G) points. +struct Builtin { + make: &'static str, + model: &'static str, + points: [(f32, f32, f32); 5], +} + +const BUILTINS: &[Builtin] = &[ + // Nikon Z5II (issue emmertex/lightcraft-classic#157): Incandescent 3000 K, Direct sunlight 5210 K, the Kelvin + // setting at 5500 K, Cloudy 5990 K, Shade 8000 K (the temperatures the camera writes in `ColorTemperatureAuto`). + // Natural Auto (the camera wrote 5030 K) reads back at 5036 K; Flash (5410 K nominal) at about 6500 K. + Builtin { + make: "NIKON", + model: "Z5_2", + points: [ + (3000.0, 1.138_671_9, 2.083_984_4), + (5210.0, 1.726_562_5, 1.335_937_5), + (5500.0, 1.771_484_4, 1.281_25), + (5990.0, 1.839_843_8, 1.212_890_6), + (8000.0, 2.097_656_3, 1.089_843_8), + ], + }, +]; + +fn find_builtin(make: Option<&str>, model: &str) -> Option<&'static Builtin> { + let model = model.trim(); + BUILTINS.iter().find(|b| { + let n = b.make.len(); + let bare = match (model.get(..n), model.get(n..)) { + (Some(prefix), Some(rest)) if prefix.eq_ignore_ascii_case(b.make) => rest.trim_start(), + _ => model, + }; + make.is_none_or(|m| m.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case(b.make))) && bare.eq_ignore_ascii_case(b.model) + }) +} + +/// Whether this Exif make and model has built-in presets. +pub fn covers(make: Option<&str>, model: &str) -> bool { + find_builtin(make, model).is_some() +} + +/// The built-in presets of this Exif make and model, if any. +pub fn find(make: Option<&str>, model: &str) -> Option { + let b = find_builtin(make, model)?; + KelvinPresets::new(b.points.map(|(k, r, b)| (k, [r, 1.0, b]))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_makes_a_curve() { + for b in BUILTINS { + assert!(find(Some(b.make), b.model).is_some(), "{} {}", b.make, b.model); + } + } + + #[test] + fn z5ii_reads_its_own_shots_near_the_camera_temperatures() { + let p = find(Some("NIKON CORPORATION"), "NIKON Z5_2").unwrap(); + // Natural Auto, not on the curve: the camera wrote 5030 K for it + let (t, tint) = p.temp_tint([1.695_312_5, 1.0, 1.373_046_9]).unwrap(); + assert!((1e6 / t - 1e6 / 5030.0).abs() < 10.0 && tint.abs() < 5.0, "{t} {tint}"); + // Flash (nominally 5410 K) sits near the curve on the daylight side + let (t, tint) = p.temp_tint([1.916_015_6, 1.0, 1.164_062_5]).unwrap(); + assert!((5000.0..7500.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + // the 5990 K Kelvin shot with a green-magenta fine-tune shift: daylight, clearly tinted + let (t, tint) = p.temp_tint([1.531_25, 1.0, 1.074_218_8]).unwrap(); + assert!((4000.0..7500.0).contains(&t) && tint.abs() > 20.0, "{t} {tint}"); + } + + #[test] + fn matching() { + assert!(covers(Some("NIKON CORPORATION"), "NIKON Z5_2") && covers(None, "Z5_2") && covers(Some("Nikon"), "nikon z5_2")); + assert!(!covers(Some("NIKON CORPORATION"), "NIKON Z5") && !covers(Some("SONY"), "Z5_2") && !covers(None, "") && !covers(None, "NIK")); + } +} diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index d4e6f33f3..4db0a521e 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -8,8 +8,13 @@ //! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the //! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730). //! +//! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see +//! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way, +//! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the +//! curve reads the as-shot levels within 5 % of the camera's own `ColorTempAsShot`. +//! //! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): -//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments +//! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments //! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to //! 8500 K within ±3 %), so the segments add no visible kinks. //! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled @@ -35,32 +40,54 @@ const MIN_K: f64 = 2000.0; const MAX_K: f64 = 50000.0; const MAX_TINT: f64 = 150.0; -/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own -/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A -/// camera with measured matrices (`crate::spectral`) is read through those instead. -const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; +/// Models whose white balance is edited in Kelvin through their presets, as (brand, Exif model without the brand), +/// each checked to carry them: the Sony bodies on 80–150 own photos each, or the public corpus samples for the +/// ILCE-3500 and ILCE-7S; the Canon PowerShot G1 X Mark III on a reporter's nine photos, one per white-balance +/// setting (issue #157 of a downstream fork). A camera with measured matrices (`crate::spectral`) is read through +/// those instead. +const MODELS: &[(&str, &str)] = &[ + ("SONY", "DSC-RX10"), + ("SONY", "ILCA-77M2"), + ("SONY", "ILCA-99M2"), + ("SONY", "ILCE-3500"), + ("SONY", "ILCE-7S"), + ("SONY", "SLT-A33"), + ("CANON", "PowerShot G1 X Mark III"), +]; /// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour -/// matrices of its own or measured: see [`crate::spectral::find`], which comes first). +/// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or +/// built-in ones ([`crate::kelvin_builtin`]). pub fn covers(make: Option<&str>, model: &str) -> bool { + if crate::kelvin_builtin::covers(make, model) { + return true; + } let model = model.trim(); - let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); - make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) - && MODELS.iter().any(|m| m.eq_ignore_ascii_case(model)) + let brand = make.and_then(|m| m.split_whitespace().next()); + MODELS.iter().any(|(b, m)| { + let bare = model + .get(..b.len()) + .zip(model.get(b.len()..)) + .filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' ')) + .map_or(model, |(_, rest)| rest.trim_start()); + brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m) + }) } /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). pub fn covers_camera(camera: &str) -> bool { - match camera.split_once(char::is_whitespace) { - Some((make, model)) => covers(Some(make), model), - None => covers(None, camera), - } + let words: Vec<&str> = camera.split_whitespace().collect(); + // the make can be several words ("NIKON CORPORATION"), so try every split, as `spectral::find_camera` does + (1..words.len()).any(|i| covers(Some(&words[..i].join(" ")), &words[i..].join(" "))) || covers(None, camera) } -/// A camera's multipliers at five stated temperatures, ascending, green = 1. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// Most points a curve keeps (Canon writes about seven at a stated temperature, Sony five). +const MAX_POINTS: usize = 32; + +/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KelvinPresets { - points: [(f32, [f32; 3]); 5], + points: Vec<(f32, [f32; 3])>, } fn usable(m: [f32; 3]) -> bool { @@ -76,15 +103,36 @@ fn locus_uv(t: f64) -> (f64, f64) { } impl KelvinPresets { - /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive - /// multipliers, red rising and blue falling from one preset to the next. - pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { - let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to + /// [`MAX_POINTS`] of them, positive multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: impl IntoIterator) -> Option { + let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect(); + let ok = (3..=MAX_POINTS).contains(&points.len()) + && points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) && points.windows(2).all(|w| { let (a, b) = (log_point(w[0].1), log_point(w[1].1)); w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] }); - ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) }) + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + } + + /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): + /// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back + /// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`]. + pub fn from_unordered(points: impl IntoIterator) -> Option { + let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len()); + for p in pts { + let fits = kept.last().is_none_or(|q| { + let (a, b) = (log_point(q.1), log_point(p.1)); + q.0 < p.0 && b[0] > a[0] && b[1] < a[1] + }); + if fits { + kept.push(p); + } + } + KelvinPresets::new(kept) } /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable @@ -94,13 +142,17 @@ impl KelvinPresets { return None; } let q = log_point(wb_multipliers); - let last = self.points.len() - 2; + let last = self.points.len().saturating_sub(2); let mut best: Option<(f64, f64, f64)> = None; for (i, w) in self.points.windows(2).enumerate() { let ((k0, m0), (k1, m1)) = (w[0], w[1]); let (p0, p1) = (log_point(m0), log_point(m1)); let d = [p1[0] - p0[0], p1[1] - p0[1]]; let len = d[0].hypot(d[1]); + // only a deserialized curve can repeat a point + if !(len > 0.0 && len.is_finite()) { + continue; + } let t = [d[0] / len, d[1] / len]; // the normal towards higher red and blue: a green white let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; @@ -158,7 +210,7 @@ mod tests { #[test] fn a_preset_reads_as_its_own_temperature_with_no_tint() { let p = a99m2(); - for (k, m) in p.points { + for &(k, m) in &p.points { let (t, tint) = p.temp_tint(m).unwrap(); assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}"); // the scale of the multipliers doesn't matter, only their ratios to green @@ -200,26 +252,67 @@ mod tests { assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None); assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None); assert_eq!(p.as_shot_white(None), None); - let mut pts = p.points; + let mut pts = p.points.clone(); pts.swap(1, 2); assert_eq!(KelvinPresets::new(pts), None, "out of order"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[4].1 = pts[3].1; assert_eq!(KelvinPresets::new(pts), None, "no step"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[0].1[0] = 0.0; assert_eq!(KelvinPresets::new(pts), None); assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None); + assert_eq!(KelvinPresets::new(p.points[..2].to_vec()), None, "too few"); let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); } + /// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the + /// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back. + #[test] + fn a_curve_from_presets_in_any_order() { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let p = KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (5651.0, g(1418.0, 1357.0)), + (8000.0, g(1400.0, 1400.0)), + (8529.0, g(1807.0, 1080.0)), + (f32::NAN, g(1000.0, 1000.0)), + ]) + .unwrap(); + assert_eq!(p.points.len(), 5); + assert!(p.points.windows(2).all(|w| w[0].0 < w[1].0)); + let (t, tint) = p.temp_tint(g(1653.0, 1171.0)).unwrap(); + assert!((t - 7505.0).abs() < 1.0 && tint.abs() < 0.1, "{t} {tint}"); + assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None); + assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None); + // a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero + let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] }; + assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None); + assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None); + } + #[test] fn covered_models() { - for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + assert!(covers_camera("NIKON CORPORATION NIKON Z5_2") && covers_camera("NIKON Z5_2") && !covers_camera("NIKON CORPORATION NIKON Z6")); + for c in [ + "SONY ILCA-99M2", + "SONY DSC-RX10", + "SONY ILCA-77M2", + "SONY SLT-A33", + "ILCA-99M2", + "Sony ilca-99m2", + "Canon Canon PowerShot G1 X Mark III", + "Canon PowerShot G1 X Mark III", + ] { assert!(covers_camera(c), "{c}"); } - for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + for c in + ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY", "Canon Canon PowerShot G1 X Mark II", "SONY PowerShot G1 X Mark III"] + { assert!(!covers_camera(c), "{c}"); } assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); diff --git a/crates/raw/src/lens_metadata.rs b/crates/raw/src/lens_metadata.rs index 4529950c8..08aecc9e5 100644 --- a/crates/raw/src/lens_metadata.rs +++ b/crates/raw/src/lens_metadata.rs @@ -137,7 +137,7 @@ pub enum SonySettingMeaning { /// Whether a component is applied, and if not, why. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SonyLensStatus { - /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing). + /// Converted into the image's `OpcodeList3` (ILCE-7RM4A, ILCE-7RM6 or eligible full-frame ILCE-7CR Bayer distortion). Applied, /// No usable table. NoTable, @@ -149,6 +149,8 @@ pub enum SonyLensStatus { /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a /// table found only in the `SR2SubIFD`. NotValidatedMode, + /// A7CR distortion is Off, unavailable, missing or unknown. Its uncorrected JPEG and render keep the same geometry. + CameraCorrectionDisabled, /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. Neutral, /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2800f0823..011142b09 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin_builtin; pub mod kelvin_presets; pub mod lens_metadata; pub mod ljpeg; @@ -71,6 +72,15 @@ use serde::{Deserialize, Serialize}; /// Upper bound on decoded samples (guards allocations driven by header values). pub const MAX_SAMPLES: usize = 1 << 30; +/// Checked product of input-derived dimensions, in u64 so it cannot wrap +/// on either 32-bit (wasm32) or 64-bit targets. The arguments are u64 too, so callers can feed +/// unchecked products of header values (row bytes, sample counts) without truncating on 32-bit. +/// +/// Returns `None` on overflow, zero, or when the product exceeds [`MAX_SAMPLES`]. +pub(crate) fn checked_pixel_count(width: u64, height: u64) -> Option { + width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64) +} + /// Errors from raw decoding. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RawError { @@ -104,7 +114,7 @@ pub enum RawFormat { Srw, /// Canon CRW (CIFF heap), the bodies before the CR2 era. Crw, - /// Minolta MRW (`\0MRM` block container). + /// Minolta MRW (`\0MRM` block container, `vendor::mrw`). Mrw, /// Sigma / Foveon X3F (`FOVb`). X3f, @@ -139,6 +149,7 @@ impl RawFormat { | RawFormat::CfaTiff | RawFormat::Srw | RawFormat::KodakDcs + | RawFormat::Mrw | RawFormat::Erf | RawFormat::Mef ) @@ -359,6 +370,17 @@ pub fn decode(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Full) } +/// [`decode`] at a smaller size, for a picture whose default crop needs a long edge of only `min_long` px: a DNG +/// that keeps reduced-resolution copies of its LinearRaw image (Lightroom's HDR and panorama merges keep 256 to 2048 +/// px ones) decodes the smallest that covers it. The image has the full one's levels, colour and metadata; its +/// active area and crop are scaled to its size. `Ok(None)` when the file keeps no such copy: decode it whole. +pub fn decode_reduced(bytes: &[u8], min_long: usize) -> Result> { + if probe(bytes) != Some(RawFormat::Dng) { + return Ok(None); + } + Ok(dng::decode_reduced(bytes, min_long)?.map(lift_clipped)) +} + /// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA, /// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read /// from the headers without decompressing the pixel data, for imports. Equal to @@ -400,6 +422,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), + RawFormat::Mrw => vendor::mrw::decode(bytes, mode), RawFormat::Erf => vendor::erf::decode(bytes, mode), RawFormat::Mef => vendor::mef::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), @@ -647,7 +670,7 @@ pub struct ColorData { /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, - /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour + /// The camera's white-balance presets at stated temperatures (Sony ARW, Canon CR2), which give a raw without colour /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. #[serde(default)] pub kelvin_presets: Option, @@ -765,8 +788,14 @@ impl RawImage { if self.cpp == 2 { return Err(RawError::Unsupported("2 samples per pixel".into())); } - if samples && self.data.len() != self.width * self.height * self.cpp { - return Err(RawError::Corrupt("data length mismatch".into())); + if samples { + let expected = crate::checked_pixel_count(self.width as u64, self.height as u64) + .and_then(|n| n.checked_mul(self.cpp as u64)) + .ok_or_else(|| RawError::Corrupt("data length mismatch".into()))?; + let actual = u64::try_from(self.data.len()).map_err(|_| RawError::Corrupt("data length mismatch".into()))?; + if actual != expected { + return Err(RawError::Corrupt("data length mismatch".into())); + } } if let Some(c) = &self.cfa && (!c.valid() || c.pattern.iter().any(|&p| p > 2)) @@ -1126,10 +1155,10 @@ mod tests { assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); - for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] { + for f in [RawFormat::Crw, RawFormat::X3f] { assert!(!f.is_supported()); } - assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw"))); + assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); // too short for the CIFF signature assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); } @@ -1182,6 +1211,69 @@ mod tests { assert_eq!((info.width, info.height), (32, 16)); } + /// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default + /// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. + fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); + ifd + }; + let mut main = linear(0, w, h, 1000); + main.set(t::BLACK_LEVEL, Value::Short(vec![100])); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + for (tag, v) in main_tags { + main.set(*tag, v.clone()); + } + let mut ifd0 = rgb_ifd(4, 2); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.add_sub_ifd(main); + for &(cw, ch) in copies { + ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); + } + write(&[ifd0]) + } + + /// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the + /// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. + #[test] + fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { + let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); + let full = decode(&file).unwrap(); + assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); + // a 12 px picture: the 16 px copy (its crop is 14 px long) + let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); + assert_eq!((small.width, small.height), (16, 8)); + assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); + let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; + assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); + assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); + assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); + // 20 px: the 32 px copy (28 px); 40 px: none covers it + assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); + assert!(decode_reduced(&file, 40).unwrap().is_none()); + // a different aspect ratio is not a copy of the image + assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); + // opcodes are in full-size pixels + let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); + assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); + // not a DNG + assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); + } + /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. #[test] fn private_compression_99_is_a_raw() { @@ -1416,3 +1508,40 @@ mod tests { assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); } } + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_pixel_count_wrap_inputs_rejected() { + // 65536 × 65536 = 2^32 wraps to 0 on wasm32 usize; u64 math keeps it exact. + assert_eq!(checked_pixel_count(65536, 65536), None); + assert_eq!(checked_pixel_count(65536, 65537), None); + // u32::MAX × 2 wraps on 32-bit usize. + assert_eq!(checked_pixel_count(u64::from(u32::MAX), 2), None); + // inputs wider than a 32-bit usize reach the gate untruncated (2^40 × 2^40 overflows u64). + assert_eq!(checked_pixel_count(1 << 40, 1 << 40), None); + } + + #[test] + fn checked_pixel_count_over_cap_rejected() { + // MAX_SAMPLES = 2^30; one over is rejected. + assert_eq!(checked_pixel_count((1 << 30) + 1, 1), None); + assert_eq!(checked_pixel_count(1, (1 << 30) + 1), None); + } + + #[test] + fn checked_pixel_count_at_cap_accepted() { + assert_eq!(checked_pixel_count(1 << 30, 1), Some(1 << 30)); + assert_eq!(checked_pixel_count(1 << 15, 1 << 15), Some(1 << 30)); + } + + #[test] + fn checked_pixel_count_small_accepted() { + assert_eq!(checked_pixel_count(100, 100), Some(10_000)); + assert_eq!(checked_pixel_count(1, 1), Some(1)); + assert_eq!(checked_pixel_count(0, 100), None); + assert_eq!(checked_pixel_count(100, 0), None); + } +} diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 71dfad10d..95f9bdcaa 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -713,7 +713,8 @@ mod tests { f.extend_from_slice(part); f.extend_from_slice(&[0u8; 7]); } - assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}"); + // MRW decodes; its preview is still found by the scan + assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); for n in (0..f.len()).step_by(11) { let _ = embedded_preview(&f[..n]); diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 17032f694..a781151b4 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -192,8 +192,12 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } comp::ADOBE_DEFLATE | comp::DEFLATE => { let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; - let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) }; - let raw = inflate(src, row_bytes * ch)?; + // row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate + let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too + let inflate_len = + crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + let raw = inflate(src, inflate_len as usize)?; unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float), diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index a662799ae..3938fa50d 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -245,7 +245,9 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O /// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file. fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool { let (w, h) = (info.width as u64, info.height as u64); - info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && strip_len == w * h * 3 / 2 + // checked_mul chain: a wrapping `w · h · 3` must refuse the strip, not compare against a wrapped length + let packed_len = w.checked_mul(h).and_then(|n| n.checked_mul(3)).map(|n| n / 2); + info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && packed_len == Some(strip_len) } /// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being @@ -621,9 +623,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; let info = raw.image()?; let (w, h) = (info.width as usize, info.height as usize); - if w * h > crate::MAX_SAMPLES { - return Err(RawError::Limit("image too large")); - } + // the cap (≤ 2^30) keeps every downstream usize product (w·h·2, w·h·3/2, y·w) inside range on 32-bit targets + let samples = crate::checked_pixel_count(w as u64, h as u64).ok_or(RawError::Limit("image too large"))?; let bits = info.bits() as u32; let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); @@ -631,7 +632,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one // smaller), which is a different packing, so they are refused rather than read with the wrong layout. - let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64; + let one_byte_per_sample = chunks.len() == 1 && strip_len == samples; let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len); let (data, out_bits) = match info.compression { 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), @@ -642,7 +643,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if one_byte_per_sample => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { let row = src.get(y * w..((y + 1) * w).min(src.len())).unwrap_or(&[]); if row.len() == w { @@ -659,7 +660,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if packed12 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let row_bytes = w / 2 * 3; - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) { unpack_row12(row, out); diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index aa7313e23..473f5d9ff 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,6 +1,6 @@ //! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of -//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one -//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated +//! ILCE-7RM4A and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline. //! //! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and //! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags @@ -34,13 +34,18 @@ const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x798 /// TIFF field types. const SHORT: u16 = 3; const SSHORT: u16 = 8; -/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A -/// are distinct models, not interchangeable aliases. +/// Models using the validated A7R IVA conversion. The A7CR uses a separate conversion. +/// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; /// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; +/// Empirical A7CR unit, fixed on four FE 24-50mm files before checking the other files and lenses. +/// The camera-JPEG measurements and remaining coverage limits are in docs/sony-lens-corrections.md. +const A7CR_UNIT: f64 = 15559.300457825264; +const A7CR_FRAME: (usize, usize) = (9504, 6336); +const A7CR_APPROXIMATION_PX: f64 = 0.25; #[derive(Clone, Copy)] struct Tags { @@ -307,11 +312,17 @@ pub(super) fn corrections( // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags // select an aspect crop). - let validated_model = DISTORTION_MODELS.contains(&model); - let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let a7cr = model == "ILCE-7CR"; + let validated_model = DISTORTION_MODELS.contains(&model) || a7cr; + let in_mode = !geometry.linear_ycbcr + && geometry.native_3_2 + && distortion_table.source == Some(SonySource::RawIfd) + && (!a7cr || ((geometry.reference.width, geometry.reference.height) == A7CR_FRAME && geometry.image_crop == geometry.reference)); + let a7cr_auto = distortion_setting.value.is_some_and(|s| matches!(s.meaning, SonySettingMeaning::Auto | SonySettingMeaning::ForcedAuto)); let (w, h) = (geometry.storage_width, geometry.storage_height); let warp = match &distortion_table.value { - Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + Some(table) if in_mode && a7cr && a7cr_auto => a7cr_distortion(table, Rect::new(0, 0, w, h), geometry.reference), + Some(table) if in_mode && !a7cr => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), _ => None, }; let distortion_status = match &distortion_table.value { @@ -319,6 +330,7 @@ pub(super) fn corrections( Some(_) if warp.is_some() => SonyLensStatus::Applied, Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(_) if a7cr && !a7cr_auto => SonyLensStatus::CameraCorrectionDisabled, Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, Some(_) => SonyLensStatus::Rejected, }; @@ -334,6 +346,94 @@ pub(super) fn corrections( (Some(record), warp) } +/// A7CR camera-JPEG convention, measured independently of the ILCE-7RM4A conversion below. +/// For output radius rho, the corrected radius is rho / zoom. The table gives source radius at that +/// corrected radius. Its knots use the full-frame diagonal, not storage padding. Off/unknown settings +/// and unvalidated capture modes are excluded by the caller. +fn a7cr_distortion(table: &[i16; 16], active: Rect, crop: Rect) -> Option { + if (crop.width, crop.height) != A7CR_FRAME + || crop != crop.clipped(active.width, active.height) + || table.iter().all(|v| *v == 0) + || table.iter().any(|v| !(-4096..=4096).contains(v)) + { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let spacing = cw.hypot(ch) / 30.0; + let source = |r: f64| { + let at = r / spacing; + let i = (at.floor() as usize).min(14); + let (a, b) = (f64::from(table[i]), f64::from(table[i + 1])); + r * (1.0 + (a + (b - a) * (at - i as f64)) / A7CR_UNIT) + }; + let border_min = cw.min(ch) / 2.0; + let border_max = cw.hypot(ch) / 2.0; + let border_ratio = |zoom: f64| { + (0..=4096) + .map(|j| { + let r = border_min + (border_max - border_min) * j as f64 / 4096.0; + source(r / zoom) / r + }) + .fold(0.0, f64::max) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if border_ratio(lo) <= 1.0 || border_ratio(hi) > 1.0 { + return None; + } + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if border_ratio(mid) > 1.0 { + lo = mid; + } else { + hi = mid; + } + } + let zoom = (lo + hi) / 2.0; + let radius = (cw - 1.0).hypot(ch - 1.0) / 2.0; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let mapped = source(r * radius / zoom) / radius; + if !mapped.is_finite() || mapped <= previous { + return None; + } + previous = mapped; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * mapped; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Check every displayed radius, separately from the empirical camera-JPEG validation. + for j in 0..=32768 { + let r = j as f64 / 32768.0; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + let error_px = (fit * radius - source(r * radius / zoom)).abs() * 1440.0 / cw; + if !error_px.is_finite() || error_px > A7CR_APPROXIMATION_PX || derivative <= 0.0 { + return None; + } + } + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (farthest / radius).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + /// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated /// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. /// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it @@ -454,6 +554,8 @@ mod tests { /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + /// Public issue #1300, FE 16-25mm F2.8 G at 16 mm, raw-IFD samples. + const A7CR_WIDE: [i16; 16] = [0, -14, -59, -130, -226, -341, -472, -615, -767, -924, -1084, -1242, -1397, -1549, -1696, -1839]; /// A CA table with negative and positive samples and two different curves. fn ca() -> [i16; 32] { @@ -630,7 +732,7 @@ mod tests { for order in [Little, Big] { for bigtiff in [false, true] { for (model, warps, distortion_status) in - [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedMode), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] { let case = format!("{model} {order:?} bigtiff={bigtiff}"); let mut arw = Arw::new(model, order); @@ -1030,7 +1132,7 @@ mod tests { ("ILCE-7RM4A", [i16::MIN; 16], Rejected), ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), ("ILCE-7RM4", TELE, NotValidatedModel), - ("ILCE-7CR", [0; 16], NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedMode), ("", TELE, NotValidatedModel), ] { let mut arw = Arw::new(model, order); @@ -1073,6 +1175,136 @@ mod tests { } } + fn a7cr_geometry(storage: (usize, usize)) -> SonyLensGeometry { + let crop = Rect::new(32, 20, 9504, 6336); + SonyLensGeometry { + storage_width: storage.0, + storage_height: storage.1, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 7, + linear_ycbcr: false, + native_3_2: true, + } + } + + #[test] + fn a7cr_requires_an_enabled_setting_and_validated_full_frame_mode() { + use SonyLensStatus::*; + for order in [Little, Big] { + for (setting, status) in + [(0, CameraCorrectionDisabled), (1, Applied), (17, Applied), (255, CameraCorrectionDisabled), (2, CameraCorrectionDisabled)] + { + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain = plain(&A7CR_WIDE, setting); + arw.sr2 = None; + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let run = |model: &str, geometry: SonyLensGeometry| { + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, model, geometry); + (record.unwrap().distortion.status, warp.is_some()) + }; + let geometry = a7cr_geometry((9728, 6656)); + assert_eq!(run("ILCE-7CR", geometry.clone()), (status, status == Applied)); + for model in ["ILCE-7CR ", "ILCE-7CR unknown", "ILCE-7C", "ILCE-7CM2", "ILCE-7RM4", "ILCE-7RM6"] { + assert_eq!(run(model, geometry.clone()), (NotValidatedModel, false), "{model}"); + } + let mut linear = geometry.clone(); + linear.linear_ycbcr = true; + let mut apsc = geometry.clone(); + apsc.reference = Rect::new(32, 20, 6240, 4160); + apsc.image_crop = apsc.reference; + let mut aspect = geometry.clone(); + aspect.image_crop.height = 5346; + aspect.native_3_2 = false; + let mut offset = geometry.clone(); + offset.image_crop.x = 0; + for mode in [linear, apsc, aspect, offset] { + assert_eq!(run("ILCE-7CR", mode), (NotValidatedMode, false)); + } + // Missing or malformed settings cannot enable the correction. + for value in [None, Some(Value::SShort(vec![-1])), Some(Value::Long(vec![17]))] { + arw.plain.retain(|(tag, _)| *tag != DISTORTION.setting.0); + if let Some(value) = value { + arw.plain.push((DISTORTION.setting.0, value)); + } + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, "ILCE-7CR", geometry.clone()); + assert_eq!(record.unwrap().distortion.status, CameraCorrectionDisabled); + assert!(warp.is_none()); + } + } + } + } + + #[test] + fn a7cr_storage_padding_does_not_change_the_optical_mapping() { + let crop = Rect::new(32, 20, 9504, 6336); + let mut maps = Vec::new(); + for (w, h) in [(9600, 6376), (9728, 6656)] { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&A7CR_WIDE, Rect::new(0, 0, w, h), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * (w - 1) as f64, center[1] * (h - 1) as f64); + assert!((cx - 4783.5).abs() < 1e-10 && (cy - 3187.5).abs() < 1e-10); + let radius = cx.max((w - 1) as f64 - cx).hypot(cy.max((h - 1) as f64 - cy)); + let k = planes[0]; + maps.push( + (0..=100) + .map(|j| { + let r = j as f64 / 100.0 * 5700.0; + let r2 = (r / radius).powi(2); + r * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))) + }) + .collect::>(), + ); + } + for (a, b) in maps[0].iter().zip(&maps[1]) { + assert!((a - b).abs() < 1e-9); + } + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { + assert!(a7cr_distortion(&bad, Rect::new(0, 0, 9728, 6656), crop).is_none()); + } + assert!(a7cr_distortion(&A7CR_WIDE, AREA, crop).is_none(), "crop outside storage"); + assert!(a7cr_distortion(&A7CR_WIDE, AREA, Rect::new(0, 0, 6240, 4160)).is_none(), "APS-C"); + } + + #[test] + fn a7cr_matches_independently_measured_public_jpeg_geometry() { + // Green-channel feature fits, independent of the table, on the public FE 16-25mm (issue #1300) + // and FE 50mm files. Check only the supported radii, not extrapolated corners. + let prime = [-4, -1, 3, 6, 12, 17, 23, 30, 37, 43, 50, 58, 65, 71, 76, 84]; + let cases = [ + (A7CR_WIDE, [1.0606515126386147, -0.24282593993968085, 0.1667903749262344, -0.059882313562606454], 0.93), + (prime, [0.9951622479344596, 0.00741813858793482, -0.002474290073462703, -0.000235147000260114], 0.96), + ]; + let crop = Rect::new(32, 20, 9504, 6336); + let radius = 9503f64.hypot(6335.0) / 2.0; + for (table, reference, supported) in cases { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&table, Rect::new(0, 0, 9728, 6656), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * 9727.0, center[1] * 6655.0); + let normalization = cx.max(9727.0 - cx).hypot(cy.max(6655.0 - cy)); + let k = planes[0]; + let mut worst = 0.0f64; + for j in 0..=1000 { + let r = supported * j as f64 / 1000.0; + let rho = r * radius; + let r2 = (rho / normalization).powi(2); + let mapped = rho * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))); + let expected = rho * (reference[0] + r * r * (reference[1] + r * r * (reference[2] + r * r * reference[3]))); + worst = worst.max((mapped - expected).abs() * 1440.0 / 9504.0); + } + // This smooth feature fit has localization noise. The combined budget allows the + // 0.75 px camera-table residual plus the separately bounded 0.25 px polynomial error. + assert!(worst < 1.0, "camera-JPEG shape residual {worst} px at 1440"); + } + } + #[test] fn linear_ycbcr_files_are_decoded_but_not_corrected() { // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 3fe173e09..6cab70d2f 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -19,6 +19,8 @@ //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known //! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED //! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. +//! - The same array lists the levels of the camera's white-balance presets, each with the colour temperature Canon +//! gives it ([`kelvin_presets_from_color_data`]): a Kelvin scale for a body without colour matrices. //! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`]. mod sraw; @@ -90,6 +92,41 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } +/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word +/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and +/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the +/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at +/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool +/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool +/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after +/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II +/// (`ColorData8`: 5-word records). Other versions give no presets. +fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> { + match version { + -4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]), + 13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]), + _ => None, + } +} + +/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve; +/// `None` for an unknown version, a short array or presets that don't make a curve. +fn kelvin_presets_from_color_data(v: &[u64]) -> Option { + let version = *v.first()? as u16 as i16; + let rows = preset_rows(version)?; + let mut points = Vec::with_capacity(rows.len()); + for (at, temp_at) in rows { + let q = v.get(at..at + 4)?; + let temp = *v.get(temp_at)? as f32; + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) + && (1000.0..=20000.0).contains(&temp) + { + points.push((temp, wb)); + } + } + crate::kelvin_presets::KelvinPresets::from_unordered(points) +} + /// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that /// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in /// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, @@ -161,7 +198,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let (fw, fh, nc, prec) = ljpeg::frame_info(src)?; let frame = match mode { - Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?), + Mode::Full => { + let total = (fw as u64) + .checked_mul(fh as u64) + .and_then(|v| v.checked_mul(nc as u64)) + .filter(|t| *t > 0 && *t <= crate::MAX_SAMPLES as u64) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + Some(ljpeg::decode(src, total as usize)?) + } Mode::Header => None, }; let total = match &frame { @@ -186,7 +230,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { v.push(s[2] as usize); v } - None => vec![fw * nc], + None => { + let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + vec![w as usize] + } }; let width: usize = widths.iter().sum(); if width == 0 || total % width != 0 { @@ -219,11 +266,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn - .as_ref() - .and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))) - .and_then(|v| wb_from_color_balance(&v)) - .or_else(|| wb_from_wb_info(mn.as_ref()?)); + let color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data); + let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let white = white_from_data(&active_rows(&data, width, active), prec as u32); let black = match masked_columns(&data, width, active, white) { @@ -246,7 +291,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: active, crop: Rect::new(0, 0, active.width, active.height), orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + color: ColorData { kelvin_presets, ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect: 1.0, @@ -497,6 +542,78 @@ pub(crate) mod tests { } } + /// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its + /// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63. + fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec { + let mut v = vec![0u64; 400]; + v[0] = u64::from(version as u16); + for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) { + v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]); + v[temp_at] = k.into(); + } + v + } + + /// Issue #157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its + /// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which + /// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise. + #[test] + fn kelvin_presets_from_color_data_presets() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let v = color_data_with_presets(-4, &g1x3); + let p = kelvin_presets_from_color_data(&v).unwrap(); + for (r, b, k) in g1x3 { + let (t, tint) = p.temp_tint([r as f32 / 1024.0, 1.0, b as f32 / 1024.0]).unwrap(); + assert!((t - k as f64).abs() < 1.0 && tint.abs() < 0.1, "{k}: {t} {tint}"); + } + // levels between Tungsten and Daylight: a temperature between them + let (t, _) = p.temp_tint([1264.0 / 1024.0, 1.0, 1537.0 / 1024.0]).unwrap(); + assert!((3174.0..5651.0).contains(&t), "{t}"); + let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)]; + for version in [13, 14, 15] { + assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}"); + } + // a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal + let mut odd = g1x3; + odd[4] = (1500, 1300, 9000); + assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some()); + } + + /// Unknown versions, short or garbled arrays give no presets and never panic. + #[test] + fn kelvin_presets_from_hostile_color_data() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let good = color_data_with_presets(-4, &g1x3); + let mut other = good.clone(); + other[0] = u64::from(-3i16 as u16); + assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version"); + assert!(kelvin_presets_from_color_data(&[]).is_none()); + for len in [1, 96, 120, 157, 158] { + assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}"); + } + let mut zeros = good.clone(); + for at in [102, 118, 126] { + zeros[at] = 0; + } + assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left"); + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..2000 { + let v: Vec = (0..200) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 } + }) + .collect(); + let _ = kelvin_presets_from_color_data(&v); + } + // through a whole decode, ColorData as UNDEFINED bytes + let bytes: Vec = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect(); + assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some()); + assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none()); + } + /// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. #[test] diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 853efdffe..5d69bf2d4 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -366,6 +366,39 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) } +/// The width at which high-frequency band `index` (1–9, coarsest level first) is entropy coded, +/// given its synthesis width `width`. +/// +/// A tile with a neighbour on its right codes the vertical high-pass band of the level-1 and level-2 +/// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd +/// width: an even synthesis width gains one column, which synthesis does not use. Every other band, +/// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane +/// that `tests/it/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816, +/// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5 +/// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and +/// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream +/// ends exactly at that width and at no other width from `width` to `width + 2`. The width used to be +/// probed by trying `width`, `width + 1` and `width + 2` in turn, but two widths can both consume a +/// stream exactly and give different coefficients (#899), so it is now taken from the geometry. +/// No public file has a middle tile (three or more C-RAW tiles); it is assumed to follow the left tile. +fn coded_band_width(index: usize, width: usize, edges: TileEdges) -> usize { + if edges.right && matches!(index, 5 | 8) { width | 1 } else { width } +} + +/// A high-frequency band entropy coded `coded_width` columns wide, cropped to its `width` synthesis columns. +fn coded_high_frequency_band(src: &[u8], width: usize, coded_width: usize, height: usize) -> Result> { + let values = high_frequency_band(src, coded_width, height)?; + if coded_width == width { + return Ok(values); + } + let mut cropped = Vec::new(); + cropped.try_reserve_exact(width.saturating_mul(height)).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; + for row in values.chunks_exact(coded_width) { + cropped.extend_from_slice(row.get(..width).ok_or_else(|| corrupt("band crop outside row"))?); + } + Ok(cropped) +} + fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result> { let mut dimensions = vec![(width, height)]; let mut high_dimensions = Vec::new(); @@ -383,32 +416,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge let mut bands = Vec::with_capacity(10); for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() { let bytes = band_bytes(sample, band)?; - let mut data = if i == 0 { - lossless_plane(bytes, w, h)? - } else { - // Some CRX high-frequency bands retain a redundant right column - // beyond synthesis support (LH2 in the M50/R100 fixtures). Infer - // that small extension only from a complete, correctly aligned - // entropy stream, then crop each row to the synthesis support. - let mut decoded = None; - for extra in 0..=2 { - let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?; - if let Ok(values) = high_frequency_band(bytes, coded_width, h) { - if extra == 0 { - decoded = Some(values); - } else { - let mut cropped = Vec::new(); - cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; - for row in values.chunks_exact(coded_width) { - cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?); - } - decoded = Some(cropped); - } - break; - } - } - decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))? - }; + let mut data = if i == 0 { lossless_plane(bytes, w, h)? } else { coded_high_frequency_band(bytes, w, coded_band_width(i, w, edges), h)? }; for (position, value) in data.iter_mut().enumerate() { let step = match band.quant { Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?, @@ -951,6 +959,56 @@ mod tests { } } + #[test] + fn coded_band_width_follows_the_measured_tile_geometry() { + let left_tile = TileEdges { right: true, ..TileEdges::default() }; + let right_tile = TileEdges { left: true, ..TileEdges::default() }; + let single = TileEdges::default(); + // Bands 5 and 8 of a left tile: an even width gains a column (RP 400 -> 401, R 862 -> 863, + // 90D / M6 Mark II 892 -> 893), an odd one does not (90D / M6 Mark II band 5 at 447). + for (band, width, coded) in [(5, 394, 395), (5, 400, 401), (5, 432, 433), (5, 447, 447), (8, 862, 863), (8, 892, 893), (8, 705, 705)] { + assert_eq!(coded_band_width(band, width, left_tile), coded, "band {band} at {width}"); + } + // The last level's low-pass columns are not transformed again: band 2 keeps an even width. + assert_eq!(coded_band_width(2, 198, left_tile), 198); + for band in [1, 3, 4, 6, 7, 9] { + assert_eq!(coded_band_width(band, 394, left_tile), 394, "band {band}"); + } + for band in 1..=9 { + assert_eq!(coded_band_width(band, 446, right_tile), 446, "band {band}"); + assert_eq!(coded_band_width(band, 446, single), 446, "band {band}"); + } + } + + #[test] + fn a_band_that_decodes_at_two_widths_uses_the_coded_width() { + // #899: entropy alignment alone does not identify the coded width. This 2-row stream is + // consumed exactly both 2 and 3 columns wide, with different retained coefficients. + let src = [0xc0, 0x0f]; + assert_eq!(high_frequency_band(&src, 2, 2).unwrap(), [0, 0, 5, -2]); + assert_eq!(high_frequency_band(&src, 3, 2).unwrap(), [0, 0, 5, 0, 0, 0]); + // The width probe took the first width that decoded (2). Band 8 of a left tile at an even + // width is coded one column wider, so the geometry selects 3 and crops it. + let coded = coded_band_width(8, 2, TileEdges { right: true, ..TileEdges::default() }); + assert_eq!(coded, 3); + assert_eq!(coded_high_frequency_band(&src, 2, coded, 2).unwrap(), [0, 0, 0, 0]); + // Elsewhere the same stream is read at its synthesis width. + assert_eq!(coded_high_frequency_band(&src, 2, coded_band_width(7, 2, TileEdges::default()), 2).unwrap(), [0, 0, 5, -2]); + // The #899 reproducer: an odd width is never extended, so the narrow reading is kept. + let src = [0x00, 0x33]; + assert_eq!(coded_band_width(5, 3, TileEdges { right: true, ..TileEdges::default() }), 3); + assert_eq!(coded_high_frequency_band(&src, 3, 3, 1).unwrap(), [5, 0, -1]); + assert_eq!(high_frequency_band(&src, 4, 1).unwrap(), [5, 0, 0, 0]); + } + + #[test] + fn a_band_that_fails_at_its_coded_width_is_an_error() { + // No fallback to another width: a stream that only fits 2 columns is corrupt where 3 are coded. + let src = [0xc0, 0x0f]; + assert!(high_frequency_band(&src, 4, 2).is_err()); + assert!(coded_high_frequency_band(&src, 3, 4, 2).is_err()); + } + #[test] fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() { // As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200 diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index ef9616e09..b0f65f49e 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -15,6 +15,7 @@ pub mod erf; pub(crate) mod hasselblad; pub mod kodak; pub mod mef; +pub mod mrw; pub mod nef; mod nef_lens; pub mod nefc; diff --git a/crates/raw/src/vendor/mrw.rs b/crates/raw/src/vendor/mrw.rs new file mode 100644 index 000000000..6404ec9aa --- /dev/null +++ b/crates/raw/src/vendor/mrw.rs @@ -0,0 +1,232 @@ +//! Minolta / Konica Minolta MRW: a block container (see [`lightcraft_meta::mrw`]) around a 12-bit Bayer mosaic. +//! +//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box +//! analysis of the eight CC0 MRW samples on raw.pixls.us (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum / ALPHA-7 +//! Digital); every number below was measured on them. +//! +//! - `PRD` (24 bytes, big-endian): 8 ASCII version bytes, sensor rows and columns (`u16` each), image rows and +//! columns, data size (12 or 16 bits per stored sample), pixel size (12), storage method (`0x59` = 12-bit +//! packed: two samples in three bytes, most significant bits first; `0x52` = one 12-bit sample per big-endian +//! 16-bit word), two unknown bytes and the Bayer layout as a `u16` (`1` = RGGB, `4` = GBRG). The sensor data +//! starts after the header and is exactly sensor rows x columns samples in every sample. +//! - The sensor frame is 8 columns and 6 to 8 rows larger than the image size, but every edge holds picture (no +//! masked border), so the whole frame is the active area and the image size is the default crop. +//! - Black is 0: the darkest samples are 0 in every packed file and 13..34 in the unpacked ones, with no offset +//! left to subtract. White is the saturation plateau where the frame has one (3964 on the A1, 3965 on a clipped +//! DiMAGE 7, 4020 on the A200, 4076 on the 7D), else 4095. +//! - The layout code was checked against the embedded preview: with `1` the two greens are the equal (0,1) and +//! (1,0) positions; in the A200 (`4`) the greens are (0,0) and (1,1), and only red at (1,0) reproduces the +//! preview's red-dominated colour. +//! - `WBG` (12 bytes): four scale bytes, then four big-endian `u16` coefficients in the order of the Bayer cell +//! read row by row (RGGB: R G G B, GBRG: G B R G). Red over green and blue over green are the as-shot gains; +//! applied to the raw channel means they reproduce the preview's colour balance within the colour matrix's +//! reach on all eight samples. +//! - `TTW` is a TIFF with IFD0 (orientation), Exif and the maker note; the preview JPEG sits in it with its first +//! byte overwritten (see `preview::scan_for_jpeg`). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, unpack}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::{Tiff, tags as t}; + +/// Storage method codes in `PRD`. +const PACKED_12: u8 = 0x59; +const WORDS_16: u8 = 0x52; + +/// What `PRD` says about the sensor data. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Prd { + sensor: (usize, usize), + image: (usize, usize), + packed: bool, + cfa: &'static str, +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) +} + +fn prd(b: &[u8]) -> Result { + let u = |at| be16(b, at).map(usize::from).ok_or_else(|| RawError::Corrupt("Mrw PRD block too short".into())); + let (sh, sw, ih, iw) = (u(8)?, u(10)?, u(12)?, u(14)?); + let (size, storage) = (b.get(16).copied(), b.get(18).copied()); + let packed = match (size, storage) { + (Some(12), Some(PACKED_12)) => true, + (Some(16), Some(WORDS_16)) => false, + _ => return Err(RawError::Unsupported(format!("Mrw storage {size:?}/{storage:?} is not decoded yet"))), + }; + let cfa = match u(22)? { + 1 => "RGGB", + 4 => "GBRG", + other => return Err(RawError::Unsupported(format!("Mrw Bayer layout {other}"))), + }; + if sw == 0 || sh == 0 || (packed && sw % 2 != 0) { + return Err(RawError::Corrupt("Mrw sensor size".into())); + } + Ok(Prd { sensor: (sw, sh), image: (iw.min(sw), ih.min(sh)), packed, cfa }) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let m = lightcraft_meta::mrw::parse(bytes).ok_or_else(|| RawError::Corrupt("Mrw block header".into()))?; + let p = prd(m.block(b"PRD").ok_or_else(|| RawError::Unsupported("Mrw file without a PRD block".into()))?)?; + let (w, h) = p.sensor; + let n = w.checked_mul(h).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let need = if p.packed { n / 2 * 3 } else { n * 2 }; + let src = bytes + .get(m.data_offset..) + .and_then(|s| s.get(..need)) + .ok_or_else(|| RawError::Corrupt(format!("Mrw sensor data truncated (needs {need} bytes)")))?; + let cfa = Cfa::bayer_static(p.cfa); + let (data, white) = if mode == Mode::Full { + let mut d = vec![0u16; n]; + if p.packed { + unpack::unpack_msb(src, 12, &mut d); + } else { + unpack::read_u16s(src, lightcraft_tiff::ByteOrder::Big, &mut d); + for v in &mut d { + *v = (*v).min(4095); + } + } + let white = super::white_from_data(&d, 12); + (RawData::U16(d), white) + } else { + (RawData::U16(Vec::new()), 4095.0) + }; + let wb = m.block(b"WBG").and_then(|b| wb_from(b, &cfa)); + let tiff = m.block(b"TTW").and_then(|b| Tiff::parse(b).ok()); + let mut metadata = tiff.as_ref().map(lightcraft_meta::from_tiff).unwrap_or_default(); + metadata.width = Some(p.image.0 as u32); + metadata.height = Some(p.image.1 as u32); + let orientation = tiff.as_ref().and_then(|t| t.ifds.first()).and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1); + let img = RawImage { + format: RawFormat::Mrw, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black: BlackLevel::uniform(0.0), + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, p.image.0, p.image.1), + orientation: Orientation::from_exif(orientation), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + img.validate_for(mode)?; + Ok(img) +} + +/// As-shot gains from `WBG`: four coefficients in Bayer-cell order after four scale bytes. +fn wb_from(b: &[u8], cfa: &Cfa) -> Option<[f32; 3]> { + let mut sum = [0f32; 3]; + let mut count = [0f32; 3]; + for (i, &c) in cfa.pattern.iter().enumerate().take(4) { + let v = f32::from(be16(b, 4 + 2 * i)?); + let k = usize::from(c).min(2); + sum[k] += v; + count[k] += 1.0; + } + if count.contains(&0.0) || sum.iter().any(|&s| s <= 0.0) { + return None; + } + let mean = |k: usize| sum[k] / count[k]; + Some([mean(0) / mean(1), 1.0, mean(2) / mean(1)]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A synthetic MRW: `PRD` with the given storage, `WBG`, a `TTW` TIFF with orientation 6, then the samples. + pub(crate) fn sample(w: u16, h: u16, packed: bool, layout: u16, wbg: [u16; 4], samples: &[u16]) -> Vec { + let mut prd = b"21810002".to_vec(); + for v in [h, w, h - 2, w - 2] { + prd.extend_from_slice(&v.to_be_bytes()); + } + prd.extend_from_slice(&[if packed { 12 } else { 16 }, 12, if packed { PACKED_12 } else { WORDS_16 }, 0, 0, 0]); + prd.extend_from_slice(&layout.to_be_bytes()); + let mut wb = vec![2u8; 4]; + for v in wbg { + wb.extend_from_slice(&v.to_be_bytes()); + } + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(t::MAKE, lightcraft_tiff::Value::Ascii("Minolta Co., Ltd.".into())); + ifd0.set(t::ORIENTATION, lightcraft_tiff::Value::Short(vec![6])); + let ttw = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap(); + let mut body = Vec::new(); + for (name, data) in [(b"PRD", &prd), (b"WBG", &wb), (b"TTW", &ttw)] { + body.push(0); + body.extend_from_slice(name); + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + } + let mut f = b"\0MRM".to_vec(); + f.extend_from_slice(&(body.len() as u32).to_be_bytes()); + f.extend_from_slice(&body); + if packed { + for p in samples.chunks(2) { + let (a, b) = (p[0], p.get(1).copied().unwrap_or(0)); + f.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + } else { + for v in samples { + f.extend_from_slice(&v.to_be_bytes()); + } + } + f + } + + #[test] + fn decodes_packed_and_word_storage() { + let (w, h) = (8u16, 6u16); + let px: Vec = (0..48).map(|i| (i * 85) as u16 % 4096).collect(); + for packed in [true, false] { + let f = sample(w, h, packed, 1, [408, 256, 256, 450], &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Mrw)); + let r = crate::decode(&f).unwrap(); + assert_eq!((r.width, r.height, r.bits), (8, 6, 12)); + assert_eq!(r.data, RawData::U16(px.clone()), "packed {packed}"); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB")); + assert_eq!(r.crop, Rect::new(0, 0, 6, 4)); + assert_eq!(r.orientation, Orientation::from_exif(6)); + assert_eq!(r.metadata.make.as_deref(), Some("Minolta Co., Ltd.")); + let wb = r.wb_multipliers.unwrap(); + assert!((wb[0] - 408.0 / 256.0).abs() < 1e-6 && (wb[2] - 450.0 / 256.0).abs() < 1e-6, "{wb:?}"); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + } + + /// GBRG (layout 4): the coefficients follow the cell, so the second is blue and the third red. + #[test] + fn gbrg_white_balance_follows_the_cell() { + let f = sample(4, 4, true, 4, [255, 416, 480, 255], &[100; 16]); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG")); + let wb = r.wb_multipliers.unwrap(); + assert!((wb[0] - 480.0 / 255.0).abs() < 1e-6 && (wb[2] - 416.0 / 255.0).abs() < 1e-6, "{wb:?}"); + } + + /// Truncations and corrupted header bytes give errors, never panics. + #[test] + fn malformed_files_never_panic() { + let f = sample(8, 6, true, 1, [408, 256, 256, 450], &[7; 48]); + for n in 0..f.len() { + assert!(crate::decode(&f[..n]).is_err() || n == f.len()); + } + for i in 8..f.len().min(120) { + for v in [0u8, 0xff, 0x52, 0x59] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + assert!(matches!(crate::decode(b"\0MRM\0\x01\0\0"), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(b"\0MRM\0\0\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw"))); + } +} diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 5076c6df9..62c9a2505 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -7,6 +7,9 @@ //! rule documented there; files it can't decode are reported as [`RawError::Unsupported`] and their embedded previews still work. So are High Efficiency NEFs //! (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. use super::{nefc, white_from_data}; use crate::tiffraw::{Packing, check_image, read_image}; @@ -253,7 +256,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(), ..Default::default() }, - _ => BlackLevel::uniform(nrw_black(mn.as_ref(), bits).unwrap_or(0.0)), + _ => BlackLevel::uniform(nrw_black(mn.as_ref(), bits).unwrap_or_else(|| be16_black(&info, bytes.len() as u64, mn.as_ref(), samples))), }; let wb = mn .as_ref() @@ -298,7 +301,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: Rect::new(0, 0, active_w, h), crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + color: ColorData { kelvin_presets: crate::kelvin_builtin::find(Some(&make), &model), ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect, @@ -342,8 +345,118 @@ fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&ma read_image(bytes, &top, order, packing) } -/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. Decodes the first -/// `rows` rows of the `info.height`. +/// Packed layouts of strips that are labelled compression 34713 but have no linearization table (maker note +/// `0x0096`): they hold plain packed samples, not Huffman codes. The layouts were measured from CC0 raw.pixls.us +/// files by a clean-room black-box analysis (strip byte-count identity, same-colour smoothness and correlation with +/// the camera JPEG against at least eight alternative packings). Every layout is a single strip with +/// `stride = StripByteCounts / height`, selected only when `stride * height` equals the strip size exactly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackedLayout { + /// LSB-first bit stream of `bits` per sample (12: 3 bytes per 2 samples, 14: 7 bytes per 4), rows byte aligned. + /// 12-bit rows are `3w/2` bytes (older bodies) or padded to 16 bytes (Z 6, D6); 14-bit rows are `7w/4` bytes + /// padded to 16 bytes. + Lsb { stride: usize }, + /// D100: 16-byte chunks of fifteen data bytes holding ten MSB-first 12-bit samples, plus one zero pad byte. The + /// rows carry more samples than the IFD width (3040 against 3034); the extra columns are not image. + Chunked16 { stride: usize }, + /// Coolpix NRW: one big-endian 16-bit word per pixel, rows `2w` bytes with no header or padding; the 12-bit sample + /// is the upper 12 bits of the word (the low nibble is zero, rarely 8). + Be16, +} + +fn packed_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return None; + } + let strip = usize::try_from(strip).ok()?; + if !strip.is_multiple_of(h) { + return None; + } + let stride = strip / h; + let tight = w.checked_mul(bits as usize)?.div_ceil(8); + let tight = if bits == 12 && w.is_multiple_of(2) || bits == 14 && w.is_multiple_of(4) { tight } else { return None }; + let padded = tight.checked_next_multiple_of(16)?; + let lsb_ok = match bits { + 12 => stride == tight || stride == padded, + _ => stride == padded, + }; + if lsb_ok { + return Some(PackedLayout::Lsb { stride }); + } + None +} + +fn d100_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + let strip = usize::try_from(strip).ok()?; + if bits != 12 || h == 0 || !strip.is_multiple_of(h) { + return None; + } + let stride = strip / h; + let per_row = stride / 16 * 10; + (stride.is_multiple_of(16) && per_row >= w && per_row < w.checked_add(10)?).then_some(PackedLayout::Chunked16 { stride }) +} + +/// 12-bit strips of exactly `2 * w * h` bytes: big-endian 16-bit words (Coolpix P330, P7700, P7800 NRW, found by a +/// clean-room black-box analysis of the CC0 files). Tried after the other layouts, whose strides (`1.5w` to +/// `1.5w + 15`, or ten samples per 16 bytes) only reach `2w` for rows narrower than 40 samples, so it can't take a +/// strip they handle. +fn be16_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + let want = w.checked_mul(h)?.checked_mul(2)?; + (bits == 12 && w > 0 && h > 0 && usize::try_from(strip).ok()? == want).then_some(PackedLayout::Be16) +} + +/// Black level of the big-endian 16-bit layout, which carries neither `0x003d` nor an NRW block and has no masked +/// pixels (the borders of the frame are image). The darkest samples of the three measured bodies are 214 (P330), +/// 217 (P7700) and 201 (P7800, the only one with a dark enough frame: its per-channel 1st percentile is 209 / 225 / +/// 223), and the other 12-bit Coolpix NRWs state 200 in their NRW block; so 200, never above the darkest sample. +const BE16_BLACK: f32 = 200.0; + +fn be16_black(info: &ImageInfo, file_len: u64, mn: Option<&makernote::MakerNote>, samples: &[u16]) -> f32 { + let has_table = mn.is_some_and(|m| m.ifd.bytes(LINEARIZATION_TABLE).is_some()); + let single = matches!(info.chunks(file_len).as_slice(), [c] if be16_layout(info.width as usize, info.height as usize, info.bits() as u32, c.len).is_some()); + if info.compression != t::compression::NIKON || has_table || info.samples_per_pixel != 1 || !single { + return 0.0; + } + let darkest = samples.iter().copied().min().unwrap_or(0); + BE16_BLACK.min(f32::from(darkest)) +} + +/// Unpack a [`PackedLayout`] strip into `w * h` samples. Short input leaves the missing samples zero. +fn unpack_packed(src: &[u8], w: usize, h: usize, bits: u32, layout: PackedLayout) -> Vec { + use rayon::prelude::*; + let mut out = vec![0u16; w * h]; + let stride = match layout { + PackedLayout::Lsb { stride } | PackedLayout::Chunked16 { stride } => stride, + PackedLayout::Be16 => w.saturating_mul(2), + }; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let Some(line) = src.get(y * stride..) else { return }; + let line = &line[..stride.min(line.len())]; + match layout { + PackedLayout::Lsb { .. } => crate::unpack::unpack_lsb(line, bits, row), + PackedLayout::Be16 => { + let (words, _) = line.as_chunks::<2>(); + for (dst, word) in row.iter_mut().zip(words) { + *dst = u16::from_be_bytes(*word) >> 4; + } + } + PackedLayout::Chunked16 { .. } => { + let mut samples = vec![0u16; line.len() / 16 * 10]; + for (i, dst) in samples.chunks_mut(10).enumerate() { + if let Some(chunk) = line.get(i * 16..i * 16 + 15) { + crate::unpack::unpack_msb(chunk, 12, dst); + } + } + let n = row.len().min(samples.len()); + row[..n].copy_from_slice(&samples[..n]); + } + } + }); + out +} + +/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. Without a table the +/// strip may be packed uncompressed data (see [`PackedLayout`]). fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, rows: usize) -> Result { let starts_with = |magic: &[u8]| { let start = info.chunks(bytes.len() as u64).first().and_then(|c| usize::try_from(c.offset).ok()); @@ -352,10 +465,9 @@ fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, if starts_with(&JPEG_XS_START) { return Err(RawError::Unsupported(HIGH_EFFICIENCY.into())); } - // (some Z bodies label uncompressed, row-padded data 34713 without a table: not handled here yet) - let table = mn - .and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order))) - .ok_or_else(|| RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into()))?; + let Some(table) = mn.and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order))) else { + return packed(bytes, info, rows); + }; if info.samples_per_pixel != 1 { return Err(RawError::Unsupported(format!("Nikon compressed NEF with {} samples per pixel", info.samples_per_pixel))); } @@ -370,6 +482,36 @@ fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, Ok(RawData::U16(nefc::decode_rows(src, info.width as usize, info.height as usize, rows, bits, &table)?)) } +/// A table-less "compressed" strip: packed samples when the strip size matches a measured layout exactly. +fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { + let (w, h) = (info.width as usize, info.height as usize); + let bits = info.bits() as u32; + let chunks = info.chunks(bytes.len() as u64); + let layout = match chunks.as_slice() { + [c] if info.samples_per_pixel == 1 => { + packed_layout(w, h, bits, c.len).or_else(|| d100_layout(w, h, bits, c.len)).or_else(|| be16_layout(w, h, bits, c.len)) + } + _ => 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() + })); + }; + let src = lightcraft_tiff::image::chunk_bytes(bytes, c) + .filter(|src| u64::try_from(src.len()).ok() == Some(c.len)) + .ok_or_else(|| RawError::Corrupt("NEF: incomplete image strip".into()))?; + if (w as u64).saturating_mul(h as u64) > crate::MAX_SAMPLES as u64 { + return Err(RawError::Limit("image too large")); + } + Ok(RawData::U16(unpack_packed(src, w, rows.min(h), bits, layout))) +} + #[cfg(test)] mod tests { use super::*; @@ -819,4 +961,229 @@ mod tests { let short = nef(34713, 14, vec![vec![0xff, 0x10]], 1, 1, 1); assert!(matches!(crate::decode(&short), Err(RawError::Unsupported(w)) if w.contains("linearization table"))); } + + fn samples(n: usize, bits: u32) -> Vec { + (0..n).map(|i| (((i * 2654435761usize) >> 7) as u32 % (1 << bits)) as u16).collect() + } + + fn pack_lsb(row: &[u16], bits: u32) -> Vec { + let (mut out, mut acc, mut n) = (Vec::new(), 0u64, 0u32); + for &v in row { + acc |= (v as u64) << n; + n += bits; + while n >= 8 { + out.push(acc as u8); + acc >>= 8; + n -= 8; + } + } + if n > 0 { + out.push(acc as u8); + } + out + } + + fn pack_msb12(row: &[u16]) -> Vec { + let mut out = Vec::new(); + for pair in row.chunks(2) { + let (a, b) = (pair[0], pair.get(1).copied().unwrap_or(0)); + out.extend([(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + out + } + + /// Rows of `samples` packed by `row`, each padded with `pad` bytes of 0xA5 (never decoded). + fn strip(data: &[u16], w: usize, pad: usize, row: impl Fn(&[u16]) -> Vec) -> Vec { + data.chunks(w).flat_map(|r| row(r).into_iter().chain(std::iter::repeat_n(0xA5, pad))).collect() + } + + fn decoded(strip: Vec, bits: u16, w: u32, h: u32) -> Result> { + let bytes = nef(34713, bits, vec![strip], w, h, h); + match crate::decode(&bytes)?.data { + RawData::U16(d) => Ok(d), + _ => Err(RawError::Unsupported("float".into())), + } + } + + #[test] + fn packed_34713_layouts_round_trip() { + let (w, h) = (40usize, 6usize); + let d12 = samples(w * h, 12); + let d14 = samples(w * h, 14); + // group A: 12-bit, 3w/2 bytes per row + assert_eq!(decoded(strip(&d12, w, 0, |r| pack_lsb(r, 12)), 12, 40, 6).unwrap(), d12); + // group B: 12-bit, rows padded to 16 bytes (60 -> 64) + assert_eq!(decoded(strip(&d12, w, 4, |r| pack_lsb(r, 12)), 12, 40, 6).unwrap(), d12); + // group C: 14-bit, 70 bytes -> 80 + assert_eq!(decoded(strip(&d14, w, 10, |r| pack_lsb(r, 14)), 14, 40, 6).unwrap(), d14); + // group E: 16-byte chunks, 15 data bytes (ten samples) + a zero byte; 16 chunks = 160 samples (at 40 wide the + // 64-byte stride would be group B's) + let (w, h) = (160usize, 3usize); + let d12 = samples(w * h, 12); + let e = strip(&d12, w, 0, |r| r.chunks(10).flat_map(|c| pack_msb12(c).into_iter().chain([0u8])).collect()); + assert_eq!(e.len(), 256 * h); + assert_eq!(decoded(e, 12, 160, 3).unwrap(), d12); + } + + /// Z 6II "Packed 14 bits" (maker note 0x93 = 10, issue #769): 6064x4040, 14-bit, one strip of 4040 rows of 10624 + /// bytes (6064 * 14 / 8 = 10612, padded to a multiple of 16). Four rows keep the real stride. + #[test] + fn packed_14_bit_z6ii_geometry_with_row_padding() { + let (w, h) = (6064usize, 4usize); + let d14 = samples(w * h, 14); + let s = strip(&d14, w, 12, |r| pack_lsb(r, 14)); + assert_eq!(s.len(), 10624 * h); + assert_eq!(decoded(s, 14, w as u32, h as u32).unwrap(), d14); + assert_eq!(packed_layout(6064, 4040, 14, 42_920_960), Some(PackedLayout::Lsb { stride: 10624 })); + } + + #[test] + fn packed_layout_refuses_overflowing_dimensions() { + for bits in [12, 14] { + assert!(packed_layout(usize::MAX - 3, 1, bits, u64::MAX).is_none()); + } + assert!(d100_layout(usize::MAX, 1, 12, u64::MAX).is_none()); + } + + /// Header probes inspect only the same top rows that the masked-column test uses. Routing + /// still validates the complete strip's layout, so header and full decode keep the same crop. + #[test] + fn packed_header_probe_reads_only_the_mask_rows() { + let (w, h) = (160usize, MASK_ROWS + 7); + let data = samples(w * h, 12); + for image in [strip(&data, w, 0, |r| pack_lsb(r, 12)), be16(&data)] { + let bytes = nef(34713, 12, vec![image], w as u32, h as u32, h as u32); + let tiff = Tiff::parse(&bytes).unwrap(); + let info = raw_ifd(&tiff).unwrap().image().unwrap(); + let RawData::U16(top) = packed(&bytes, &info, MASK_ROWS).unwrap() else { panic!("integer samples") }; + assert_eq!(top, data[..w * MASK_ROWS]); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = decode(&bytes, Mode::Full).unwrap(); + assert_eq!((header.active_area, header.crop), (full.active_area, full.crop)); + assert!(matches!(header.data, RawData::U16(ref samples) if samples.is_empty())); + } + } + + #[test] + fn packed_d100_rows_carry_extra_columns() { + // IFD width 154, rows hold 160 samples (16 chunks); the last 6 are dropped + let (w, wide, h) = (154usize, 160usize, 3usize); + let wide_data = samples(wide * h, 12); + let e = strip(&wide_data, wide, 0, |r| r.chunks(10).flat_map(|c| pack_msb12(c).into_iter().chain([0u8])).collect()); + let got = decoded(e, 12, w as u32, h as u32).unwrap(); + let want: Vec = wide_data.chunks(wide).flat_map(|r| r[..w].iter().copied()).collect(); + assert_eq!(got, want); + } + + #[test] + fn packed_34713_wrong_sizes_stay_refused() { + let d12 = samples(40 * 6, 12); + let good = strip(&d12, 40, 0, |r| pack_lsb(r, 12)); + // one byte more or fewer, a stride that is neither tight nor 16-aligned, a 13-bit file + for len in [good.len() - 1, good.len() + 1, good.len() + 6] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(decoded(s, 12, 40, 6), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(decoded(good.clone(), 13, 40, 6), Err(RawError::Unsupported(_)))); + // 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}"); + } + + /// Big-endian 16-bit words holding the 12-bit samples in their upper bits. + fn be16(data: &[u16]) -> Vec { + data.iter().flat_map(|v| (v << 4).to_be_bytes()).collect() + } + + #[test] + fn coolpix_be16_words_decode_and_wrong_sizes_are_refused() { + let (w, h) = (40usize, 6usize); + let d12 = samples(w * h, 12); + let good = be16(&d12); + assert_eq!(good.len(), 2 * w * h); + assert_eq!(decoded(good.clone(), 12, 40, 6).unwrap(), d12); + // a half-LSB in the low nibble does not change the sample + let mut half = good.clone(); + for b in half.iter_mut().skip(1).step_by(2) { + *b |= 8; + } + assert_eq!(decoded(half, 12, 40, 6).unwrap(), d12); + // any other strip size, or another bit depth, is not this layout + for len in [good.len() - 2, good.len() - 1, good.len() + 1, good.len() + 2, good.len() + 16] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(decoded(s, 12, 40, 6), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(decoded(good.clone(), 13, 40, 6), Err(RawError::Unsupported(_)))); + // the black level is 200, but never above the darkest sample + let black = |data: &[u16]| crate::decode(&nef(34713, 12, vec![be16(data)], 40, 6, 6)).unwrap().black.values[0]; + let mut d = d12.clone(); + for v in &mut d { + *v = (*v).max(300); + } + assert_eq!(black(&d), 200.0); + d[5] = 150; + assert_eq!(black(&d), 150.0); + } + + #[test] + fn coolpix_be16_does_not_take_other_layouts() { + // every width where another group's stride equals 2w must still go to that group (the narrow rows where they + // coincide are below the real-world widths; the other groups are tried first) + for w in (2..=400usize).step_by(2) { + let strip = 2 * w as u64 * 6; + let other = packed_layout(w, 6, 12, strip).or_else(|| d100_layout(w, 6, 12, strip)); + if other.is_some() { + assert_eq!(packed_layout(w, 6, 12, strip).or_else(|| d100_layout(w, 6, 12, strip)).or_else(|| be16_layout(w, 6, 12, strip)), other); + } + } + assert_eq!(be16_layout(4032, 3024, 12, 24385536), Some(PackedLayout::Be16)); + assert!(packed_layout(4032, 3024, 12, 24385536).is_none() && d100_layout(4032, 3024, 12, 24385536).is_none()); + assert!(be16_layout(4032, 3024, 14, 24385536).is_none()); + assert!(be16_layout(usize::MAX, usize::MAX, 12, u64::MAX).is_none()); + } + + #[test] + fn packed_34713_short_input_does_not_panic() { + let (w, h) = (40usize, 6usize); + for layout in [PackedLayout::Lsb { stride: 64 }, PackedLayout::Lsb { stride: 80 }, PackedLayout::Chunked16 { stride: 64 }, PackedLayout::Be16] + { + for len in [0, 1, 15, 16, 17, 63, 64, 65, 200] { + let bits = if matches!(layout, PackedLayout::Lsb { stride: 80 }) { 14 } else { 12 }; + assert_eq!(unpack_packed(&vec![0xFF; len], w, h, bits, layout).len(), w * h); + } + } + // a strip that claims more bytes than the file has + let mut bytes = nef(34713, 12, vec![vec![0x55; 60 * 6]], 40, 6, 6); + bytes.truncate(bytes.len() - 100); + assert!(crate::decode(&bytes).is_err(), "a truncated packed strip is corrupt"); + } + /// Keep every directory intact before a relocated tail strip, then remove its last rows. + fn truncated_tail_strip(mut bytes: Vec, keep: usize) -> Vec { + let tiff = Tiff::parse(&bytes).unwrap(); + let raw = raw_ifd(&tiff).unwrap(); + let info = raw.image().unwrap(); + let chunk = info.chunks(bytes.len() as u64)[0]; + let data = lightcraft_tiff::image::chunk_bytes(&bytes, &chunk).unwrap().to_vec(); + let offset_entry = raw.get(t::STRIP_OFFSETS).unwrap().offset as usize; + let start = bytes.len() as u32; + bytes[offset_entry..offset_entry + 4].copy_from_slice(&start.to_be_bytes()); + bytes.extend_from_slice(&data[..keep]); + bytes + } + + #[test] + fn packed_rejects_tail_truncation_with_intact_directories() { + let bytes = nef(34713, 12, vec![vec![0x55; 360]], 40, 6, 6); + for keep in [0, 60, 359] { + let short = truncated_tail_strip(bytes.clone(), keep); + for mode in [Mode::Header, Mode::Full] { + assert!(matches!(decode(&short, mode), Err(RawError::Corrupt(_)))); + } + } + } } diff --git a/crates/raw/src/vendor/raf_lens.rs b/crates/raw/src/vendor/raf_lens.rs index 93b668e2f..a976751d2 100644 --- a/crates/raw/src/vendor/raf_lens.rs +++ b/crates/raw/src/vendor/raf_lens.rs @@ -8,12 +8,13 @@ //! demosaic already registers the colour planes, so the stored shift over-corrects (see the document). //! //! Layout (`n` knots): `[scale, knot × n, value × n]`. `scale × n` is the half diagonal of the recorded image in -//! pixels. Knots are positions along the radius, the last one being the farthest -//! corner; they are evenly spaced in `r` (`0, 0.1 … 1`) or in `r²` (`√(k/8)`, last `√(9/8)`) depending on the body. +//! pixels. Knots are radii in half diagonals, evenly spaced in `r` (`0, 0.1 … 1`) or in `r²` (`√(k/8)`) depending +//! on the body; the corner is at 1. The nine-knot tables run one knot past it, to `√(9/8)` (on the X-T4 the ninth +//! repeats the eighth's value). //! //! - distortion: percent, the bracket `1 + D/100` multiplies the radius; the camera's JPEG samples the raw at -//! `source = z · r · (1 + D(ρ(source))/100)`, `ρ = knot_last · radius / half diagonal`, with the zoom `z` that -//! keeps the output rectangle inside the source (`1 / max` of the bracket over the frame's border radii). +//! `source = z · r · (1 + D(source)/100)`, radii in half diagonals, with the zoom `z` that keeps the output +//! rectangle inside the source (`1 / max` of the bracket over the frame's border radii). //! - vignetting: relative illumination in percent at the source radius; the correction is the gain `100 / V`. use crate::{Opcode, Rect}; @@ -35,6 +36,8 @@ const SCALE_TOLERANCE: f64 = 0.005; /// Camera models whose tables were checked against the camera's own JPEG (distortion within 0.01 of the best free /// radial fit's correlation on at least one lens, see docs/fuji-lens-corrections.md). Other bodies stay uncorrected. +/// The nine-knot bodies other than the X-T4 were checked with their tables read at `knot_last · r`; the plain radius +/// they are now read at was measured on the X-T4 only ("Knot radius" in that doc). const VALIDATED: &[&str] = &[ "FinePix F770EXR", "FinePix HS30EXR", @@ -143,9 +146,6 @@ impl Table { Some(i) => y[i - 1] + (y[i] - y[i - 1]) * (x - k[i - 1]) / (k[i] - k[i - 1]), } } - fn last(&self) -> f64 { - self.knots[self.knots.len() - 1] - } } /// Source radius of the output radius `r` (both in half diagonals) with the distortion zoom. @@ -155,8 +155,9 @@ struct Geometry<'a> { } impl Geometry<'_> { + /// The radius bracket `1 + D/100` at source radius `source` (half diagonals, the table's own unit); 1 without a table. fn bracket(&self, source: f64) -> f64 { - self.table.map_or(1.0, |t| 1.0 + t.at(source * t.last(), Some(0.0)) / 100.0) + self.table.map_or(1.0, |t| 1.0 + t.at(source, Some(0.0)) / 100.0) } fn source(&self, r: f64) -> f64 { let mut s = self.zoom * r; @@ -283,7 +284,7 @@ fn vignette(ifd: &Ifd, w: usize, h: usize) -> Option { if t.values.iter().any(|v| !(10.0..=130.0).contains(v)) { return None; } - let gain = |r: f64| 100.0 / t.at(r * t.last(), Some(100.0)); + let gain = |r: f64| 100.0 / t.at(r, Some(100.0)); if (0..=SAMPLES).all(|j| (gain(j as f64 / SAMPLES as f64) - 1.0).abs() < 1e-4) { return None; } @@ -347,25 +348,30 @@ mod tests { r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))) } + /// The X-T4's arrays become a one-plane warp with the frame-filling zoom and a vignette gain read at the plain radius. #[test] fn x_t4_tables_become_a_warp_and_a_vignette_gain() { let ops = corrections("X-T4", &xt4(), XT4_AREA); let [Opcode::WarpRectilinear { planes, center }, Opcode::FixVignetteRadial { k, center: vc }] = ops.as_slice() else { panic!("{ops:?}") }; assert_eq!((*center, *vc), ([0.5, 0.5], [0.5, 0.5])); assert_eq!(planes.len(), 1, "no colour planes: lateral CA is not applied"); - // barrel distortion: zoomed to fill the frame (measured against the camera JPEG: 1.030..1.033), the - // corner samples inside the raw (-6.4 % bracket) + // barrel distortion: zoomed to fill the frame, the corner samples inside the raw (-6.4 % bracket; the measured + // free fit below gives 0.9635 there) let green = &planes[0]; assert!((1.02..1.04).contains(&green[0]), "{}", green[0]); let corner = evaluate(green, 1.0); - assert!((corner - 0.9668).abs() < 0.002, "{corner}"); + assert!((corner - 0.9641).abs() < 0.002, "{corner}"); // vignette: 67.457 % at the corner, 100 % on the axis let gain = |r: f64| 1.0 + (0..5).map(|i| k[i] * r.powi(2 * i as i32 + 2)).sum::(); assert!((gain(1.0) - 100.0 / 67.45703125).abs() < 0.01, "{}", gain(1.0)); assert!((gain(0.0) - 1.0).abs() < 1e-12); - assert!((gain(0.5) - 100.0 / (96.87792969 + (94.90234375 - 96.87792969) * (0.5 * 1.06056338 - 0.5) / (0.6126760563 - 0.5))).abs() < 0.01); + // read at the plain radius: r = 0.5 is the second knot, r = 0.85 lies between the fifth and sixth + assert!((gain(0.5) - 100.0 / 96.87792969).abs() < 0.01, "{}", gain(0.5)); + let v = 84.73291016 + (78.43701172 - 84.73291016) * (0.85 - 0.7908450704) / (0.8661971831 - 0.7908450704); + assert!((gain(0.85) - 100.0 / v).abs() < 0.01, "{}", gain(0.85)); } + /// The warp follows a free radial fit of the camera's own JPEG of pixls 3913 to within 0.0012 half diagonals. #[test] fn matches_the_measured_x_t4_geometry() { // Free radial fit (data/testing/raf-lens/tools/fit_model.py) of the camera JPEG against LightCraft's uncorrected @@ -378,7 +384,7 @@ mod tests { let r = i as f64 / 20.0; let r2 = r * r; let reference = measured[0] * r * (1.0 + measured[1] * r2 + measured[2] * r2 * r2 + measured[3] * r2 * r2 * r2); - assert!((evaluate(&planes[0], r) - reference).abs() < 0.004, "{r}: {} vs {reference}", evaluate(&planes[0], r)); + assert!((evaluate(&planes[0], r) - reference).abs() < 0.0012, "{r}: {} vs {reference}", evaluate(&planes[0], r)); } } diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 7ebee5916..4acd53a02 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -48,8 +48,12 @@ //! apart with red and blue between them]; each is //! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the //! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a -//! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same -//! values in every sample; files with other values are reported as unsupported rather than guessed at. +//! few padding rows before their stated length. Tags `0x0039`–`0x003f` and `0x0041` carry the same +//! values in every sample; files with other values are reported as unsupported rather than guessed at. The high-resolution +//! mode of the DC-S1RM2 (16296 × 10856, `0x0043` = 2) tiles the image in two rows of four: `0x0045` then lists (x, y) +//! `u16` pairs (y is 0 in single-row files, so the 32-bit reading used for them is the same numbers), the tiles are +//! 5632 and 5224 rows high and are decoded exactly like strips [the tiles' areas add up to the sensor, and `0x0043` equals +//! the number of distinct tile tops in every sample]; the decoder takes the geometry from the lists, not from the body. //! //! Levels and geometry: //! @@ -130,7 +134,7 @@ const STRIP_BITS: u16 = 0x0046; const STRIP_WIDTHS: u16 = 0x0047; const STRIP_HEIGHTS: u16 = 0x0048; /// Format-8 tags of unknown meaning and the values every sample carries (as `u16` arrays). -const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ +const FORMAT8_FIXED: [(u16, &[u16]); 8] = [ (0x0039, &[6, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]), (0x003a, &[6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), (0x003b, &[65535]), @@ -139,15 +143,16 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ (0x003e, &[0]), (0x003f, &[0]), (0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - (0x0043, &[1]), ]; +/// Format-8 tile rows (ExifTool: no name): 1 for a single row of strips, 2 for the 16296 × 10856 high-resolution mode. +const TILE_ROWS: u16 = 0x0043; /// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs. const DISTORTION_INFO: u16 = 0x0119; /// Fixed-point one of the distortion coefficients and zoom. const DISTORTION_ONE: f64 = 32768.0; /// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; -/// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). +/// Most strips seen in a file is 8 (the 16296 × 10856 high-resolution mode: 4 columns by 2 rows of tiles). const MAX_STRIPS: usize = 64; const CHUNK: usize = 0x4000; @@ -346,6 +351,15 @@ fn u16_array(ifd: &Ifd, tag: u16, order: ByteOrder) -> Option> { } } +/// The format-8 tile origins (tag `0x0045`): a count, then (x, y) pairs of `u16`. +fn origins(ifd: &Ifd, order: ByteOrder) -> Result> { + let a = u16_array(ifd, STRIP_LEFT, order).ok_or_else(|| RawError::Corrupt("RW2 format 8 without tag 0x0045".into()))?; + let n = a.first().map_or(0, |&n| n as usize); + let at = |i: usize| a.get(i).map(|&v| v as usize); + let values: Option> = (0..n).map(|i| Some((at(1 + 2 * i)?, at(2 + 2 * i)?))).collect(); + values.filter(|v| !v.is_empty() && v.len() <= MAX_STRIPS).ok_or_else(|| RawError::Corrupt("RW2 format 8: bad list in tag 0x0045".into())) +} + /// A format-8 `u16` list (count, then the values; `wide`: 32-bit values as low/high pairs). fn list(ifd: &Ifd, tag: u16, order: ByteOrder, wide: bool) -> Result> { let a = u16_array(ifd, tag, order).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8 without tag {tag:#06x}")))?; @@ -355,15 +369,17 @@ fn list(ifd: &Ifd, tag: u16, order: ByteOrder, wide: bool) -> Result> { values.filter(|v| !v.is_empty() && v.len() <= MAX_STRIPS).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: bad list in tag {tag:#06x}"))) } -/// One format-8 strip: its bit stream and placement. +/// One format-8 strip or tile: its bit stream and placement. struct Strip<'a> { data: &'a [u8], + left: usize, + top: usize, width: usize, height: usize, bits: u64, } -/// The format-8 strips (tiling the full width, top to bottom) and code table. +/// The format-8 strips or tiles (together covering the whole image exactly once) and code table. fn format8<'a>(ifd: &Ifd, order: ByteOrder, bytes: &'a [u8], w: usize, h: usize) -> Result<(Vec>, Huffman)> { for (tag, want) in FORMAT8_FIXED { if let Some(v) = u16_array(ifd, tag, order) @@ -372,26 +388,40 @@ fn format8<'a>(ifd: &Ifd, order: ByteOrder, bytes: &'a [u8], w: usize, h: usize) return Err(RawError::Unsupported(format!("Panasonic raw format 8 with tag {tag:#06x} = {v:?}"))); } } - let (offsets, lefts, bits) = (list(ifd, STRIP_OFFSETS, order, true)?, list(ifd, STRIP_LEFT, order, true)?, list(ifd, STRIP_BITS, order, true)?); + let (offsets, origins, bits) = (list(ifd, STRIP_OFFSETS, order, true)?, origins(ifd, order)?, list(ifd, STRIP_BITS, order, true)?); let (widths, heights) = (list(ifd, STRIP_WIDTHS, order, false)?, list(ifd, STRIP_HEIGHTS, order, false)?); let n = offsets.len(); - if [lefts.len(), bits.len(), widths.len(), heights.len()].iter().any(|&l| l != n) { + if [origins.len(), bits.len(), widths.len(), heights.len()].iter().any(|&l| l != n) { return Err(RawError::Corrupt("RW2 format 8: strip lists of different lengths".into())); } let mut strips = Vec::with_capacity(n); - let mut x = 0usize; + let mut area = 0usize; for i in 0..n { - let (left, width, height) = (lefts[i] as usize, widths[i] as usize, heights[i] as usize); + let ((left, top), width, height) = (origins[i], widths[i] as usize, heights[i] as usize); let len = usize::try_from(bits[i].div_ceil(8)).unwrap_or(usize::MAX); let data = usize::try_from(offsets[i]).ok().and_then(|o| bytes.get(o..o.checked_add(len)?)); - let Some(data) = data.filter(|_| left == x && width > 0 && height == h) else { + let inside = width > 0 && height > 0 && left + width <= w && top + height <= h; + let Some(data) = data.filter(|_| inside) else { return Err(RawError::Corrupt(format!("RW2 format 8: strip {i} outside the image or file"))); }; - x += width; - strips.push(Strip { data, width, height, bits: bits[i] }); + area += width * height; + strips.push(Strip { data, left, top, width, height, bits: bits[i] }); } - if x != w { - return Err(RawError::Corrupt(format!("RW2 format 8: strips cover {x} of {w} columns"))); + // inside the image, no two overlapping and the areas adding up to the image: every sample has exactly one strip + let overlap = strips.iter().enumerate().any(|(i, a)| { + strips[i + 1..].iter().any(|b| a.left < b.left + b.width && b.left < a.left + a.width && a.top < b.top + b.height && b.top < a.top + a.height) + }); + if overlap || area != w * h { + return Err(RawError::Corrupt(format!("RW2 format 8: strips cover {area} of {} samples", w * h))); + } + // tag 0x0043 counts the rows of tiles; a file that says otherwise is not one we understand + let mut tops: Vec = strips.iter().map(|s| s.top).collect(); + tops.sort_unstable(); + tops.dedup(); + if let Some(v) = u16_array(ifd, TILE_ROWS, order) + && v != [tops.len() as u16] + { + return Err(RawError::Unsupported(format!("Panasonic raw format 8 with tag {TILE_ROWS:#06x} = {v:?} for {} tile rows", tops.len()))); } let codes = u16_array(ifd, HUFFMAN_CODES, order).unwrap_or_default(); let pairs: Vec<(u32, u32)> = match (codes.first(), codes.get(1..35)) { @@ -452,12 +482,21 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result fn decode_strips(strips: &[Strip], codes: &Huffman, w: usize, out: &mut [u16]) -> Result<()> { let mut parts: Vec> = strips.iter().map(|s| Vec::with_capacity(s.height)).collect(); - for row in out.chunks_mut(w) { - let mut rest = row; - for (s, rows) in strips.iter().zip(parts.iter_mut()) { - let (part, tail) = rest.split_at_mut(s.width.min(rest.len())); - rows.push(part); + for (y, row) in out.chunks_mut(w).enumerate() { + // the strips crossing this row, left to right, each take their columns off its front + let mut here: Vec = (0..strips.len()).filter(|&i| strips.get(i).is_some_and(|s| s.top <= y && y < s.top + s.height)).collect(); + here.sort_by_key(|&i| strips.get(i).map_or(0, |s| s.left)); + let (mut rest, mut x) = (row, 0usize); + for i in here { + let Some(s) = strips.get(i) else { continue }; + let skip = s.left.saturating_sub(x).min(rest.len()); + let (_, tail) = rest.split_at_mut(skip); + let (part, tail) = tail.split_at_mut(s.width.min(tail.len())); + if let Some(rows) = parts.get_mut(i) { + rows.push(part); + } rest = tail; + x = s.left + s.width; } } strips.par_iter().zip(parts).try_for_each(|(s, rows)| decode_strip(s, codes, rows)) @@ -1115,16 +1154,33 @@ mod tests { /// A format-8 file with the given strip widths; `tweak` edits the tags before writing. fn format8_file(w: usize, h: usize, px: &[u16], widths: &[usize], table: &[(u32, u32); 17], tweak: &dyn Fn(&mut Vec<(u16, Value)>)) -> Vec { + format8_tiled_file(w, px, widths, &[h], table, tweak) + } + + /// A format-8 file tiled in rows of the given heights, each cut into the given widths. + fn format8_tiled_file( + w: usize, + px: &[u16], + widths: &[usize], + rows: &[usize], + table: &[(u32, u32); 17], + tweak: &dyn Fn(&mut Vec<(u16, Value)>), + ) -> Vec { + let h: usize = rows.iter().sum(); let mut payload = vec![]; - let mut strips = vec![]; // (offset in payload, bits, left, width) - let mut left = 0; - for &sw in widths { - let sub: Vec = (0..h).flat_map(|y| px[y * w + left..y * w + left + sw].to_vec()).collect(); - let (data, bits) = encode_strip(&sub, sw, h, table, 2); - strips.push((payload.len(), bits, left, sw)); - payload.extend(data); - payload.resize(payload.len().div_ceil(16) * 16, 0); - left += sw; + let mut strips = vec![]; // (offset in payload, bits, left, width, top, height) + let mut top = 0; + for &th in rows { + let mut left = 0; + for &sw in widths { + let sub: Vec = (top..top + th).flat_map(|y| px[y * w + left..y * w + left + sw].to_vec()).collect(); + let (data, bits) = encode_strip(&sub, sw, th, table, 2); + strips.push((payload.len(), bits, left, sw, top, th)); + payload.extend(data); + payload.resize(payload.len().div_ceil(16) * 16, 0); + left += sw; + } + top += th; } let u16s = |v: Vec| undefined(&v); let wide = |v: Vec| u16s(std::iter::once(v.len() as u16).chain(v.iter().flat_map(|&x| [x as u16, (x >> 16) as u16])).collect()); @@ -1135,10 +1191,11 @@ mod tests { for (tag, v) in FORMAT8_FIXED { tags.push(if v.len() == 1 { short(tag, v[0]) } else { (tag, u16s(v.to_vec())) }); } - tags.push((STRIP_LEFT, wide(strips.iter().map(|s| s.2 as u64).collect()))); + tags.push(short(TILE_ROWS, rows.len() as u16)); + tags.push((STRIP_LEFT, u16s(std::iter::once(strips.len() as u16).chain(strips.iter().flat_map(|s| [s.2 as u16, s.4 as u16])).collect()))); tags.push((STRIP_BITS, wide(strips.iter().map(|s| s.1).collect()))); tags.push((STRIP_WIDTHS, narrow(strips.iter().map(|s| s.3 as u64).collect()))); - tags.push((STRIP_HEIGHTS, narrow(vec![h as u64; strips.len()]))); + tags.push((STRIP_HEIGHTS, narrow(strips.iter().map(|s| s.5 as u64).collect()))); tweak(&mut tags); let offsets: Vec = strips.iter().map(|s| s.0).collect(); file(w as u16, h as u16, &tags, &payload, &|off| { @@ -1159,6 +1216,23 @@ mod tests { } } + #[test] + fn format8_tiles_in_two_rows_round_trip() { + // the DC-S1RM2 high-resolution mode: four columns by two rows of tiles, tag 0x0043 = 2 + let (w, h) = (96usize, 40usize); + let px = image(w, h, 520, 16000); + for (widths, rows) in [(vec![24, 24, 24, 24], vec![24, 16]), (vec![40, 56], vec![20, 20]), (vec![96], vec![8, 32])] { + let bytes = format8_tiled_file(w, &px, &widths, &rows, &TABLE14, &|_| {}); + let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{widths:?} x {rows:?}: {e}")); + assert_eq!(samples(&r), px, "{widths:?} x {rows:?}"); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + } + // a tile row count that disagrees with the tiles, or tiles that leave a gap, are refused + let case = |tag: u16, v: Value| crate::decode(&format8_tiled_file(w, &px, &[48, 48], &[20, 20], &TABLE14, &|t| replace(t, tag, v.clone()))); + assert!(case(TILE_ROWS, Value::Short(vec![1])).is_err_and(|e| matches!(e, RawError::Unsupported(_)))); + assert!(case(STRIP_HEIGHTS, undefined(&[4, 20, 20, 20, 12])).is_err_and(|e| matches!(e, RawError::Corrupt(_)))); + } + #[test] fn format8_cell_is_coded_column_by_column() { // One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values @@ -1175,7 +1249,12 @@ mod tests { let mut data: Vec = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect(); data.extend([0; 8]); let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]); - decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap(); + decode_strip( + &Strip { data: &data, left: 0, top: 0, width: 2, height: 2, bits: n }, + &code_table(&TABLE14).unwrap(), + vec![&mut top, &mut bottom], + ) + .unwrap(); assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right"); } diff --git a/crates/raw/tests/body_corpus.rs b/crates/raw/tests/it/body_corpus.rs similarity index 100% rename from crates/raw/tests/body_corpus.rs rename to crates/raw/tests/it/body_corpus.rs diff --git a/crates/raw/tests/canon_pentax_corpus.rs b/crates/raw/tests/it/canon_pentax_corpus.rs similarity index 100% rename from crates/raw/tests/canon_pentax_corpus.rs rename to crates/raw/tests/it/canon_pentax_corpus.rs diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/it/corpus.rs similarity index 94% rename from crates/raw/tests/corpus.rs rename to crates/raw/tests/it/corpus.rs index 31e62f7dc..0ec60849a 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/it/corpus.rs @@ -4,7 +4,8 @@ //! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, //! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times -//! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). +//! (`cargo test -p lightcraft-raw --release --test it corpus:: -- --nocapture --skip _corpus::`; the +//! `--skip` leaves out the other `*_corpus` modules). use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe, probe_info}; use std::path::{Path, PathBuf}; @@ -946,7 +947,7 @@ fn corpus_samsung_srw() { #[test] fn corpus_fujifilm_compressed_samples() { use sha2::{Digest, Sha256}; - let checksums = include_str!("../../../docs/raf-corpus.sha256"); + let checksums = include_str!("../../../../docs/raf-corpus.sha256"); let cases: &[(&str, usize, usize, u64, u64)] = &[ ("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080), ("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551), @@ -1200,7 +1201,7 @@ fn sony_pins_match_the_corpus_manifest() { lines.sort(); let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); - let manifest = include_str!("../../../xtask/src/main.rs"); + let manifest = include_str!("../../../../xtask/src/main.rs"); for (name, sha) in SONY_SAMPLES { let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); @@ -1253,7 +1254,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-compressed.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::CameraCorrectionDisabled, (9600, 6376), 32767, false, @@ -1264,7 +1265,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-lossless-l.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::CameraCorrectionDisabled, (9728, 6656), 7, false, @@ -1275,7 +1276,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-lossless-m.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::NotValidatedMode, (6656, 4608), 7, true, @@ -1362,6 +1363,38 @@ fn sony_lens_records_of_the_public_samples() { eprintln!("Sony lens records: {checked} checks on corpus files"); } +/// These pinned JPEGs have correction Off. Changing only the raw-IFD setting tests decoder wiring, +/// not agreement with those JPEGs. The empirical corrected references are documented separately. +#[test] +fn a7cr_enabled_distortion_agrees_in_header_full_and_dng() { + use lightcraft_raw::lens_metadata::SonyLensStatus; + let root = corpus_root().join("raw"); + let mut checked = 0; + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw"] { + let Some(mut bytes) = pinned_sony(&root, name) else { continue }; + let original = probe_info(&bytes).unwrap(); + assert!(original.opcodes.list3.is_empty(), "Off files must remain uncorrected"); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let header = probe_info(&bytes).unwrap(); + let full = decode(&bytes).unwrap(); + assert_eq!(header, full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap(); + let linear = s.geometry.linear_ycbcr; + assert_eq!(s.distortion.status, if linear { SonyLensStatus::NotValidatedMode } else { SonyLensStatus::Applied }); + assert_eq!(full.opcodes.list3.len(), usize::from(!linear)); + // DNG export keeps the normalized map, including the different lossless storage padding. + let dng = lightcraft_raw::write_dng(&full, &Default::default()).unwrap(); + let back = probe_info(&dng).unwrap(); + assert_eq!(back.opcodes.list3, full.opcodes.list3, "{name}"); + checked += 1; + } + assert!(checked == 0 || checked == 3, "run with all three pinned A7CR codings"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. @@ -1532,3 +1565,51 @@ fn corpus_mamiya_mef() { let step: f64 = d.chunks(4016).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::() / (400.0 * 4014.0); assert!(step < 40.0, "rough {step}"); } + +/// Minolta MRW (`crates/raw/src/vendor/mrw.rs`): packed and 16-bit-word storage, RGGB and GBRG cells, the image +/// size as the crop, the `WBG` gains in cell order and an embedded preview. Skips files that are absent. +#[test] +fn corpus_minolta_mrw() { + let root = corpus_root().join("raw"); + let mut checked = 0; + // (file, sensor size, crop size, cell, white, red gain, blue gain) + for (name, sensor, crop, cell, white, r, b) in [ + ("mrw-minolta-dimage7hi-words16.mrw", (2568, 1928), (2560, 1920), "RGGB", 4095.0, 411.0 / 256.0, 343.0 / 256.0), + ("mrw-minolta-dimage-a1.mrw", (2568, 1928), (2560, 1920), "RGGB", 3964.0, 341.0 / 256.0, 539.0 / 256.0), + ("mrw-minolta-dimage-a200-gbrg.mrw", (3280, 2454), (3264, 2448), "GBRG", 4020.0, 480.0 / 255.0, 416.0 / 255.0), + ("mrw-minolta-dynax7d.mrw", (3016, 2008), (3008, 2000), "RGGB", 4076.0, 435.0 / 256.0, 435.0 / 256.0), + ] { + let Ok(bytes) = std::fs::read(root.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Mrw), "{name}"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(((img.width, img.height), img.bits), (sensor, 12), "{name}"); + assert_eq!((img.crop.width, img.crop.height), crop, "{name}"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cell), "{name}"); + assert!((img.white_at(0) - white).abs() <= 8.0, "{name}: white {}", img.white_at(0)); + assert_eq!(img.black.mean(), 0.0, "{name}"); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance")); + assert!((wb[0] - r).abs() < 1e-4 && (wb[2] - b).abs() < 1e-4, "{name}: {wb:?}"); + assert!(img.metadata.make.as_deref().is_some_and(|m| m.to_ascii_uppercase().contains("MINOLTA")), "{name}: {:?}", img.metadata.make); + // the two greens of the cell agree (a wrong cell or a packing slip would split them) + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let (w, h) = sensor; + let mean = |y0: usize, x0: usize| { + let (mut s, mut n) = (0f64, 0f64); + for y in (y0..h).step_by(2) { + for x in (x0..w).step_by(2) { + s += f64::from(d[y * w + x]); + n += 1.0; + } + } + s / n + }; + let (g1, g2) = if cell == "RGGB" { (mean(0, 1), mean(1, 0)) } else { (mean(0, 0), mean(1, 1)) }; + assert!((g1 - g2).abs() < 0.02 * g1, "{name}: greens {g1} vs {g2}"); + assert!(embedded_preview(&bytes).is_some(), "{name}: no preview"); + checked += 1; + } + eprintln!("MRW: {checked} corpus files checked"); +} diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/it/coverage_sweep_corpus.rs similarity index 97% rename from crates/raw/tests/coverage_sweep_corpus.rs rename to crates/raw/tests/it/coverage_sweep_corpus.rs index 78f384651..5aabe5365 100644 --- a/crates/raw/tests/coverage_sweep_corpus.rs +++ b/crates/raw/tests/it/coverage_sweep_corpus.rs @@ -10,7 +10,7 @@ use lightcraft_raw::{RawData, decode, probe_info}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); +const RECORDS: &str = include_str!("../../../../docs/raw/coverage-sweep.tsv"); #[test] fn coverage_sweep_sensor_decodes_are_pinned() { diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/it/cr3_corpus.rs similarity index 100% rename from crates/raw/tests/cr3_corpus.rs rename to crates/raw/tests/it/cr3_corpus.rs diff --git a/crates/raw/tests/cr3_variants.rs b/crates/raw/tests/it/cr3_variants.rs similarity index 100% rename from crates/raw/tests/cr3_variants.rs rename to crates/raw/tests/it/cr3_variants.rs diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/it/crx_properties.rs similarity index 98% rename from crates/raw/tests/crx_properties.rs rename to crates/raw/tests/it/crx_properties.rs index 32c9f65d3..804970de4 100644 --- a/crates/raw/tests/crx_properties.rs +++ b/crates/raw/tests/it/crx_properties.rs @@ -250,6 +250,7 @@ fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec { // --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) --- +#[derive(Clone)] struct Band { width: usize, height: usize, @@ -345,6 +346,17 @@ fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: boo [(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)] } +/// The band as the camera codes it (crx.rs `coded_band_width`): with a tile on the right, bands 5 and 8 +/// are coded at an odd width, so an even band gains a column that synthesis ignores (zero here). +fn coded_band(band: &Band, index: usize, after: bool) -> Band { + if !(after && matches!(index, 5 | 8) && band.width.is_multiple_of(2)) { + return band.clone(); + } + let width = band.width + 1; + let data = band.data.chunks_exact(band.width).flat_map(|row| row.iter().copied().chain([0])).collect(); + Band { width, height: band.height, data } +} + fn encode_band(band: &Band, lowpass: bool) -> Vec { let mut bits = BitWriter::default(); if lowpass { @@ -568,7 +580,7 @@ fn encode_tile_planes( *plane_bytes = vec![encode_band(&band, true)]; } else { let bands = forward_bands(values, plane_w, plane_h, before, after, halo); - *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(&coded_band(band, i, after), i == 0)).collect(); } } encoded diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/it/dng.rs similarity index 100% rename from crates/raw/tests/dng.rs rename to crates/raw/tests/it/dng.rs diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/it/lossy_dng.rs similarity index 100% rename from crates/raw/tests/lossy_dng.rs rename to crates/raw/tests/it/lossy_dng.rs diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs new file mode 100644 index 000000000..8834ed0f0 --- /dev/null +++ b/crates/raw/tests/it/main.rs @@ -0,0 +1,20 @@ +//! The integration tests, one module per file, linked into one test binary. +//! `tests/sr2_heap.rs` stays a binary of its own: it installs dhat as the global allocator. + +mod body_corpus; +mod canon_pentax_corpus; +mod corpus; +mod coverage_sweep_corpus; +mod cr3_corpus; +mod cr3_variants; +mod crx_properties; +mod dng; +mod lossy_dng; +mod nrw_be16; +mod olympus_samsung_corpus; +mod rendered_dng; +mod robust; +mod sony_leica_corpus; +mod sony_lens_inventory; +mod sparse_corpus; +mod vendor_robust; diff --git a/crates/raw/tests/it/nrw_be16.rs b/crates/raw/tests/it/nrw_be16.rs new file mode 100644 index 000000000..73bf3dcc5 --- /dev/null +++ b/crates/raw/tests/it/nrw_be16.rs @@ -0,0 +1,49 @@ +//! Coolpix NRW files stored as big-endian 16-bit words under compression 34713 (P330, P7700, P7800; CC0 samples +//! from raw.pixls.us). Reads `*.nrw` below `LIGHTCRAFT_NRW_DIR` (default `corpus/raw`), recursively; skips cleanly +//! when no such file is there. + +use lightcraft_raw::{RawData, decode}; +use std::path::{Path, PathBuf}; + +fn nrw_files(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for p in rd.flatten().map(|e| e.path()) { + if p.is_dir() && depth < 3 { + nrw_files(&p, depth + 1, out); + } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("nrw")) { + out.push(p); + } + } +} + +#[test] +fn coolpix_nrw_be16_decodes() { + let root = + std::env::var_os("LIGHTCRAFT_NRW_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/raw")); + let mut files = Vec::new(); + nrw_files(&root, 0, &mut files); + let mut checked = 0; + for p in files { + let Ok(bytes) = std::fs::read(&p) else { continue }; + let Ok(img) = decode(&bytes) else { continue }; + let model = img.metadata.model.clone().unwrap_or_default(); + if !["P330", "P7700", "P7800"].iter().any(|m| model.contains(m)) { + continue; + } + let RawData::U16(d) = &img.data else { panic!("{model}: not integer data") }; + assert_eq!((img.width, img.height, img.bits), (4032, 3024, 12), "{model}"); + assert!(d.iter().all(|&v| v < 4096), "{model}: sample above 12 bits"); + assert_eq!(img.black.values.first().copied(), Some(200.0), "{model}"); + // same-colour neighbours are smooth: the upper 12 bits of the word, not the lower byte + let (mut sum, mut n) = (0u64, 0u64); + for row in d.chunks(img.width).step_by(7) { + for x in 0..img.width - 2 { + sum += u64::from(row[x].abs_diff(row[x + 2])); + n += 1; + } + } + assert!(sum / n.max(1) < 120, "{model}: mean same-colour step {}", sum / n.max(1)); + checked += 1; + } + eprintln!("coolpix_nrw_be16: {checked} files checked"); +} diff --git a/crates/raw/tests/olympus_samsung_corpus.rs b/crates/raw/tests/it/olympus_samsung_corpus.rs similarity index 100% rename from crates/raw/tests/olympus_samsung_corpus.rs rename to crates/raw/tests/it/olympus_samsung_corpus.rs diff --git a/crates/raw/tests/rendered_dng.rs b/crates/raw/tests/it/rendered_dng.rs similarity index 100% rename from crates/raw/tests/rendered_dng.rs rename to crates/raw/tests/it/rendered_dng.rs diff --git a/crates/raw/tests/robust.proptest-regressions b/crates/raw/tests/it/robust.proptest-regressions similarity index 100% rename from crates/raw/tests/robust.proptest-regressions rename to crates/raw/tests/it/robust.proptest-regressions diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/it/robust.rs similarity index 100% rename from crates/raw/tests/robust.rs rename to crates/raw/tests/it/robust.rs diff --git a/crates/raw/tests/sony_leica_corpus.rs b/crates/raw/tests/it/sony_leica_corpus.rs similarity index 100% rename from crates/raw/tests/sony_leica_corpus.rs rename to crates/raw/tests/it/sony_leica_corpus.rs diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/it/sony_lens_inventory.rs similarity index 100% rename from crates/raw/tests/sony_lens_inventory.rs rename to crates/raw/tests/it/sony_lens_inventory.rs diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/it/sparse_corpus.rs similarity index 99% rename from crates/raw/tests/sparse_corpus.rs rename to crates/raw/tests/it/sparse_corpus.rs index a602dd148..7f50d935d 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/it/sparse_corpus.rs @@ -5,7 +5,7 @@ //! For every file the plan reaches `Ready` on, probing the partly loaded buffer must give exactly //! what probing the whole file gives — with the skipped bytes zero and again with them 0xFF, so a //! reader that looked at skipped data would be caught. CR3 and DNG files must be described from a -//! small part of the file (`cargo test -p lightcraft-raw --release --test sparse_corpus -- --nocapture` +//! small part of the file (`cargo test -p lightcraft-raw --release --test it sparse_corpus:: -- --nocapture` //! prints what each format reads). use lightcraft_raw::sparse::{HeaderPlan, Ranges, header_plan, preview_plan}; diff --git a/crates/raw/tests/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs similarity index 100% rename from crates/raw/tests/vendor_robust.rs rename to crates/raw/tests/it/vendor_robust.rs diff --git a/crates/segment/src/detector.rs b/crates/segment/src/detector.rs index 7c4eccbd3..c302aafe7 100644 --- a/crates/segment/src/detector.rs +++ b/crates/segment/src/detector.rs @@ -41,6 +41,9 @@ pub struct Output { pub boxes: Vec<[f32; 4]>, /// The text features `[1, 32, 256]` (tests). pub text: Tensor, + /// Semantic logits `[288, 288]` flattened: everything the prompt names, one mask without + /// boundaries between instances. + pub semantic: Vec, } struct EncoderLayer { @@ -91,6 +94,7 @@ pub struct Detector { pixel_convs: Vec, pixel_norms: Vec, instance_proj: Conv2d, + semantic_proj: Conv2d, mask_embedder: Vec, } @@ -155,6 +159,7 @@ impl Detector { pixel_convs: (0..3).map(|i| Ok(candle_nn::conv2d(HIDDEN, HIDDEN, 3, conv3, p.pp(format!("conv_layers.{i}")))?)).collect::>()?, pixel_norms: (0..3).map(|i| Ok(candle_nn::group_norm(8, HIDDEN, 1e-5, p.pp(format!("norms.{i}")))?)).collect::>()?, instance_proj: candle_nn::conv2d(HIDDEN, HIDDEN, 1, Conv2dConfig::default(), m.pp("instance_projection"))?, + semantic_proj: candle_nn::conv2d(HIDDEN, 1, 1, Conv2dConfig::default(), m.pp("semantic_projection"))?, mask_embedder: (0..3).map(|i| linear(HIDDEN, HIDDEN, m.pp(format!("mask_embedder.layers.{i}")))).collect::>()?, }) } @@ -268,6 +273,7 @@ impl Detector { prev = norm.forward(&conv.forward(&prev)?)?.relu()?; } let inst = self.instance_proj.forward(&prev)?; // [1, 256, 288, 288] + let semantic = to_vec(&self.semantic_proj.forward(&prev)?)?; let (_, c, mh, mw) = inst.dims4()?; let mut e = queries.clone(); for (i, l) in self.mask_embedder.iter().enumerate() { @@ -285,24 +291,98 @@ impl Detector { presence: to_vec(&presence)?.first().copied().unwrap_or(-10.0), boxes: bv.as_chunks::<4>().0.to_vec(), text, + semantic, }) } } +/// How far below 0 the strongest instance may be where it still takes its share of the semantic mask. +const NEAR: f32 = 4.0; + +/// Gives each of `instances` its share of `semantic` (everything the prompt names, one mask): where an +/// instance is the strongest and near (above −[`NEAR`]), the semantic logits replace its own (it keeps +/// its own where it is surer of itself). An instance's own logits stay close to 0 even well inside it +/// (a person comes out about 90 % selected) and both fade where two instances touch, which leaves a gap +/// between them; the semantic mask is surer inside and runs on across that boundary. Cells far from +/// every instance keep their logits, so things no instance found don't join one. +pub fn share(instances: &mut [Vec], semantic: &[f32]) { + let n = semantic.len(); + if instances.is_empty() || instances.iter().any(|l| l.len() != n) { + return; + } + for (i, s) in semantic.iter().enumerate() { + let Some((k, best)) = instances.iter().enumerate().map(|(k, l)| (k, l[i])).max_by(|a, b| a.1.total_cmp(&b.1)) else { return }; + if best > -NEAR { + let l = &mut instances[k][i]; + *l = if *l > 0.0 { l.max(*s) } else { *s }; + } + } +} + /// The instances of `out` scoring above `threshold` (sigmoid(logit) × sigmoid(presence)), -/// merged into one mask (the per-pixel maximum of their probabilities, as logits). +/// merged into one mask: each its share of the semantic mask ([`share`]), then the per-pixel +/// maximum of their probabilities, as logits. pub fn merge(out: &Output, threshold: f32) -> Option { let s = |x: f32| 1.0 / (1.0 + (-x).exp()); let presence = s(out.presence); let keep: Vec<(usize, f32)> = out.logits.iter().enumerate().map(|(i, l)| (i, s(*l) * presence)).filter(|(_, p)| *p > threshold).collect(); let best = keep.iter().map(|k| k.1).fold(0f32, f32::max); - let mut merged: Option> = None; - for (i, _) in &keep { - let m = out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())?; - merged = Some(match merged { - None => m, - Some(acc) => acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect(), - }); - } + let mut masks: Vec> = keep.iter().map(|(i, _)| out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())).collect::>()?; + share(&mut masks, &out.semantic); + let merged = masks.into_iter().reduce(|acc, m| acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect()); merged.map(|logits| Prediction { logits, score: best }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Two people side by side on a row of cells, each only weakly sure of themself and both fading where + /// they touch; the semantic mask is sure of both and runs across. Each takes the semantic logits where + /// it is the strongest instance, a third region nobody is near keeps its own. + #[test] + fn instances_share_the_semantic_mask() { + // cells: a (0..3), the seam (3), b (4..7), floor beside b (7), someone neither found (8), background + let a = vec![2.0, 2.5, 1.0, -0.5, -3.0, -5.0, -6.0, -3.5, -9.0, -9.0]; + let b = vec![-6.0, -5.0, -3.0, -1.0, 1.0, 2.5, 2.0, -3.0, -9.0, -9.0]; + let mut inst = vec![a, b]; + let semantic = [5.0, 6.0, 5.0, 4.0, 5.0, 6.0, 5.0, -7.0, 5.0, -8.0]; + share(&mut inst, &semantic); + assert_eq!(inst[0][..4], [5.0, 6.0, 5.0, 4.0], "a's share, the seam included (a is the stronger there)"); + assert_eq!(inst[1][4..7], [5.0, 6.0, 5.0], "b's share"); + assert_eq!(inst[0][4..7], [-3.0, -5.0, -6.0], "a stays out of b"); + assert_eq!(inst[1][7], -7.0, "the floor near b is surely background"); + assert_eq!([inst[0][8], inst[1][8]], [-9.0, -9.0], "someone neither instance is near joins neither"); + // an instance surer than the semantic mask keeps its own logits + let mut sure = vec![vec![9.0, 1.0]]; + share(&mut sure, &[5.0, 5.0]); + assert_eq!(sure[0], [9.0, 5.0]); + // mismatched sizes (no semantic mask) leave the instances alone + let mut none = vec![vec![1.0, 2.0]]; + share(&mut none, &[]); + assert_eq!(none[0], [1.0, 2.0]); + } + + /// Describe: the instances above the threshold, each with its share of the semantic mask, merged; + /// a query below the threshold adds nothing, and nothing found is `None`. + #[test] + fn describe_merges_the_shares_of_what_it_found() { + // two instances, weak inside and both fading where they touch (cell 2); a third query scores low + let masks = [[2.0f32, 1.0, -0.5, -3.0, -6.0], [-6.0, -3.0, -1.0, 1.0, 2.0], [-9.0, -9.0, -9.0, -9.0, 3.0]]; + let output = |logits: [f32; 3], semantic: &[f32]| Output { + masks: Tensor::from_vec(masks.concat(), (3, 1, 5), &Device::Cpu).unwrap(), + logits: logits.to_vec(), + presence: 8.0, + boxes: vec![[0.0; 4]; 3], + text: Tensor::zeros(1, candle_core::DType::F32, &Device::Cpu).unwrap(), + semantic: semantic.to_vec(), + }; + let semantic = [5.0, 6.0, 4.0, 6.0, 5.0]; + let merged = merge(&output([4.0, 4.0, -6.0], &semantic), 0.5).unwrap(); + assert_eq!(merged.logits, semantic, "sure inside both and across where they touch"); + // without a semantic mask: the per-pixel maximum of the instances, as before + let plain = merge(&output([4.0, 4.0, -6.0], &[]), 0.5).unwrap(); + assert_eq!(plain.logits, [2.0, 1.0, -0.5, 1.0, 2.0]); + assert!(merge(&output([-6.0; 3], &semantic), 0.5).is_none(), "nothing found"); + } +} diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index 6d488a919..6c24d0836 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -87,6 +87,11 @@ pub fn is_model_dir(dir: &Path) -> bool { dir.join(WEIGHTS_FILE).is_file() && dir.join("vocab.json").is_file() && dir.join("merges.txt").is_file() } +#[cfg(not(target_arch = "wasm32"))] +/// Whether [`best_device`] can be a GPU in this build (Metal on macOS). Where it can, a caller that +/// sees the model fail there can load it again with [`Sam3::load_cpu`]. +pub const GPU_BUILD: bool = cfg!(target_os = "macos"); + #[cfg(not(target_arch = "wasm32"))] /// The best device here: Metal on macOS (when a GPU is available), else the CPU. pub fn best_device() -> Device { @@ -134,6 +139,11 @@ impl Sam3 { Self::load_on(dir, best_device()) } + /// [`Sam3::load`] on the CPU, whatever GPU there is. + pub fn load_cpu(dir: &Path) -> Result { + Self::load_on(dir, Device::Cpu) + } + pub fn load_on(dir: &Path, device: Device) -> Result { if !is_model_dir(dir) { return Err(Error::Missing(dir.to_path_buf())); diff --git a/crates/segment/src/remote.rs b/crates/segment/src/remote.rs index cdee096fe..10f7b2931 100644 --- a/crates/segment/src/remote.rs +++ b/crates/segment/src/remote.rs @@ -108,10 +108,10 @@ impl Client { } } -pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result>, String> { +pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> crate::Result>> { let mut merged: Option> = None; for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { - if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? { + if let Some(p) = model.segment_text(enc, phrase, 0.5)? { if let Some(m) = &mut merged { for (a, b) in m.iter_mut().zip(p.logits) { *a = a.max(b); @@ -130,7 +130,7 @@ fn predict(model: &mut Sam3, enc: &mut Encoded, operation: Operation) -> Result< model.prepare_clicks(enc).map_err(|e| e.to_string())?; Ok(None) } - Operation::Text(text) => text_logits(model, enc, &text), + Operation::Text(text) => text_logits(model, enc, &text).map_err(|e| e.to_string()), Operation::Clicks(points) => { let clicks: Vec<_> = points.into_iter().map(|p| Click { x: p[0], y: p[1], positive: p[2] == 1.0 }).collect(); Ok(Some(model.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?.logits)) diff --git a/crates/segment/tests/it/main.rs b/crates/segment/tests/it/main.rs new file mode 100644 index 000000000..4dcb68591 --- /dev/null +++ b/crates/segment/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod reference; +mod remote; diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/it/reference.rs similarity index 92% rename from crates/segment/tests/reference.rs rename to crates/segment/tests/it/reference.rs index 315d36cd1..a2a110053 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/it/reference.rs @@ -93,5 +93,12 @@ fn matches_reference() { let want = vec(&r[&format!("det{i}_masks")].i((0, q)).unwrap()); assert!(rel_err("best mask", &ours, &want) < 2e-2); assert!(mask_disagreement(&ours, &want) < 2e-3); + // the semantic mask (all instances as one; Subject's people take their share of it); reference + // files made before it was written lack it + if let Some(want) = r.get(&format!("det{i}_semantic")) { + let want = vec(want); + assert!(rel_err("semantic mask", &out.semantic, &want) < 2e-2); + assert!(mask_disagreement(&out.semantic, &want) < 2e-3); + } } } diff --git a/crates/segment/tests/remote.rs b/crates/segment/tests/it/remote.rs similarity index 100% rename from crates/segment/tests/remote.rs rename to crates/segment/tests/it/remote.rs diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 7a2322245..cfdf7e65a 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -16,7 +16,7 @@ //! //! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file //! (`cargo xtask ci` sets it), appends one JSON line there: -//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `{"need":"gpu","reason":"no GPU adapter","test":"equivalence::camera_tone","binary":"it","location":"crates/gpu/tests/it/equivalence.rs:22"}`. //! `cargo xtask ci` lists the skips by need and reason in its summary and in //! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records //! the part it could not run the same way. @@ -142,13 +142,13 @@ mod tests { #[test] fn an_entry_is_one_json_line_with_its_text_escaped() { - let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "it", "crates\\raw\\tests\\it\\corpus.rs", 28); assert!(line.ends_with('\n')); assert_eq!(line.matches('\n').count(), 1, "{line}"); assert_eq!( line, "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ - \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + \"binary\":\"it\",\"location\":\"crates/raw/tests/it/corpus.rs:28\"}\n" ); } diff --git a/crates/tiff/README.md b/crates/tiff/README.md index e6a771288..2436b0d31 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -29,9 +29,12 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was -read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't -pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; at most 65,536 directory entry slots +(`ParseOptions::max_entries_total`) are accepted across the main IFD chain and all child IFDs, even when tables +overlap at distinct offsets, and an IFD past that bound ends the chain or is skipped like any other bad IFD after +the first; a next-IFD pointer is followed only when every declared entry was read, with the counts compared as +64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't pass for a whole directory on a +32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index 1d0b74d0d..2072c4936 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -11,8 +11,9 @@ //! either byte order, classic or BigTIFF. //! //! The reader never panics on malformed input: every offset is bounds-checked, IFD loops are -//! detected, nesting depth / IFD count are limited and the total amount of decoded value bytes is -//! capped relative to the input size (so a hostile file cannot make us allocate unboundedly). +//! detected, nesting depth / IFD count are limited, at most 65,536 directory entry slots are read +//! across the main chain and child IFDs, and decoded value bytes are capped relative to the input +//! size (so overlapping tables cannot multiply entry-vector allocations without bound). //! //! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF //! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32). diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index 5f38dfad2..bd728ca9c 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -12,6 +12,15 @@ pub struct ParseOptions { pub max_depth: usize, /// Maximum number of entries read from one IFD. pub max_entries: usize, + /// Maximum total number of entries read across all IFDs (chain + children). + /// + /// Hostile files can reuse the same bytes for up to `max_ifds` tables of `max_entries` + /// entries each (overlapping IFD tables via SubIFD pointers shifted by one entry), which can + /// allocate millions of `Entry`s from a small input (~100 KB → ~400 MB). This hard cap bounds + /// that amplification regardless of entry size. 65 536 is generous for real files — a 50 MB + /// scanned TIFF with thousands of entries across chain + children stays well under it — yet + /// stops a small file from allocating hundreds of megabytes of `Entry`s. + pub max_entries_total: usize, /// Maximum total bytes of decoded values; `None` = `8 × input length + 1 MiB`. pub value_budget: Option, /// Follow `SubIFDs` / Exif / GPS / Interop pointers. @@ -22,7 +31,15 @@ pub struct ParseOptions { impl Default for ParseOptions { fn default() -> Self { - Self { max_ifds: 1024, max_depth: 10, max_entries: 8192, value_budget: None, follow_children: true, accept_raw_magic: true } + Self { + max_ifds: 1024, + max_depth: 10, + max_entries: 8192, + max_entries_total: 65536, + value_budget: None, + follow_children: true, + accept_raw_magic: true, + } } } @@ -195,12 +212,13 @@ struct Ctx<'a> { opts: &'a ParseOptions, visited: HashSet, budget: u64, + entries_total: usize, } impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { let budget = opts.budget_for(data.len()); - Self { data, order, big, opts, visited: HashSet::new(), budget } + Self { data, order, big, opts, visited: HashSet::new(), budget, entries_total: 0 } } fn read_off(&self, pos: usize) -> Option { @@ -219,6 +237,12 @@ impl<'a> Ctx<'a> { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + // Bound total entries across the whole parse so overlapping IFD tables (SubIFD pointers + // shifted by one entry) cannot allocate millions of `Entry`s from a small input. + self.entries_total = self.entries_total.saturating_add(dir.read); + if self.entries_total > self.opts.max_entries_total { + return Err(TiffError::Limit("entry count")); + } let mut entries = Vec::with_capacity(dir.read); for p in dir.positions() { if let Some(e) = self.entry(p, base) { @@ -405,4 +429,89 @@ mod tests { assert_eq!(tiff.ifds.len(), 1); assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); } + #[test] + fn overlapping_ifd_tables_hit_entry_cap_instead_of_amplifying() { + // classic TIFF; IFDs at 8, 20, 32, ... each declaring 8192 entries whose tables overlap + // (next table starts one entry after the previous table's start). Without the cap, a single + // `Tiff::parse` would run `Vec::with_capacity(8192)` per IFD inside one `Ctx`, amplifying a + // small file into millions of `Entry`s; the per-`Ctx` `max_entries_total` cap rejects the + // 9th IFD before any allocation. + let mut data = vec![0u8; 200_000]; + data[0..2].copy_from_slice(b"II"); + data[2..4].copy_from_slice(&42u16.to_le_bytes()); + data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8 + + for k in 0..9u64 { + let ifd_off = (8 + 12 * k) as usize; + data[ifd_off..ifd_off + 2].copy_from_slice(&8192u16.to_le_bytes()); // count + let table = ifd_off + 2; + let ptr = table + 8192 * 12; // next-IFD pointer lives right after the entries + let next = if k + 1 < 9 { (8 + 12 * (k + 1)) as u32 } else { 0 }; + data[ptr..ptr + 4].copy_from_slice(&next.to_le_bytes()); + } + + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 8, "8 IFDs parsed, 9th rejected by max_entries_total before allocation"); + } + + #[test] + fn a_legitimate_multi_ifd_file_still_parses_under_the_entry_cap() { + // classic TIFF: IFD0 @8 (3 entries: value + Exif + GPS child pointers) -> IFD1 @58 chain, + // plus Exif child @60 and GPS child @90. Total entries = 6, far under max_entries_total. + let mut data = vec![0u8; 128]; + data[0..2].copy_from_slice(b"II"); + data[2..4].copy_from_slice(&42u16.to_le_bytes()); + data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8 + + // IFD0 @8: count 3, table @10, next-IFD @46 -> IFD1 @58 + data[8..10].copy_from_slice(&3u16.to_le_bytes()); + // entry0 @10: tag 0x0100, SHORT, count 1, value 8 (inline @18..22) + data[10..12].copy_from_slice(&0x0100u16.to_le_bytes()); + data[12..14].copy_from_slice(&3u16.to_le_bytes()); + data[14..18].copy_from_slice(&1u32.to_le_bytes()); + data[18..22].copy_from_slice(&8u32.to_le_bytes()); + // entry1 @22: tag 0x8769 ExifIFD, LONG, count 1, value 60 (inline @30..34) + data[22..24].copy_from_slice(&0x8769u16.to_le_bytes()); + data[24..26].copy_from_slice(&4u16.to_le_bytes()); + data[26..30].copy_from_slice(&1u32.to_le_bytes()); + data[30..34].copy_from_slice(&60u32.to_le_bytes()); + // entry2 @34: tag 0x8825 GPSIFD, LONG, count 1, value 90 (inline @42..46) + data[34..36].copy_from_slice(&0x8825u16.to_le_bytes()); + data[36..38].copy_from_slice(&4u16.to_le_bytes()); + data[38..42].copy_from_slice(&1u32.to_le_bytes()); + data[42..46].copy_from_slice(&90u32.to_le_bytes()); + data[46..50].copy_from_slice(&58u32.to_le_bytes()); // next IFD + + // IFD1 @58: count 1, table @60, next 0 + data[58..60].copy_from_slice(&1u16.to_le_bytes()); + data[60..62].copy_from_slice(&0x0100u16.to_le_bytes()); + data[62..64].copy_from_slice(&3u16.to_le_bytes()); + data[64..68].copy_from_slice(&1u32.to_le_bytes()); + data[68..72].copy_from_slice(&8u32.to_le_bytes()); + data[72..76].copy_from_slice(&0u32.to_le_bytes()); + + // Exif child IFD @60: count 1, entry tag 0x9000 value 1, next 0 + data[60..62].copy_from_slice(&1u16.to_le_bytes()); + data[62..64].copy_from_slice(&0x9000u16.to_le_bytes()); + data[64..66].copy_from_slice(&3u16.to_le_bytes()); + data[66..70].copy_from_slice(&1u32.to_le_bytes()); + data[70..74].copy_from_slice(&1u32.to_le_bytes()); + data[74..78].copy_from_slice(&0u32.to_le_bytes()); + + // GPS child IFD @90: count 1, entry tag 0 value 1, next 0 + data[90..92].copy_from_slice(&1u16.to_le_bytes()); + data[92..94].copy_from_slice(&0u16.to_le_bytes()); + data[94..96].copy_from_slice(&3u16.to_le_bytes()); + data[96..100].copy_from_slice(&1u32.to_le_bytes()); + data[100..104].copy_from_slice(&1u32.to_le_bytes()); + data[104..108].copy_from_slice(&0u32.to_le_bytes()); + + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 2, "chain IFDs parse fully"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + let exif = tiff.ifds[0].exif.as_ref().expect("Exif child present"); + assert_eq!(exif.u64(0x9000), Some(1)); + let gps = tiff.ifds[0].gps.as_ref().expect("GPS child present"); + assert_eq!(gps.u64(0x0000), Some(1)); + } } diff --git a/crates/tiff/tests/it/main.rs b/crates/tiff/tests/it/main.rs new file mode 100644 index 000000000..e415ed537 --- /dev/null +++ b/crates/tiff/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod roundtrip; diff --git a/crates/tiff/tests/roundtrip.rs b/crates/tiff/tests/it/roundtrip.rs similarity index 100% rename from crates/tiff/tests/roundtrip.rs rename to crates/tiff/tests/it/roundtrip.rs diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 0d0f9a3a4..9d8300e8e 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -7,9 +7,9 @@ //! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means //! adding its file (and its `language_table!` entry); this script picks the file up by name. //! -//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog -//! does not compile, so a message missing from one language would otherwise go unnoticed until it -//! shows English. +//! A message a language lacks (or translates as an empty string) shows the English source: that +//! language has no arm for it, and a message no catalog has formats the English source through the +//! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language. //! //! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`, //! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from @@ -61,25 +61,14 @@ fn generate() -> Result> { let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; println!("cargo:rerun-if-changed=locales/{name}"); let variant = variant_for(code)?; - let messages: BTreeMap = + let mut messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + messages.retain(|_, translated| !translated.is_empty()); catalogs.push((code.to_string(), variant, messages)); } catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - // Every catalog translates the same messages. let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); - let mut problems = Vec::new(); - for (code, _, messages) in &catalogs { - for key in &all { - if !messages.contains_key(*key) { - problems.push(format!("locales/{code}-formats.json is missing {key:?}")); - } - } - } - if !problems.is_empty() { - return Err(problems.join("\n").into()); - } let mut source = String::from(concat!( "/// Translate a format string, checked by `format!` in every language.\n", @@ -98,6 +87,9 @@ fn generate() -> Result> { source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); } source.push_str(concat!( + " ($english:literal $(, $($args:tt)*)?) => {\n", + " format!($english $(, $($args)*)?)\n", + " };\n", "}\n", "\n", "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 8c3c97427..8a50d38f1 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -248,52 +248,54 @@ "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?", "• {assumption}": "• {assumption}", "… and {more} more": "… a dalších {more}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "new, inside {parent}": "new, inside {parent}", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - " in {g} group{}": " in {g} group{}", - " · kept, not applied: {}": " · kept, not applied: {}", - " — kept but not applied: {}": " — kept but not applied: {}", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "Effect: {name}": "Effect: {name}", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "{n} preset{}": "{n} preset{}", - "{count} more warnings.": "{count} more warnings.", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "Applied {name}": "Applied {name}", - "Delete “{name}”?": "Delete “{name}”?", - "Updated {name}": "Updated {name}", - "Exported to {path}": "Exported to {path}", - "Saved template {name}": "Saved template {name}", - "Imported {name}": "Imported {name}", - "Skipped {problem}": "Skipped {problem}", - "…and {more} more": "…and {more} more", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "{n} pages": "{n} pages", - "{n} JPEG files": "{n} JPEG files", - "{s} s of AV1 video": "{s} s of AV1 video", - "Slideshow exported: {what}": "Slideshow exported: {what}", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nepodařilo se zapsat přidružené soubory XMP u fotografií v počtu {n} (není jejich složka offline?). LightCraft to bude zkoušet dál.", + "Create the keyword “{name}”": "Vytvořit klíčové slovo „{name}“", + "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í.", + " in {g} group{}": " ve skupinách: {g}{:.0}", + " · kept, not applied: {}": " · zachováno, nepoužito: {}", + " — kept but not applied: {}": " — zachováno, ale nepoužito: {}", + "Apply to “{mask}” on Selected Photos": "Použít na masku „{mask}“ u vybraných fotografií", + "Delete the effect preset “{name}”?": "Odstranit přednastavení efektu „{name}“?", + "Delete these {n} effect presets?": "Odstranit tato přednastavení efektů (počet: {n})?", + "Delete {n} effect preset{}? This can't be undone.": "Odstranit přednastavení efektů v počtu {n}{:.0}? Tuto akci nelze vrátit zpět.", + "Effect: {name}": "Efekt: {name}", + "Exported {} effect preset{}": "Exportováno přednastavení efektů: {0}{1:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Importováno efektů masek: {e}{:.0} (Maskování → Efekt)", + "Update “{name}” with Current Settings": "Aktualizovat „{name}“ podle aktuálního nastavení", + "{n} preset{}": "Přednastavení: {n}{:.0}", + "{count} more warnings.": "Další varování: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Neimportované obrázky katalogu: {skipped}. Jejich záznamy v katalogu jsou neúplné.", + "{photos} catalog records processed; {collections} collections.": "Zpracováno záznamů katalogu: {photos}; kolekcí: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Chybějící originály: {missing}. Jejich záznamy jsou v katalogu dostupné; použijte Najít chybějící fotografie a znovu je propojte.", + "Applied {name}": "Použito: {name}", + "Delete “{name}”?": "Odstranit „{name}“?", + "Updated {name}": "Aktualizováno: {name}", + "Exported to {path}": "Exportováno do {path}", + "Saved template {name}": "Uložena šablona {name}", + "Imported {name}": "Importováno: {name}", + "Skipped {problem}": "Přeskočeno: {problem}", + "…and {more} more": "…a další: {more}", + "Template preview: {name}": "Náhled šablony: {name}", + "{n} photos in view · {slides} slides · {time}": "Fotografie v zobrazení: {n} · snímky: {slides} · {time}", + "{n} pages": "Stránky: {n}", + "{n} JPEG files": "Soubory JPEG: {n}", + "{s} s of AV1 video": "Video AV1: {s} s", + "Slideshow exported: {what}": "Prezentace byla exportována: {what}", + " · {n} skipped (could not be rendered)": " · přeskočeno: {n} (nepodařilo se vykreslit)", + "{n} raws can now be developed from their raw data": "Soubory RAW, které lze nyní vyvolat z dat RAW: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se neočekávaně ukončil: {e}\n\nPodrobnosti najdete v souboru protokolu: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft se neočekávaně ukončil: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Přejmenuje obličeje v celé knihovně (počet: {n}{:.0}). Soubory se nezmění.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "Osoba „{to}“ již existuje. Sloučením vznikne jedna osoba; příkaz Zpět je znovu oddělí.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Přidáno sloučení {what} v rozlišení {mp}: plné rozlišení {full} vyžaduje více paměti, než má tento počítač", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Přidáno sloučení {what} v rozlišení {mp}, což je nejvyšší možné rozlišení panoramatu (plné rozlišení je {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Barevný prostor: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "Vybrané fotografie: {n} · stránky: {pages}", "Already in “{}”": "Již v „{}“", "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í." } diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 1e84313ae..e3a98569f 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -11,6 +11,7 @@ ", rejected": ", odmítnuto", "0: nothing is rejected.": "0: nic se neodmítá.", "1 day": "1 den", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuální úpravy · přizpůsobit bez ořezu", "2026-04 or 2026-04-12": "2026-04 nebo 2026-04-12", "= exactly": "= přesně", "A blank template keeps the original names.": "Prázdná šablona zachová původní názvy.", @@ -19,6 +20,7 @@ "A folder is being synchronized": "Probíhá synchronizace složky", "A keyword set needs a name": "Sada klíčových slov musí mít název", "A one-time download of about": "Jednorázové stažení přibližně", + "A pen's pressure sets the size and flow of the brush": "Přítlak pera určuje velikost a tok štětce", "AI Denoise": "Redukce šumu pomocí AI", "AI Denoise Model": "Model pro redukci šumu pomocí AI", "AI Denoise is enabled on the requesting photo": "Redukce šumu pomocí AI je u požadované fotografie zapnutá", @@ -53,6 +55,9 @@ "Add to Favorites": "Přidat do oblíbených", "Add to Local": "Přidat do místních souborů", "Add to My Photos": "Přidat do mých fotografií", + "Can't be moved here": "Sem nelze přesunout", + "This row covers more than one disk: drop on a folder inside it": "Tento řádek zahrnuje více disků: přetáhněte položky na některou ze složek v něm", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Místní fotografie zatím nejsou v knihovně: nejprve použijte Přidat do mých fotografií a pak je přesuňte", "Add to My Photos First": "Nejprve přidat do mých fotografií", "Add to Selected Photos": "Přidat k vybraným fotografiím", "Add to Target Album": "Přidat do cílového alba", @@ -63,9 +68,112 @@ "Adding": "Přidávání", "Adding Lightroom photos…": "Přidávání fotografií z Lightroom…", "Adding to Apple Photos": "Přidávání do Apple Photos", + "Dimensions": "Rozměry", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nebyl nalezen grafický procesor: LightCraft vykresluje na CPU, což je mnohem pomalejší. Podrobnosti najdete v nabídce Nápověda → Informace o systému.", "On NixOS, start it from `nix develop`.": "V NixOS ho spusťte z `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Soubory se stejným obsahem jako jiný soubor v tomto importu: importuje se jen první z nich.", + "Lightroom Catalog Import": "Import katalogu Lightroom", + "Destination library": "Cílová knihovna", + "Show in Library": "Zobrazit v knihovně", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Katalogy Lightroom se importují do otevřené knihovny; samostatná složka katalogu se nevytváří.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Tyto fotografie jsou pouze v paměti. Chcete-li je uložit, otevřete knihovnu na disku a importujte katalog znovu.", + "Playback": "Přehrávání", + "Layout": "Rozložení", + "Link all margins": "Propojit všechny okraje", + "Margins": "Okraje", + "Left": "Vlevo", + "Right": "Vpravo", + "Top": "Nahoře", + "Bottom": "Dole", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Okraje se zadávají jako podíl kratší strany snímku, takže snímky vypadají na každé obrazovce stejně.", + "Show guides": "Zobrazit vodítka", + "Aspect preview": "Poměr stran náhledu", + "Zoom to fill frame": "Přiblížit pro vyplnění rámečku", + "Stroke border": "Obrysový rámeček", + "Colour": "Barva", + "Cast shadow": "Vržený stín", + "Offset": "Posun", + "Backdrop": "Pozadí", + "Background colour": "Barva pozadí", + "Colour wash": "Barevný přechod", + "Background image": "Obrázek na pozadí", + "(not in the library)": "(není v knihovně)", + "Use selected photo": "Použít vybranou fotografii", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Vyberte fotografii a zapněte tuto volbu, aby vyplnila pozadí snímku za každou fotografií.", + "Reset layout": "Obnovit rozložení", + "Select a photo to preview its slide": "Vyberte fotografii pro náhled jejího snímku", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Vybraná fotografie jako snímek. Přehrávání vyplní obrazovku; poměr stran náhledu mění pouze tento náhled.", + "Overlays": "Překrytí", + "Identity plate": "Označení autora", + "Your name or studio": "Vaše jméno nebo studio", + "Anchored to": "Ukotveno k", + "Slide": "Snímek", + "Render behind image": "Vykreslit za obrázkem", + "Rating stars": "Hvězdičky hodnocení", + "Text overlays": "Textová překrytí", + "Add text overlay": "Přidat textové překrytí", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Značky: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Ostatní text se zobrazí tak, jak jej zadáte.", + "Use export watermark": "Použít exportní vodoznak", + "The export watermark, drawn on the photo and sized relative to it.": "Exportní vodoznak, vykreslený na fotografii s velikostí vztaženou k ní.", + "Titles": "Titulky", + "Intro screen": "Úvodní obrazovka", + "Ending screen": "Závěrečná obrazovka", + "Shows": "Obsah", + "Colour only": "Pouze barva", + "Override colour": "Vlastní barva", + "Own duration (off: the slide duration)": "Vlastní délka (vypnuto: délka snímku)", + "Duration": "Délka", + "Title text": "Text titulku", + "Intro": "Úvod", + "Ending": "Závěr", + "End": "Závěr", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Úvod se přehraje jednou před první fotografií; závěr po poslední fotografii, pokud je vypnuto opakování. Označení autora se přebírá z nastavení Překrytí. Titulky se prolínají jako snímky a náhodné pořadí je nikdy nepřesouvá.", + "This screen is off: turn it on under Titles to play it.": "Tato obrazovka je vypnutá: chcete-li ji přehrát, zapněte ji v části Titulky.", + "The intro screen, before the first photo.": "Úvodní obrazovka, před první fotografií.", + "The ending screen, after the last photo.": "Závěrečná obrazovka, po poslední fotografii.", + "Templates": "Šablony", + "Built-in": "Vestavěné", + "Your templates": "Vaše šablony", + "None yet: save the current settings as a template below.": "Zatím žádné: níže uložte aktuální nastavení jako šablonu.", + "Before the last template": "Před použitím poslední šablony", + "Previous settings": "Předchozí nastavení", + "New template name": "Název nové šablony", + "Save as Template": "Uložit jako šablonu", + "Import…": "Importovat…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Vlastní šablony vyžadují složku nastavení, kterou toto sestavení nemá; vestavěné šablony fungují.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Kliknutím šablonu použijete; najetím ukazatele zobrazíte její náhled na vybrané fotografii. Vlastní šablonu můžete kliknutím pravým tlačítkem aktualizovat, přejmenovat, exportovat nebo odstranit.", + "Import Slideshow Templates": "Importovat šablony prezentace", + "Export Slideshow Template": "Exportovat šablonu prezentace", + "Slideshow Templates": "Šablony prezentace", + "Plain Black": "Prostá černá", + "Gallery Wall": "Stěna galerie", + "Edge to Edge": "Od kraje ke kraji", + "Cinema Wide": "Širokoúhlé kino", + "Shooting Data": "Údaje o pořízení", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Každá fotografie co největší na černém pozadí, s krátkým prolnutím. Nic se přes ni nekreslí.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Světle šedá stěna s bílou paspartou a jemným stínem; popisek a hodnocení pod fotografií.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Každá fotografie oříznutá tak, aby vyplnila celou obrazovku, s pomalým prolnutím.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Rám 16:9 s černými pruhy nahoře a dole a titulem v rohu; přechody přes černou a na konci závěrečná obrazovka.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Tmavé břidlicové pozadí; pod každou fotografií expozice, ISO a ohnisková vzdálenost a dále fotoaparát a objektiv.", + "Exporting slideshow": "Exportování prezentace", + "Export Slideshow…": "Exportovat prezentaci…", + "Export Slideshow": "Exportovat prezentaci", + "Export Slideshow: folder for the JPEG files": "Exportovat prezentaci: složka pro soubory JPEG", + "Frame rate": "Snímková frekvence", + "Container": "Kontejner", + "Export PDF…": "Exportovat PDF…", + "Export JPEG…": "Exportovat JPEG…", + "Export Video…": "Exportovat video…", + "Automatically show full screen": "Automaticky zobrazit na celou obrazovku", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Jeden snímek na stránku (delší strana v pixelech = body), s délkou zobrazení snímku a prolnutím pro režim prezentace v prohlížeči PDF.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Číslované soubory JPEG (name-001.jpg…) v nové složce, kterou pojmenujete v dalším kroku.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, každý snímek se zobrazuje po nastavenou dobu včetně prolnutí. Bez zvuku: hudba se zatím neexportuje.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Snímky používají rozložení, překrytí a úvodní i závěrečnou obrazovku prezentace a řadí se podle aktuálního zobrazení.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Fotografie v aktuálním zobrazení jako snímky s výše uvedeným nastavením. Průběh se zobrazuje v seznamu úloh na pozadí.", + "Checking raw files": "Kontrola souborů RAW", + "1 raw can now be developed from its raw data": "1 soubor RAW lze nyní vyvolat z dat RAW", + "This raw is now developed from its raw data": "Tento soubor RAW se nyní vyvolává z dat RAW", + "This raw variant still can't be decoded": "Tuto variantu RAW stále nelze dekódovat", "Adding to Apple Photos…": "Přidávání do Apple Photos…", "Advanced search…": "Rozšířené hledání…", "After": "Po", @@ -324,7 +432,14 @@ "Concrete Cool": "Chladný beton", "Confirm before deleting": "Potvrdit před odstraněním", "Confirm before moving photos to Recently Deleted": "Potvrdit před přesunem fotografií do nedávno odstraněných", + "Move Photos": "Přesunout fotografie", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Jejich soubory a přidružené soubory XMP se přesunou na disku. Úpravy → Zpět je přesune zpět.", + "Don't ask again": "Znovu se neptat", + "Another library was opened since the photos were dropped: nothing was moved": "Od přetažení fotografií byla otevřena jiná knihovna: nic nebylo přesunuto", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Znovu zapnete v Nastavení → Potvrdit před přesunem fotografií do jiné složky na disku", + "Confirm before moving photos to another folder on disk": "Potvrdit před přesunem fotografií do jiné složky na disku", "Constrain Crop": "Omezit oříznutí", + "Contact Sheet PDF": "Přehled snímků PDF", "Content-Aware": "Podle obsahu", "Continue": "Pokračovat", "Continue Without Saving": "Pokračovat bez uložení", @@ -603,6 +718,7 @@ "File name": "Název výstupního souboru", "File types:": "Typy souborů:", "Filename": "Jméno souboru", + "Filename captions": "Popisky s názvy souborů", "Fill": "Vyplnit", "Fill Edges": "Vyplnit okraje", "Fill Location": "Doplnit místo", @@ -671,6 +787,7 @@ "Friday": "Pátek", "Full": "Plné", "Full Screen Preview": "Náhled na celé obrazovce", + "Hold View Until Refreshed": "Ponechat zobrazení do obnovení", "Full screen mode": "Režim celé obrazovky", "Full size": "Plná velikost", "GPS Track Log": "Záznam trasy GPS", @@ -728,6 +845,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "Panoramata HDR", "HDR with Last Settings": "HDR s posledním nastavením", + "HDR for Each Bracket Set": "Sloučit každou expoziční řadu do HDR", "HDR…": "HDR…", "Has Edits": "Obsahuje úpravy", "Has GPS": "Obsahuje GPS", @@ -800,6 +918,7 @@ "In memory — nothing is saved": "V paměti — nic není uloženo", "In use": "Používané", "In-memory session: these settings last until LightCraft quits.": "Relace v paměti: tato nastavení platí do ukončení LightCraft.", + "Include selected photos": "Zahrnout vybrané fotografie", "Include on Export": "Zahrnout do exportu", "Include subfolders": "Zahrnout podsložky", "Increase Brush Feather": "Zvětšit prolnutí štětce", @@ -1096,12 +1215,26 @@ "No edits yet.": "Zatím žádné úpravy.", "No exposure information": "Žádné informace o expozici", "No filter presets yet": "Zatím žádná přednastavení filtrů", + "No lens data embedded in this file.": "V tomto souboru nejsou vložena data objektivu.", "No folders to browse": "Žádné složky k procházení", "No imported folders yet": "Zatím žádné importované složky", "No keywords are exported": "Neexportují se žádná klíčová slova", "No keywords match": "Žádné klíčové slovo neodpovídá", "No keywords yet": "Zatím žádná klíčová slova", + "Show fewer": "Zobrazit méně", + "Whole names": "Úplné názvy", + "Grouped by parent": "Seskupené podle nadřazeného slova", + "Keywords of the selection": "Klíčová slova výběru", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Seskupené: klíčová slova se stejným nadřazeným slovem sdílejí řádek s jeho názvem; dlouhé seznamy a návrhy se zkracují.", + "Show every keyword of the selected photos": "Zobrazit všechna klíčová slova vybraných fotografií", + "new keyword": "nové klíčové slovo", + "Return creates this keyword and gives it to the selected photos": "Klávesou Return vytvoříte toto klíčové slovo a přiřadíte je vybraným fotografiím", + "Separate several keywords with commas. Return gives them to the selected photos.": "Více klíčových slov oddělte čárkami. Klávesou Return je přiřadíte vybraným fotografiím.", "No lens data embedded in this file (DNG lens opcodes only).": "V tomto souboru nejsou vložena data objektivu (pouze operační kódy objektivu DNG).", + "Export to": "Exportovat do", + "Specific folder": "Konkrétní složka", + "Same folder as original photo": "Stejná složka jako původní fotografie", + "Tidying the thumbnail cache": "Úklid mezipaměti miniatur", "No masks yet. Choose a mask type above.": "Zatím žádné masky. Vyberte typ masky nahoře.", "No matching commands": "Žádné odpovídající příkazy", "No matching photos": "Žádné odpovídající fotografie", @@ -1113,6 +1246,11 @@ "No photo has it; the keywords below it go too.": "Není přiřazeno žádné fotografii; odstraní se i podřízená klíčová slova.", "No photo selected": "Není vybrána fotografie", "No photos": "Žádné fotografie", + "Your library is empty": "Vaše knihovna je prázdná", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Zde se zobrazí importované fotografie. Nic se nekopíruje ani nepřesouvá, pokud to sami nezvolíte.", + "or drop photos and folders into this window": "nebo do tohoto okna přetáhněte fotografie a složky", + "Import photos to get started": "Začněte importem fotografií", + "or drop photos into this window": "nebo do tohoto okna přetáhněte fotografie", "No photos found": "Nebyly nalezeny fotografie", "No photos in this folder": "V této složce nejsou fotografie", "No photos match the active filters": "Žádné fotografie neodpovídají aktivním filtrům", @@ -1203,6 +1341,7 @@ "Paste from Previous": "Vložit z předchozí fotografie", "Paste pages": "Vložit stránky", "Pastel Haze": "Pastelový opar", + "Pen Pressure": "Přítlak pera", "Pending photo action cancelled": "Čekající akce s fotografií byla zrušena", "Pending photo action cancelled because its library or photo changed": "Čekající akce byla zrušena, protože se změnila knihovna nebo fotografie", "People": "Lidé", @@ -1223,6 +1362,7 @@ "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Fotografie → Upravit v externím editoru (⇧⌘E) vykreslí 16bitovou kopii TIFF, přidá ji do sady s originálem a otevře v této aplikaci (název aplikace v macOS, jinde cesta k programu).", "PhotoCraft": "PhotoCraft", "Photos": "Fotografie", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotografie upravené v HDR: JPEG s mapou zisku HDR (správné zobrazení všude, jasnější světla na displejích HDR), AVIF jako 10bitový, PNG a JPEG XL jako 16bitové Rec. 2020 PQ; TIFF vyžaduje 32bitová čísla s plovoucí řádovou čárkou", "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotografie se zobrazují přes ICC profil monitoru, aby byly barvy správné na kalibrovaných displejích i displejích se širokým gamutem. Detail využívá celý gamut displeje. Histogram, mezipaměti náhledů ani export se nemění.", "Photos per bracket (0 = auto)": "Fotografií na expoziční řadu (0 = automaticky)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotografie prohlížené v místních souborech, které nebyly přidány ani změněny, se odeberou z katalogu, pokud se jejich složka po tuto dobu neprocházela (kontroluje se při otevření knihovny). Soubory zůstanou na disku; procházením složky se znovu zobrazí.", @@ -1368,6 +1508,7 @@ "Refining the mask's detail…": "Zpřesňování detailů masky…", "Refresh Source": "Obnovit zdroj", "Refresh Source (/)": "Obnovit zdroj (/)", + "Refresh View": "Obnovit zobrazení", "Reject": "Odmítnout", "Reject below focus": "Odmítnout pod limitem zaostření", "Rejected": "Odmítnuto", @@ -1440,7 +1581,38 @@ "Reset Sidebar Sections": "Obnovit sekce postranního panelu", "Reset Tone Curve": "Obnovit tónovou křivku", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Obnoví všechny křivky: bodové (všechny kanály) i parametrickou (dvojklik na kanál obnoví pouze tento kanál)", + "Reset all edits": "Obnovit všechny úpravy", "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", + "Wide": "Široká oblast", + "Center": "Střed", + "Flexible Spot": "Pohyblivý bod", + "Zone": "Zóna", + "Expanded Flexible Spot": "Rozšířený pohyblivý bod", + "Custom AF Area": "Vlastní oblast AF", + "Face tracking": "Sledování obličeje", + "Lock On AF": "AF se sledováním objektu", + "Multi": "Vícebodové", + "Spot": "Bod", + "Tracking": "Sledování", + "Face Tracking": "Sledování obličeje", + "Animal Eye Tracking": "Sledování zvířecích očí", + "Human Eye Tracking": "Sledování lidských očí", + "Read Focus Points": "Načíst zaostřovací body", + "Reading focus points": "Načítání zaostřovacích bodů", + "Run face models on the CPU only": "Spouštět modely rozpoznávání obličejů pouze na CPU", + "Detection": "Detekce", + "Recognition": "Rozpoznávání", + "Not loaded yet": "Zatím nenačteno", + "Checking the GPU…": "Kontrola GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Model rozpoznávání obličejů používá GPU pouze tehdy, když kontrola prokáže, že na něm dosahuje stejných výsledků rychleji.", + "Checking the GPU for faces": "Kontrola GPU pro rozpoznávání obličejů", "Reshuffle": "Znovu promíchat", "Resize Face Box": "Změnit velikost rámečku obličeje", "Resize panel window": "Změnit velikost okna panelu", @@ -1606,11 +1778,13 @@ "Show in Explorer": "Zobrazit v Průzkumníku", "Show in File Manager": "Zobrazit ve správci souborů", "Show in Finder": "Zobrazit ve Finderu", + "Show in Folders": "Zobrazit ve složkách", "Show less": "Zobrazit méně", "Show on Map": "Zobrazit na mapě", "Show overlay (O)": "Zobrazit překrytí (O)", "Show ratings, flags and edit badges": "Zobrazit hodnocení, příznaky a odznaky úprav", "Show the Navigator while zoomed in": "Zobrazit navigátor při přiblížení", + "Show the import review when a camera or card is connected": "Zobrazit kontrolu importu při připojení fotoaparátu nebo karty", "Show the template tags; click one to insert it": "Zobrazit značky šablony; kliknutím jednu vložíte", "Show/Hide Mask": "Zobrazit/Skrýt masku", "Show/hide My Photos panel": "Zobrazit/Skrýt panel mých fotografií", @@ -1644,6 +1818,7 @@ "Soft Pastel": "Jemné pastelové", "Soft Proofing": "Měkký nátisk", "Soft Skin": "Jemná pleť", + "Solo Mode": "Režim jedné sekce", "Some rules need fixing (Edit Smart Album…):": "Některá pravidla je třeba opravit (Upravit inteligentní album…):", "Sort": "Seřadit", "Sort Albums A–Z": "Seřadit alba A–Z", @@ -1873,12 +2048,13 @@ "Width": "Šířka", "Width (% of photo)": "Šířka (% fotografie)", "Width (px)": "Šířka (px)", - "Width × Height": "Šířka × výška", + "Width & Height": "Šířka & výška", "Will Export": "Bude exportováno", "Will be replaced": "Bude nahrazeno", "Will be restored": "Bude obnoveno", "Window": "Okno", "Winter Blue": "Zimní modř", + "Wrote the XMP sidecars that were waiting": "Čekající přidružené soubory XMP byly zapsány", "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funguje se soubory RAW z fotoaparátů se standardním snímačem (Bayer). Ostatní soubory si zachovají běžnou redukci šumu.", "XMP Sidecar Preferences": "Předvolby přidružených souborů XMP", "XMP sidecars": "Přidružené soubory XMP", @@ -2052,6 +2228,8 @@ "SDR Shadows": "Stíny SDR", "SDR Whites": "Bílé SDR", "HDR TIFF needs 32-bit float.": "HDR TIFF vyžaduje 32bitové hodnoty s plovoucí řádovou čárkou.", + "HDR output": "Výstup HDR", + "Letter": "Letter", "Color space: Rec. 2020 HLG (HDR AVIF)": "Barevný prostor: Rec. 2020 HLG (HDR AVIF)", "Color space: Rec. 2020 PQ (HDR AVIF)": "Barevný prostor: Rec. 2020 PQ (HDR AVIF)", "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Barevný prostor: Rec. 2020 PQ, 16 bitů (HDR PNG)", @@ -2063,5 +2241,158 @@ "Open Settings": "Otevřít nastavení", "Select none": "Zrušit celý výběr", "Select all": "Vybrat vše", - "Visualize HDR": "Zobrazit rozsah HDR" + "Visualize HDR": "Zobrazit rozsah HDR", + "Add a Face Recognition Model": "Přidat model rozpoznávání obličejů", + "Choose Monitor Profile": "Vybrat profil monitoru", + "Display profile not used": "Profil displeje nebyl použit", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Tažením nastavíte odstín a sytost. Podržením Shift zachováte odstín, Cmd/Ctrl zachováte sytost, Alt/Option umožní jemné doladění. Dvojitým kliknutím obnovíte výchozí hodnoty.", + "Edit In": "Upravit v", + "Face models (ONNX)": "Modely rozpoznávání obličejů (ONNX)", + "ICC Profiles": "Profily ICC", + "Next History Step": "Další krok historie", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) používá kompresi intoPIX TicoRAW. LightCraft pro tento kodek v současnosti nemá licenci, takže je k dispozici pouze vložený náhled JPEG z fotoaparátu, nikoli data RAW ze snímače. Chcete-li upravovat data RAW, fotografujte do bezeztrátově komprimovaného NEF nebo převeďte stávající soubory HE do DNG pomocí licencovaného převodníku.", + "Open Original in Other Editor (RAW, no copy)": "Otevřít originál v jiném editoru (RAW, bez kopie)", + "Photo Above": "Fotografie nad aktuální", + "Photo Below": "Fotografie pod aktuální", + "Previous History Step": "Předchozí krok historie", + "Select Only Active Photo": "Vybrat pouze aktivní fotografii", + "Show this wheel's effect": "Zobrazit účinek tohoto barevného kruhu", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Cílená úprava je zapnutá: táhněte po fotografii nahoru nebo dolů · Esc ukončí úpravu", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Chcete-li tomuto editoru předat přímo původní soubor (RAW bez kopie, například pro DxO PhotoLab), klikněte pravým tlačítkem na fotografii → Upravit v → Otevřít originál v jiném editoru (RAW, bez kopie).", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Snímek se zobrazuje po nastavenou dobu včetně úvodního prolnutí. Prolnutí o délce 0 znamená střih.", + "Add Print Cell": "Přidat tiskovou buňku", + "Add a cell": "Přidat buňku", + "Also the masks of the same name on the other selected photos": "Také masky se stejným názvem na ostatních vybraných fotografiích", + "Apply Mask Effect": "Použít efekt masky", + "Apply Print Template": "Použít tiskovou šablonu", + "Apply Slideshow Template": "Použít šablonu prezentace", + "Apply to All Masks": "Použít na všechny masky", + "Arrange Print Cell": "Změnit pořadí tiskové buňky", + "Auto Layout": "Automaticky rozmístit", + "Choose Layout Image…": "Vybrat obrázek rozložení…", + "Choose a group": "Vyberte skupinu", + "Choose at least one setting to include": "Vyberte alespoň jedno nastavení, které chcete zahrnout", + "Clear Print Layout": "Vyprázdnit tiskové rozložení", + "Click a cell to move, resize or arrange it": "Kliknutím vyberte buňku, kterou chcete přesunout, změnit její velikost nebo pořadí", + "Color wheel": "Barevný kruh", + "Combine with the existing person": "Sloučit s existující osobou", + "Copy Settings…": "Kopírovat nastavení…", + "Curve channel": "Kanál křivky", + "Cycle Flag": "Přepnout příznak", + "Decrease Flag Status": "Snížit stav příznaku", + "Delete Cell": "Odstranit buňku", + "Delete Mask Effect": "Odstranit efekt masky", + "Delete Print Cell": "Odstranit tiskovou buňku", + "Delete Print Template": "Odstranit tiskovou šablonu", + "Delete Slideshow Template": "Odstranit šablonu prezentace", + "Delete…": "Odstranit…", + "Duplicate Mask Effect": "Duplikovat efekt masky", + "Export Effect Presets": "Exportovat přednastavení efektů", + "Export Mask Effects": "Exportovat efekty masek", + "Face Models on the GPU": "Modely rozpoznávání obličejů na GPU", + "Fade": "Prolnutí", + "Fade duration": "Délka prolnutí", + "Favorite": "Oblíbené", + "Favorite Mask Effect": "Přidat efekt masky mezi oblíbené", + "Filters": "Filtry", + "Group (optional)": "Skupina (nepovinná)", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Zvýrazněné jsou posuvníky, které tato maska mění. Nezahrnuté posuvníky si při použití přednastavení ponechají své hodnoty.", + "Import Mask Effects": "Importovat efekty masek", + "Import Slideshow Template": "Importovat šablonu prezentace", + "Include Amount": "Zahrnout míru", + "Include brush size, feather and flow": "Zahrnout velikost štětce, prolnutí okrajů a tok", + "Increase Flag Status": "Zvýšit stav příznaku", + "LightCraft Effect Presets": "Přednastavení efektů LightCraft", + "Manage Presets…": "Spravovat přednastavení…", + "Managed by Printer (Adobe RGB)": "Spravováno tiskárnou (Adobe RGB)", + "Managed by Printer (sRGB)": "Spravováno tiskárnou (sRGB)", + "Mask": "Maska", + "Mask Effect Presets": "Přednastavení efektů masek", + "Masks keep their sliders. Export presets first to keep a copy.": "Masky si ponechají hodnoty svých posuvníků. Chcete-li si ponechat kopii přednastavení, nejprve je exportujte.", + "Move Mask Effect to Group": "Přesunout efekt masky do skupiny", + "Move Print Cell": "Přesunout tiskovou buňku", + "Move Print Cell to Page": "Přesunout tiskovou buňku na stránku", + "Move to Group…": "Přesunout do skupiny…", + "Name the preset": "Pojmenujte přednastavení", + "New Print Page": "Přidat tiskovou stránku", + "New group — Return": "Nová skupina — Return", + "New spot (Return adds it here)": "Nová retušovaná oblast (Return ji zde přidá)", + "Next page": "Další stránka", + "No Matte": "Bez ztmavení", + "No change": "Beze změny", + "No effect matches": "Žádný odpovídající efekt", + "No effect presets yet": "Zatím žádná přednastavení efektů", + "One photo fills each page inside the margins.": "Každou stránku uvnitř okrajů vyplní jedna fotografie.", + "Overlay color": "Barva překrytí", + "Page": "Stránka", + "Page Options": "Možnosti stránky", + "Previous page": "Předchozí stránka", + "Print Settings": "Nastavení tisku", + "Print Templates": "Tiskové šablony", + "Print to PDF": "Tisknout do PDF", + "Read Focus Points in the Background": "Načíst zaostřovací body na pozadí", + "Recheck Preview-Only Raws": "Znovu zkontrolovat soubory RAW dostupné jen jako náhled", + "Remove region": "Odebrat oblast", + "Rename Mask Effect": "Přejmenovat efekt masky", + "Rename Mask Effect Group": "Přejmenovat skupinu efektů masek", + "Rename Person…": "Přejmenovat osobu…", + "Rename Slideshow Template": "Přejmenovat šablonu prezentace", + "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).": "Další snímky se vykreslují během zobrazení aktuálního, takže prolnutí nikdy nečeká. Vypnuto: každý snímek se vykreslí, až na něj přijde řada (nižší spotřeba paměti a méně výpočtů).", + "Reset Print Settings": "Obnovit nastavení tisku", + "Reset Sliders": "Obnovit posuvníky", + "Resize Print Cell": "Změnit velikost tiskové buňky", + "Resize region": "Změnit velikost oblasti", + "Save Current Settings as New Preset…": "Uložit aktuální nastavení jako nové přednastavení…", + "Save Mask Effect": "Uložit efekt masky", + "Save Print Template": "Uložit tiskovou šablonu", + "Save Slideshow Template": "Uložit šablonu prezentace", + "Search effects": "Hledat efekty", + "Select a mask to apply or update presets.": "Vyberte masku, abyste mohli použít nebo aktualizovat přednastavení.", + "Set Effect for New Masks": "Nastavit efekt pro nové masky", + "Set Print Cell Photo": "Nastavit fotografii tiskové buňky", + "Set Print Settings": "Upravit nastavení tisku", + "Show / hide": "Zobrazit / skrýt", + "Show mask": "Zobrazit masku", + "Slide duration": "Délka snímku", + "Slideshow Export Status": "Stav exportu prezentace", + "Slideshow Layout Settings": "Nastavení rozložení prezentace", + "Slideshow Overlay Settings": "Nastavení překrytí prezentace", + "Slideshow Playback Settings": "Nastavení přehrávání prezentace", + "Slideshow Slide Preview": "Náhled snímku prezentace", + "Slideshow Title Settings": "Nastavení titulků prezentace", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "Položka Prezentace v nabídce Zobrazení přehraje fotografie v aktuálním zobrazení od vybrané fotografie. Tato nastavení se ukládají s knihovnou.", + "Template name": "Název šablony", + "The layout image can't be shown": "Obrázek rozložení nelze zobrazit", + "This page setup is not valid": "Toto nastavení stránky není platné", + "Thumbnail size": "Velikost miniatur", + "Title bar": "Záhlaví okna", + "Tool Overlay": "Překrytí nástroje", + "Ungrouped": "Bez skupiny", + "Unknown date": "Neznámé datum", + "Unnamed face": "Nepojmenovaný obličej", + "Update Mask Effect": "Aktualizovat efekt masky", + "Update Slideshow Template": "Aktualizovat šablonu prezentace", + "When pins show on the photo (Shift+H cycles)": "Kdy se na fotografii zobrazují body (Shift+H přepíná)", + "Write Waiting XMP Sidecars": "Zapsat čekající přidružené soubory XMP", + "cells": "× buňka", + "cells overlap": "buňky se překrývají", + "current edits": "aktuální úpravy", + "size": "velikost", + "source": "zdroj", + "★ Favorites": "★ Oblíbené", + "Rename Person": "Přejmenovat osobu", + "Increase Grid Thumbnail Size": "Zvětšit miniatury v mřížce", + "Decrease Grid Thumbnail Size": "Zmenšit miniatury v mřížce", + "Cycle Tool Overlay": "Přepnout překrytí nástroje", + "Show Layout Image": "Zobrazit obrázek rozložení", + "Layout Image Opacity": "Krytí obrázku rozložení", + "Layout Image Matte": "Ztmavení kolem obrázku rozložení", + "Slideshow Options…": "Možnosti prezentace…", + "Pause / Resume Slideshow": "Pozastavit / obnovit prezentaci", + "Next Slide": "Další snímek", + "Previous Slide": "Předchozí snímek", + "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ů…" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 42245407d..af29f7134 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", "· {in_source} identical copies": "· {in_source} identische Kopien", "{count} more warnings.": "{count} weitere Warnungen.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} Katalogbilder wurden nicht importiert, weil ihre Einträge im Katalog unvollständig sind.", "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.", "Applied {name}": "{name} angewendet", @@ -295,5 +296,6 @@ "Already in “{}”": "Bereits in „{}“", "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." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 000aea2d4..67a305ddf 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -431,6 +431,7 @@ "Detail (D)": "Detail (D)", "Details": "Details", "Diagonal": "Diagonal", + "Dimensions": "Abmessungen", "Discard Preview Cache": "Vorschau-Cache verwerfen", "Display P3": "Display P3", "Display name": "Anzeigename", @@ -1428,6 +1429,7 @@ "Show in Explorer": "Im Explorer anzeigen", "Show in File Manager": "Im Dateimanager anzeigen", "Show in Finder": "Im Finder anzeigen", + "Show in Folders": "In Ordnern anzeigen", "Show on Map": "Auf Karte anzeigen", "Show overlay (O)": "Überlagerung anzeigen (O)", "Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen", @@ -1644,7 +1646,7 @@ "Width": "Breite", "Width (% of photo)": "Breite (% des Fotos)", "Width (px)": "Breite (px)", - "Width × Height": "Breite × Höhe", + "Width & Height": "Breite & Höhe", "Will be replaced": "Wird ersetzt", "Will be restored": "Wird wiederhergestellt", "Window": "Fenster", @@ -2036,7 +2038,7 @@ "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ählen Sie Fotos aus, um ihnen Stichwörter zu geben.", + "Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zu geben.", "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", @@ -2046,7 +2048,7 @@ "Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)", "A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen", "Save as a new set": "Als neuen Satz speichern", - "Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen", + "Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wähle einen anderen Namen", "Import Keywords…": "Stichwörter importieren…", "Export Keywords…": "Stichwörter exportieren…", "Import Keywords": "Stichwörter importieren", @@ -2078,7 +2080,7 @@ "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", - "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Wait for the import to finish": "Warte, bis der Import abgeschlossen ist", "Appearance": "Erscheinungsbild", "Appearance Mode": "Erscheinungsmodus", "Sync with System": "Mit System synchronisieren", @@ -2201,7 +2203,7 @@ "smart album value|No": "Nein", "smart album value|Yes": "Ja", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", - "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", + "On NixOS, start it from `nix develop`.": "Unter NixOS startest du es aus `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", "Lightroom Catalog Import": "Lightroom-Katalogimport", "Destination library": "Zielbibliothek", @@ -2230,13 +2232,13 @@ "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ählen Sie ein Foto aus und schalten Sie 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 die Folie hinter jedem Foto damit zu füllen.", "Reset layout": "Layout zurücksetzen", - "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", + "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.", "Overlays": "Überlagerungen", "Identity plate": "Erkennungstafel", - "Your name or studio": "Ihr Name oder Studio", + "Your name or studio": "Dein Name oder Studio", "Anchored to": "Verankert an", "Slide": "Folie", "Render behind image": "Hinter dem Bild rendern", @@ -2259,20 +2261,20 @@ "Ending": "Abschluss", "End": "Ende", "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn Wiederholen aus ist. Die Erkennungstafel ist die unter Überlagerungen eingestellte. Titel werden wie Dias überblendet, und die zufällige Reihenfolge verschiebt sie nie.", - "This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalten Sie ihn unter Titel ein, um ihn abzuspielen.", + "This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalte ihn unter Titel ein, um ihn abzuspielen.", "The intro screen, before the first photo.": "Der Intro-Bildschirm, vor dem ersten Foto.", "The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto.", "Templates": "Vorlagen", "Built-in": "Integriert", "Your templates": "Eigene Vorlagen", - "None yet: save the current settings as a template below.": "Noch keine: Speichern Sie die aktuellen Einstellungen unten als Vorlage.", + "None yet: save the current settings as a template below.": "Noch keine: Speichere die aktuellen Einstellungen unten als Vorlage.", "Before the last template": "Vor der letzten Vorlage", "Previous settings": "Vorherige Einstellungen", "New template name": "Name der neuen Vorlage", "Save as Template": "Als Vorlage speichern", "Import…": "Importieren…", "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Eigene Vorlagen brauchen einen Einstellungsordner, den diese Version nicht hat; die integrierten funktionieren.", - "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Klicken Sie auf eine Vorlage, um sie anzuwenden; zeigen Sie auf eine, um sie am ausgewählten Foto in der Vorschau zu sehen. Rechtsklicken Sie auf eigene, um sie zu aktualisieren, umzubenennen, zu exportieren oder zu löschen.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Klicke auf eine Vorlage, um sie anzuwenden; zeige auf eine, um sie am ausgewählten Foto in der Vorschau zu sehen. Klicke mit der rechten Maustaste auf eigene, um sie zu aktualisieren, umzubenennen, zu exportieren oder zu löschen.", "Import Slideshow Templates": "Diashow-Vorlagen importieren", "Export Slideshow Template": "Diashow-Vorlage exportieren", "Slideshow Templates": "Diashow-Vorlagen", @@ -2311,7 +2313,7 @@ "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: Ändern Sie ihre Regeln oder verwenden Sie Foto → Foto löschen", + "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", "LightCraft stopped": "LightCraft wurde beendet", "Show Focus Point": "Fokuspunkt anzeigen", "Zoom to Focus Point": "Auf Fokuspunkt zoomen", @@ -2341,5 +2343,1084 @@ "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" + "Checking the GPU for faces": "GPU für Gesichter wird geprüft", + "Set Folder Color Label": "Ordner-Farbmarkierung festlegen", + "Photo Below": "Foto darunter", + "Photo Above": "Foto darüber", + "Cycle Flag": "Markierung wechseln", + "Increase Flag Status": "Markierungsstatus erhöhen", + "Decrease Flag Status": "Markierungsstatus verringern", + "Update to Current Process": "Auf aktuelle Prozessversion aktualisieren", + "HDR": "HDR", + "Previous History Step": "Vorheriger Verlaufsschritt", + "Next History Step": "Nächster Verlaufsschritt", + "Find Blemishes": "Flecken finden", + "Keyword Info": "Stichwortinformationen", + "Create Keyword": "Stichwort erstellen", + "Edit Keyword": "Stichwort bearbeiten", + "Move Keyword": "Stichwort verschieben", + "Put New Keywords Inside": "Neue Stichwörter darin anlegen", + "Mask Effect Presets": "Maskeneffekt-Vorgaben", + "Apply Mask Effect": "Maskeneffekt anwenden", + "Save Mask Effect": "Maskeneffekt speichern", + "Update Mask Effect": "Maskeneffekt aktualisieren", + "Rename Mask Effect": "Maskeneffekt umbenennen", + "Duplicate Mask Effect": "Maskeneffekt duplizieren", + "Delete Mask Effect": "Maskeneffekt löschen", + "Favorite Mask Effect": "Maskeneffekt als Favorit markieren", + "Move Mask Effect to Group": "Maskeneffekt in Gruppe verschieben", + "Rename Mask Effect Group": "Maskeneffekt-Gruppe umbenennen", + "Set Effect for New Masks": "Effekt für neue Masken festlegen", + "Import Mask Effects": "Maskeneffekte importieren", + "Export Mask Effects": "Maskeneffekte exportieren", + "Write Waiting XMP Sidecars": "Ausstehende XMP-Begleitdateien schreiben", + "Slideshow Playback Settings": "Diashow-Wiedergabeeinstellungen", + "Slideshow Layout Settings": "Diashow-Layouteinstellungen", + "Slideshow Overlay Settings": "Diashow-Überlagerungseinstellungen", + "Slideshow Title Settings": "Diashow-Titeleinstellungen", + "Apply Slideshow Template": "Diashow-Vorlage anwenden", + "Save Slideshow Template": "Diashow-Vorlage speichern", + "Update Slideshow Template": "Diashow-Vorlage aktualisieren", + "Rename Slideshow Template": "Diashow-Vorlage umbenennen", + "Delete Slideshow Template": "Diashow-Vorlage löschen", + "Import Slideshow Template": "Diashow-Vorlage importieren", + "Slideshow Slide Preview": "Diashow-Dia-Vorschau", + "Slideshow Export Status": "Status des Diashow-Exports", + "Export Contact Sheet PDF": "Kontaktabzug-PDF exportieren", + "Apple Photos Imports": "Importe in Apple Fotos", + "Print Settings": "Druckeinstellungen", + "Set Print Settings": "Druckeinstellungen festlegen", + "Reset Print Settings": "Druckeinstellungen zurücksetzen", + "Add Print Cell": "Druckzelle hinzufügen", + "Move Print Cell": "Druckzelle verschieben", + "Resize Print Cell": "Größe der Druckzelle ändern", + "Delete Print Cell": "Druckzelle löschen", + "Arrange Print Cell": "Druckzelle anordnen", + "Set Print Cell Photo": "Foto der Druckzelle festlegen", + "Move Print Cell to Page": "Druckzelle auf Seite verschieben", + "Auto Layout": "Automatisches Layout", + "New Print Page": "Neue Druckseite", + "Clear Print Layout": "Drucklayout leeren", + "Print to PDF": "Als PDF drucken", + "Print Templates": "Druckvorlagen", + "Save Print Template": "Druckvorlage speichern", + "Apply Print Template": "Druckvorlage anwenden", + "Delete Print Template": "Druckvorlage löschen", + "Recheck Preview-Only Raws": "Nur-Vorschau-RAW-Dateien erneut prüfen", + "Scan Folder for Changes": "Ordner auf Änderungen prüfen", + "Face Models on the GPU": "Gesichtsmodelle auf der GPU", + "Rename Person": "Person umbenennen", + "Read Focus Points in the Background": "Fokuspunkte im Hintergrund lesen", + "Background Tasks": "Hintergrundaufgaben", + "Cancel Background Task": "Hintergrundaufgabe abbrechen", + "Select Only Active Photo": "Nur aktives Foto auswählen", + "Show in Compare": "In Vergleichsansicht zeigen", + "Increase Grid Thumbnail Size": "Rasterminiaturen vergrößern", + "Decrease Grid Thumbnail Size": "Rasterminiaturen verkleinern", + "Visualize HDR": "HDR visualisieren", + "Cycle Tool Overlay": "Werkzeugüberlagerung wechseln", + "Choose Layout Image…": "Layoutbild auswählen…", + "Show Layout Image": "Layoutbild anzeigen", + "Layout Image Opacity": "Deckkraft des Layoutbilds", + "Layout Image Matte": "Mattierung des Layoutbilds", + "Slideshow Options…": "Diashow-Optionen…", + "Pause / Resume Slideshow": "Diashow anhalten/fortsetzen", + "Next Slide": "Nächstes Dia", + "Previous Slide": "Vorheriges Dia", + "End Slideshow": "Diashow beenden", + "Contact Sheet PDF…": "Kontaktabzug-PDF…", + "Edit in PhotoCraft": "In PhotoCraft bearbeiten", + "Edit in Other Editor": "In anderem Editor bearbeiten", + "Open Original in Other Editor (RAW, no copy)": "Original in anderem Editor öffnen (RAW, keine Kopie)", + "Save Current Settings as New Effect Preset…": "Aktuelle Einstellungen als neue Effektvorgabe speichern…", + "Manage Effect Presets…": "Effektvorgaben verwalten…", + "Export Effect Presets…": "Effektvorgaben exportieren…", + "Display Profile": "Bildschirmprofil", + "HDR Headroom": "HDR-Spielraum", + "SDR Brightness": "SDR-Helligkeit", + "SDR Contrast": "SDR-Kontrast", + "SDR Highlights": "SDR-Lichter", + "SDR Shadows": "SDR-Tiefen", + "SDR Whites": "SDR-Weiß", + "SDR Clarity": "SDR-Klarheit", + "Previous page": "Vorherige Seite", + "Next page": "Nächste Seite", + "No Matte": "Keine Mattierung", + "Tool Overlay: Auto": "Werkzeugüberlagerung: Automatisch", + "Tool Overlay: Always": "Werkzeugüberlagerung: Immer", + "Tool Overlay: Selected": "Werkzeugüberlagerung: Nur ausgewählte", + "Tool Overlay: Never": "Werkzeugüberlagerung: Nie", + "Copy Settings…": "Einstellungen kopieren…", + "Thumbnail size": "Miniaturgröße", + "It is not a denoise model LightCraft understands": "Es ist kein Entrauschungsmodell, das LightCraft versteht", + "Resize region": "Bereichsgröße ändern", + "Remove region": "Bereich entfernen", + "Click to name this face": "Klicken, um dieses Gesicht zu benennen", + "Filters": "Filter", + "Mask": "Maske", + "New spot (Return adds it here)": "Neue Stelle (Eingabetaste fügt sie hier hinzu)", + "Combine with the existing person": "Mit der vorhandenen Person zusammenführen", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Farbraum: Rec. 2020 HLG (HDR-AVIF)", + "Progressive": "Progressiv", + "Masks keep their sliders. Export presets first to keep a copy.": "Masken behalten ihre Regler. Exportiere die Vorgaben vorher, um eine Kopie zu behalten.", + "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.", + "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", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Ziehen, um Farbton und Sättigung einzustellen. Umschalt hält den Farbton, Cmd/Strg die Sättigung, Alt/Option für Feineinstellung. Doppelklick setzt zurück.", + "Show / hide": "Ein-/ausblenden", + "Show this wheel's effect": "Wirkung dieses Rads anzeigen", + "Hide this wheel's effect": "Wirkung dieses Rads ausblenden", + "Add a model file…": "Modelldatei hinzufügen…", + "or drop a .onnx file on the window": "oder eine .onnx-Datei auf das Fenster ziehen", + "Already installed. Installing again is harmless.": "Bereits installiert. Erneutes Installieren schadet nicht.", + "Enter to confirm, Esc to cancel": "Eingabetaste bestätigt, Esc bricht ab", + "Show photos": "Fotos anzeigen", + "The layout image can't be shown": "Das Layoutbild kann nicht angezeigt werden", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Fotos bleiben in „Alle Fotos“. Beim Löschen eines Ordners werden auch seine Alben gelöscht. Du kannst das rückgängig machen.", + "No change": "Keine Änderung", + "Search effects": "Effekte durchsuchen", + "No effect presets yet": "Noch keine Effektvorgaben", + "No effect matches": "Kein passender Effekt", + "★ Favorites": "★ Favoriten", + "Reset Sliders": "Regler zurücksetzen", + "Save Current Settings as New Preset…": "Aktuelle Einstellungen als neue Vorgabe speichern…", + "Manage Presets…": "Vorgaben verwalten…", + "Apply to All Masks": "Auf alle Masken anwenden", + "Also the masks of the same name on the other selected photos": "Auch die gleichnamigen Masken der anderen ausgewählten Fotos", + "Delete…": "Löschen…", + "Group (optional)": "Gruppe (optional)", + "Choose a group": "Gruppe auswählen", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Hervorgehoben: Regler, die diese Maske ändert. Nicht enthaltene Regler bleiben beim Anwenden der Vorgabe unverändert.", + "Include Amount": "Stärke einbeziehen", + "Include brush size, feather and flow": "Pinselgröße, weiche Kante und Fluss einbeziehen", + "Name the preset": "Vorgabe benennen", + "Choose at least one setting to include": "Wähle mindestens eine Einstellung zum Einbeziehen aus", + "Ungrouped": "Ohne Gruppe", + "Favorite": "Favorit", + "Move to Group…": "In Gruppe verschieben…", + "New group — Return": "Neue Gruppe – Eingabetaste", + "Select a mask to apply or update presets.": "Wähle eine Maske aus, um Vorgaben anzuwenden oder zu aktualisieren.", + "Show mask": "Maske anzeigen", + "Overlay color": "Überlagerungsfarbe", + "Rename Person…": "Person umbenennen…", + "Their photos in the grid": "Ihre Fotos 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", + "PhotoCraft": "PhotoCraft", + "Found automatically": "Automatisch gefunden", + "Other editor": "Anderer Editor", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "„In anderem Editor bearbeiten“ (⌥⌘E) öffnet die Kopie in dieser Anwendung (unter macOS ein App-Name, sonst ein Programmpfad).", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Um diesem Editor stattdessen die Originaldatei selbst zu übergeben (die RAW-Datei, ohne Kopie, etwa für DxO PhotoLab), klicke mit der rechten Maustaste auf ein Foto → Bearbeiten in → Original in anderem Editor öffnen (RAW, keine Kopie).", + "Monitor profile": "Monitorprofil", + "None (the display is treated as sRGB)": "Keins (der Bildschirm wird als sRGB behandelt)", + "this screen's profile": "Profil dieses Bildschirms", + "Choose Profile…": "Profil auswählen…", + "Use Screen Profile": "Bildschirmprofil verwenden", + "Use sRGB": "sRGB verwenden", + "Matrix/TRC": "Matrix/TRC", + "LUT-based": "LUT-basiert", + "Primaries (x, y)": "Primärfarben (x, y)", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotos werden über das ICC-Profil deines Monitors angezeigt, sodass die Farben auf Wide-Gamut- und kalibrierten Bildschirmen stimmen und die Detailansicht den ganzen Farbraum des Bildschirms nutzt. Histogramm, Vorschau-Caches und Exporte sind davon nicht betroffen.", + "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.", + "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.", + "Title bar": "Titelleiste", + "Open Settings": "Einstellungen öffnen", + "Select none": "Keine auswählen", + "Select all": "Alle auswählen", + "Unnamed face": "Unbenanntes Gesicht", + "Template name": "Vorlagenname", + "One photo fills each page inside the margins.": "Ein Foto füllt jede Seite innerhalb der Ränder.", + "Add a cell": "Zelle hinzufügen", + "Managed by Printer (sRGB)": "Vom Drucker verwaltet (sRGB)", + "Managed by Printer (Adobe RGB)": "Vom Drucker verwaltet (Adobe RGB)", + "current edits": "aktuelle Bearbeitungen", + "This page setup is not valid": "Diese Seiteneinrichtung ist ungültig", + "Page": "Seite", + "cells": "Zellen", + "cells overlap": "Zellen überlappen sich", + "Delete Cell": "Zelle löschen", + "Click a cell to move, resize or arrange it": "Klicke auf eine Zelle, um sie zu verschieben, ihre Größe zu ändern oder sie anzuordnen", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) verwendet die Komprimierung intoPIX TicoRAW. LightCraft hat derzeit keine Lizenz für diesen Codec, daher ist nur die eingebettete JPEG-Vorschau der Kamera verfügbar, nicht die RAW-Sensordaten. Um RAW-Daten zu bearbeiten, fotografiere mit verlustfrei komprimiertem NEF oder wandle vorhandene HE-Dateien mit einem lizenzierten Konverter in DNG um.", + "Add a Face Recognition Model": "Gesichtserkennungsmodell hinzufügen", + "Choose Monitor Profile": "Monitorprofil auswählen", + "Display": "Bildschirm", + "Display profile not used": "Bildschirmprofil nicht verwendet", + "Edit In": "Bearbeiten in", + "Face models (ONNX)": "Gesichtsmodelle (ONNX)", + "ICC Profiles": "ICC-Profile", + "Remove Folder Color Label": "Ordner-Farbmarkierung entfernen", + "Loupe Overlay": "Lupenüberlagerung", + "2026-10-14": "14.10.2026", + "Lightroom radial masks on the right side": "Lightroom-Radialmasken auf der richtigen Seite", + "Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now": "Aus Lightroom Classic übernommene Radialmasken (Katalogimport, XMP-Begleitdateien, Bearbeitungen in DNGs und Vorgaben) wirken", + "apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in": "jetzt wie in Lightroom innerhalb des Kreises statt außerhalb (Issue #1815). Eine in Lightroom umgekehrte Maske", + "Lightroom stays inverted, and older Radial Filters keep their Effect Outside / Invert Mask setting. Photos imported": "bleibt umgekehrt, und ältere Radialfilter behalten ihre Einstellung „Effekt außen“ bzw. „Maske umkehren“. Vorher importierte Fotos", + "Delete in an album removes from the album only": "Löschen in einem Album entfernt nur aus dem Album", + "In an album, the Delete key now takes the selected photos out of the album, as in Lightroom. It used to move them": "In einem Album nimmt die Entf-Taste die ausgewählten Fotos jetzt wie in Lightroom aus dem Album heraus. Bisher kamen sie", + "to Recently Deleted, so they also left their folder's view (issue #1805). They stay in the library and in their": "nach „Zuletzt gelöscht“ und verschwanden so auch aus der Ansicht ihres Ordners (Issue #1805). Sie bleiben in der Bibliothek und in ihrem", + "album, Delete changes nothing: its rules decide what it shows.": "Album ändert Entf nichts: Dort bestimmen die Regeln, was es zeigt.", + "Local folders select a photo": "Lokale Ordner wählen ein Foto aus", + "Showing a Local folder now makes its first photo the active one, as albums and the other sources do (issue": "Beim Anzeigen eines lokalen Ordners wird jetzt sein erstes Foto zum aktiven, wie bei Alben und den anderen Quellen (Issue", + "#1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active:": "#1447), oder das bisherige Foto bleibt aktiv, wenn der Ordner es enthält. Vorher blieb das Foto der vorigen Ansicht aktiv:", + "Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder.": "Das Fotoraster markierte nichts, und Detail, Histogramm und Fotoinfo zeigten ein Foto, das gar nicht im Ordner liegt.", + "Nikon Z 6II, Z 8 and D5300 colour profiles": "Farbprofile für Nikon Z 6II, Z 8 und D5300", + "Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the": "RAW-Dateien von Nikon Z 6II, Z 8 und D5300 starten jetzt mit einem Farbprofil, das aus einigen hundert Fotos je Kamera erstellt wurde, so wie die", + "Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo": "Profile für Sony und Fujifilm, statt die Farben jedes Fotos einzeln anzupassen. Farben, von denen ein Foto", + "shows only a little of come out closer to the camera's JPEG.": "nur wenig zeigt, kommen dem JPEG der Kamera näher.", + "Closer camera colour for lens-corrected raws": "Kameragetreuere Farben bei objektivkorrigierten RAW-Dateien", + "Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the": "RAW-Dateien von Kameras, die ihr eigenes JPEG um die Objektivverzeichnung korrigieren (Panasonic und Fujifilm mit dieser Einstellung, die", + "Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that": "Sony A7R IVA), starten jetzt mit Farben, die denen der Kamera näher sind. Der Ausgangslook wurde an ein JPEG angepasst, das", + "didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt.": "an den Rändern nicht mit der RAW-Datei übereinstimmte. Bereits importierte Fotos übernehmen das, wenn ihre Vorschauen neu erstellt werden.", + "HDR merges of moving lights, and many brackets at once": "HDR-Zusammenfügen bewegter Lichter und vieler Belichtungsreihen auf einmal", + "A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer": "Ein Stern, der Mond oder ein Licht, das sich zwischen den Fotos einer Belichtungsreihe bewegt (eine Nachtreihe vom Stativ), hinterlässt", + "leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a": "kein schwarzes Loch mehr, wo das mittlere Foto es abgeschnitten hatte. Mit „Geisterbilder entfernen“ wird jetzt auch die Kopie aus dem hellsten Foto", + "dark sky is now removed too.": "im dunklen Himmel entfernt.", + "settings, one after the other: each stack, and photos taken less than a second apart at different exposures.": "Einstellungen nacheinander zusammen: jeden Stapel sowie Fotos, die weniger als eine Sekunde auseinander mit unterschiedlicher Belichtung aufgenommen wurden.", + "From the command line: `lightcraft-cli merge hdr-batch FOLDER`.": "Über die Befehlszeile: `lightcraft-cli merge hdr-batch FOLDER`.", + "Merging uses about a quarter less memory: three 67 MP raws need 3.8 GB instead of 4.8 GB.": "Das Zusammenfügen braucht etwa ein Viertel weniger Speicher: Drei 67-MP-RAW-Dateien benötigen 3,8 GB statt 4,8 GB.", + "XMP sidecars catch up after a network share comes back": "XMP-Begleitdateien holen auf, wenn eine Netzwerkfreigabe zurückkommt", + "With automatic XMP writing on, an edit to a photo whose folder can't be written (a network share that dropped": "Bei automatischem Schreiben von XMP wurde eine Bearbeitung an einem Foto, dessen Ordner nicht beschreibbar ist (eine Netzwerkfreigabe, die", + "out, a folder that went read-only) used to be written only to the library, with a line in the log, so the sidecar": "weggefallen ist, ein Ordner, der schreibgeschützt wurde), bisher nur in die Bibliothek geschrieben, mit einer Zeile im Protokoll, sodass die Begleitdatei", + "stayed behind the edit. LightCraft now says once that the sidecars are waiting, tries again every few seconds once": "hinter der Bearbeitung zurückblieb. LightCraft meldet jetzt einmal, dass Begleitdateien warten, versucht es alle paar Sekunden erneut, sobald", + "the files are reachable, and says when they're written. `library.retrySidecars` does it on request.": "die Dateien erreichbar sind, und meldet, wenn sie geschrieben sind. `library.retrySidecars` erledigt das auf Anfrage.", + "A damaged or crafted raw can no longer make LightCraft set aside gigabytes of memory before finding out the file is": "Eine beschädigte oder präparierte RAW-Datei kann LightCraft nicht mehr dazu bringen, Gigabytes an Speicher zu reservieren, bevor sich zeigt, dass die Datei", + "bad (issue #1512): a compressed PEF whose data is too short for the size it declares, an uncompressed TIFF-based": "fehlerhaft ist (Issue #1512): Ein komprimiertes PEF, dessen Daten für die angegebene Größe zu kurz sind, ein unkomprimiertes TIFF-basiertes", + "raw whose tile claims far more samples than it holds, and an ARW6 with a broken tile are refused first. All 74": "RAW-Datei, deren Kachel weit mehr Werte vorgibt, als sie enthält, und ein ARW6 mit defekter Kachel werden vorher abgewiesen. Alle 74", + "files in the test corpus still decode.": "Dateien des Testkorpus werden weiterhin dekodiert.", + "Thumbnails and previews are written to the cache on disk by one background writer instead of a new thread for": "Miniaturen und Vorschauen werden von einem einzigen Hintergrund-Schreiber in den Cache auf dem Datenträger geschrieben statt von einem neuen Thread für", + "each image (issue #1522). When renders come in faster than the disk takes them, at most 128 MB of images wait;": "jedes Bild (Issue #1522). Kommen Renderings schneller, als der Datenträger sie aufnimmt, warten höchstens 128 MB an Bildern;", + "past that a write is skipped (the image stays in memory and is written by a later render), so a slow disk can't": "darüber hinaus wird ein Schreibvorgang übersprungen (das Bild bleibt im Speicher und wird von einem späteren Rendering geschrieben), sodass ein langsamer Datenträger", + "pile up threads and memory.": "keine Threads und keinen Speicher mehr anhäufen kann.", + "Saving the catalog and resuming a model download no longer write through a link left at the temporary file's": "Das Speichern des Katalogs und das Fortsetzen eines Modell-Downloads schreiben nicht mehr durch einen Link, der unter dem Namen der temporären Datei", + "name (`catalog.snap.tmp`, `model.onnx.part`): the link is removed and a new file made in its place, so the file it": "liegt (`catalog.snap.tmp`, `model.onnx.part`): Der Link wird entfernt und an seiner Stelle eine neue Datei angelegt, sodass die Datei, auf die", + "pointed to is never changed (issue #1502).": "er zeigte, nie verändert wird (Issue #1502).", + "A photo whose file was replaced (Reload Changed Files after an outside edit) no longer keeps the faces recognition": "Ein Foto, dessen Datei ersetzt wurde (Geänderte Dateien neu laden nach einer externen Bearbeitung), behält nicht mehr die Gesichter, die die Erkennung", + "made from the old pixels: they are made again from the new file, and a scan still busy with the old file when it": "aus den alten Pixeln gefunden hatte: Sie werden aus der neuen Datei neu erkannt, und ein Scan, der beim Ändern noch mit der alten Datei beschäftigt war,", + "changed doesn't put its faces on the new one (issue #1524).": "setzt seine Gesichter nicht auf die neue (Issue #1524).", + "Settings stays put when you change tabs": "Die Einstellungen bleiben beim Wechsel des Tabs an ihrem Platz", + "The Settings window keeps its top edge and width when you switch tabs, so the tab row no longer jumps around": "Das Einstellungsfenster behält beim Wechsel der Tabs seine Oberkante und Breite, sodass die Tab-Leiste nicht mehr", + "under the pointer. It used to be centred, so every tab of a different height moved it.": "unter dem Mauszeiger springt. Bisher war es zentriert, sodass jeder Tab mit anderer Höhe es verschob.", + "Layout image over the loupe": "Layoutbild über der Lupenansicht", + "a magazine cover's masthead and cover lines, so you can frame a shot for the page it is made for. It fits inside": "den Titelschriftzug und die Zeilen eines Zeitschriftencovers, sodass du ein Motiv für die Seite gestalten kannst, für die es gedacht ist. Es passt sich", + "the photo, follows zoom and pan, and ⌥⌘O shows or hides it. The same menu sets its opacity and a matte that": "ins Foto ein, folgt Zoom und Verschieben, und ⌥⌘O blendet es ein oder aus. Im selben Menü stellst du die Deckkraft und eine Mattierung ein, die", + "darkens the photo outside it. The layout stays chosen across launches.": "das Foto außerhalb abdunkelt. Das Layout bleibt über Neustarts hinweg ausgewählt.", + "Smaller JPEG exports": "Kleinere JPEG-Exporte", + "JPEG exports are 12–14 % smaller at the same quality, with exactly the same pixels (issue #1373): each file now": "JPEG-Exporte sind bei gleicher Qualität 12–14 % kleiner, mit exakt denselben Pixeln (Issue #1373): Jede Datei enthält jetzt", + "carries Huffman tables made for its own picture instead of the standard example tables. Every viewer opens them": "Huffman-Tabellen, die für ihr eigenes Bild erstellt wurden, statt der Standard-Beispieltabellen. Jeder Betrachter öffnet sie", + "as before.": "wie bisher.", + "A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer": "Ein Foto, dessen Wert für „Farbsäume entfernen“ weit über der Reglergrenze von 20 gespeichert war (in einem beschädigten Katalog oder einer Begleitdatei), lässt", + "crashes LightCraft each time it renders: the amount acts as 20.": "LightCraft nicht mehr bei jedem Rendern abstürzen: Der Wert wirkt wie 20.", + "Lens names for Nikon DSLR raws": "Objektivnamen für RAW-Dateien von Nikon-DSLRs", + "Photos from Nikon DSLRs like the D700, D750, D80 or D5300 now show which lens they were taken with, for example": "Fotos von Nikon-DSLRs wie D700, D750, D80 oder D5300 zeigen jetzt, mit welchem Objektiv sie aufgenommen wurden, zum Beispiel", + "\"24.0-70.0 mm f/2.8\". Those cameras don't write a lens name, so the lens field was empty and you couldn't filter or": "„24.0-70.0 mm f/2.8“. Diese Kameras schreiben keinen Objektivnamen, sodass das Objektivfeld leer war und du nicht nach Objektiv filtern oder", + "search by lens. LightCraft now reads the lens range Nikon keeps in its own maker note, the way Lightroom shows a": "suchen konntest. LightCraft liest jetzt den Brennweitenbereich, den Nikon in seinen eigenen Herstellerdaten ablegt, so wie Lightroom ein", + "lens it doesn't have a name for. Newly imported photos show it.": "Objektiv ohne Namen anzeigt. Neu importierte Fotos zeigen ihn an.", + "Import when a card is connected": "Importieren, wenn eine Karte angeschlossen wird", + "Settings → Import → Cameras and Cards has an option to open the import review as soon as a camera or memory card": "Einstellungen → Importieren → Kameras und Karten bietet eine Option, die Importprüfung zu öffnen, sobald eine Kamera oder Speicherkarte", + "is connected, the same review File → Import from Device opens. It's off by default. A card that's already in when": "angeschlossen wird – dieselbe Prüfung, die Datei → Von Gerät importieren öffnet. Sie ist standardmäßig aus. Eine Karte, die beim Start von LightCraft", + "LightCraft starts doesn't ask, and if a dialog is open or an import is running it waits until that's done.": "schon steckt, löst keine Nachfrage aus, und ist ein Dialog offen oder läuft ein Import, wird gewartet, bis er fertig ist.", + "Photos deleted (or moved) by another app no longer stay in a Local folder, failing to load and logging an error": "Von einer anderen App gelöschte (oder verschobene) Fotos bleiben nicht mehr in einem lokalen Ordner, laden nicht und protokollieren nicht mehr bei jedem Hinsehen einen Fehler", + "each time you look (issue #1392): the next time you browse the folder, in a later session, they're left out. A photo you had rated, flagged or": "(Issue #1392): Beim nächsten Durchsuchen des Ordners, in einer späteren Sitzung, werden sie ausgelassen. Ein Foto, das du bewertet, markiert oder", + "edited stays, marked missing, so its changes aren't lost; photos in My Photos are unchanged.": "bearbeitet hattest, bleibt als fehlend markiert erhalten, damit seine Änderungen nicht verloren gehen; Fotos in „Meine Fotos“ bleiben unverändert.", + "Full-resolution panoramas": "Panoramen in voller Auflösung", + "Panorama and HDR Panorama merges keep the resolution of their photos instead of stopping at 40 megapixels": "Panorama- und HDR-Panorama-Zusammenfügungen behalten die Auflösung ihrer Fotos, statt bei 40 Megapixeln aufzuhören", + "(issue #1455), up to 512 MP and 65,000 pixels on the long edge, as in Lightroom. When the full size needs more": "(Issue #1455), bis zu 512 MP und 65.000 Pixel an der langen Kante, wie in Lightroom. Braucht die volle Größe mehr", + "memory than the computer has, the panorama is made as large as fits (five 51 MP photos: up to 166 MP with": "Speicher, als der Computer hat, wird das Panorama so groß wie möglich erstellt (fünf 51-MP-Fotos: bis zu 166 MP mit", + "16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are": "16 GB, 355 MP mit 32 GB), und die Meldung sagt das. Das Zusammenfügen gibt außerdem die Pixel jedes Fotos frei, sobald sie", + "projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB).": "projiziert sind, und braucht so bei jeder Größe weniger Speicher als bisher (ein 81-MP-Stitch: 4,4 GB statt 6,2 GB).", + "Nikon D600, D610 and D5000": "Nikon D600, D610 und D5000", + "Raws from the Nikon D600, D610 and D5000 no longer show a bright line down the right edge (issue #1627). Their": "RAW-Dateien von Nikon D600, D610 und D5000 zeigen keine helle Linie mehr am rechten Rand (Issue #1627). Ihre", + "sensor data runs a few columns of filler past the picture, which stayed in the image; these bodies now develop": "Sensordaten enthalten hinter dem Bild einige Füllspalten, die im Bild blieben; diese Modelle werden jetzt", + "at the camera JPEG's size (6016 × 4016 for the D600 and D610, 4288 × 2848 for the D5000, as the D90 already": "in der Größe des Kamera-JPEGs entwickelt (6016 × 4016 bei D600 und D610, 4288 × 2848 bei der D5000, wie schon bei der", + "did). On the D600 and D610 the white level is also measured inside the picture, so the filler can't pass for": "D90). Bei D600 und D610 wird zudem der Weißpunkt innerhalb des Bildes gemessen, damit die Füllspalten nicht als", + "clipped highlights.": "abgeschnittene Lichter gelten.", + "More Canon EOS R6 Mark II C-RAW files decode": "Mehr C-RAW-Dateien der Canon EOS R6 Mark II werden dekodiert", + "Some EOS R6 Mark II C-RAW files (about 1 in 50 in one photographer's library) opened as their preview with": "Manche C-RAW-Dateien der EOS R6 Mark II (etwa 1 von 50 in der Bibliothek eines Fotografen) öffneten sich nur als Vorschau mit", + "\"Canon CRX adaptive QP value 172\" or \"175\". They now decode from the sensor data. Photos imported before this": "„Canon CRX adaptive QP value 172“ oder „175“. Sie werden jetzt aus den Sensordaten dekodiert. Vorher importierte Fotos", + "Nikon raws without coloured edge bars": "Nikon-RAW-Dateien ohne farbige Randbalken", + "Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top": "RAW-Dateien älterer Nikon-Modelle zeigen keinen schmalen farbigen oder dunklen Balken mehr an einem Rand (am rechten Rand oder oben", + "of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914,": "bei einem Hochformatfoto): Maskierte Sensorspalten wurden als Teil des Bildes entwickelt (Issues #976, #914,", + "#1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600,": "#1236). D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600,", + "Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show": "Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s und Nikon 1 V1 zeigen jetzt", + "the area the camera itself frames, a few pixels smaller than before.": "den Bereich, den die Kamera selbst einrahmt, ein paar Pixel kleiner als bisher.", + "An empty library leads to Import": "Eine leere Bibliothek führt zum Import", + "A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a": "Eine Bibliothek ohne Fotos öffnet jetzt im Fotoraster mit Seitenleiste (Quadratisches Raster bleibt, wie es ist), statt in einer", + "Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app,": "Detailansicht, die auf ein leeres Raster verwies. Raster und Detailansicht bieten Fotos importieren… und, in der Desktop-App,", + "Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into": "Lightroom-Katalog importieren… als Schaltflächen an und sagen, dass sich Fotos (und in der Desktop-App auch Ordner) ins", + "the window. The shortcut they show is the one Import Photos… has now.": "Fenster ziehen lassen. Das angezeigte Tastenkürzel ist das, das Fotos importieren… jetzt hat.", + "The top bar has an Import button, beside Export, in every library.": "Die obere Leiste hat in jeder Bibliothek neben „Exportieren“ eine Schaltfläche „Importieren“.", + "One kind of button": "Eine Art von Schaltfläche", + "Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent),": "Die Schaltflächen der Dialoge und der Discord-Link unter „Über“ werden von einer einzigen Schaltflächenkomponente gezeichnet: primär (mit der Akzentfarbe gefüllt),", + "secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for": "sekundär, gefährlich (rot gefüllt) und Ghost, in drei Größen, mit oder ohne Symbol, sowie als reines Symbol (für", + "screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and": "Screenreader benannt). Jede wird beim Überfahren etwas und beim Drücken stärker abgedunkelt, zeigt einen Ring, wo der Tastaturfokus ist, und", + "its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's.": "ihr Text hat in jedem Design einen Kontrast von mindestens 4,5:1 zur Füllung. Dialogschaltflächen sind 30 Punkt hoch, wie die des Willkommensbildschirms.", + "A dialog's confirm button says it's the next step": "Die Bestätigungsschaltfläche eines Dialogs zeigt den nächsten Schritt an", + "In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or": "In Dialogen steht die Bestätigungsschaltfläche rechts, „Abbrechen“ links daneben, und sie ist mit der Akzentfarbe gefüllt, wenn sie etwas erstellt oder", + "brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive": "hereinholt (N Fotos importieren, Zusammenfügen, Speichern, Erstellen, Herunterladen…). Löschen und Entfernen bleiben schlicht, sodass eine destruktive", + "choice is never the eye-catching default.": "Wahl nie die auffällige Vorgabe ist.", + "Straight brush strokes with ⇧-click": "Gerade Pinselstriche mit ⇧-Klick", + "With the brush, click once, then ⇧-click somewhere else: the brush paints a straight line between the two, and": "Klicke mit dem Pinsel einmal und dann mit ⇧ woanders hin: Der Pinsel malt eine gerade Linie zwischen beiden Punkten, und", + "further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the": "weitere ⇧-Klicks setzen sie vom letzten Punkt aus fort, wie in Lightroom. Änderst du Pinselgröße, weiche Kante oder Fluss vor dem", + "⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-Klick, verläuft die Linie von den alten zu den neuen Werten. Die ganze Linie ist ein einziger Rückgängig-Schritt.", + "`mask.brushStroke {connect: true}` does the same for agents.": "`mask.brushStroke {connect: true}` macht dasselbe für Agenten.", + "Hard-edged Heal and Clone spots": "Hartkantige Reparieren- und Kopierstempel-Stellen", + "A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity": "Eine Stelle mit weicher Kante 0 zeichnet keine schwarzen Pixel mehr genau auf ihrem Radius. Das behebt auch, dass Stellen mit 0 % Deckkraft", + "changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.": "Pixel an ihrem Rand veränderten. Die harte Kante lässt Pixel auf dem Radius jetzt unverändert.", + "Where the camera focused": "Wo die Kamera fokussiert hat", + "and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`).": "und Übersicht, passend zu Drehung und Zuschnitt; das Info-Bedienfeld sagt, wie fokussiert wurde (`AF-C · Wide · Animal Eye Tracking`).", + "Lightroom has no such view.": "Lightroom hat keine solche Ansicht.", + "**Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key": "**Z zoomt dorthin**: Zoom umschalten zentriert auf den Fokuspunkt, sodass die Schärfeprüfung des Motivs eine Taste ist", + "Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame": "Vorerst RAW-Dateien der Sony α1 (ILCE-1) und A7S III (ILCE-7SM3); manuell fokussierte Aufnahmen und Wide-Aufnahmen, die nur die Bildmitte", + "centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md).": "melden, zeigen nichts. Weitere Kameras folgen. Details: [focus-points.md](focus-points.md).", + "**Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each": "**Fotos, die schon in deiner Bibliothek sind, bekommen ihren im Hintergrund**: LightCraft liest nur die wenigen Kilobyte jeder", + "file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row": "Datei, die den Fokuspunkt enthalten (etwa 100 Fotos pro Sekunde von einer Festplatte), mit einer Zeile *Fokuspunkte werden gelesen*", + "Cleaner HDR merges": "Saubereres HDR-Zusammenfügen", + "photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when": "Fotos entlang der Bereiche, die diese abgeschnitten hatten: Direkt neben einem abgeschnittenen Bereich wird die Farbe eines Fotos nicht verwendet. Das fiel am meisten auf, wenn", + "the highlights were darkened or saturated.": "die Lichter abgedunkelt oder gesättigt wurden.", + "The same photos give the same HDR whatever order they are selected in; before, a different order could change a": "Dieselben Fotos ergeben dasselbe HDR, egal in welcher Reihenfolge sie ausgewählt sind; vorher konnte eine andere Reihenfolge ein", + "few pixels.": "paar Pixel verändern.", + "HDR merges keep the white balance": "HDR-Zusammenfügungen behalten den Weißabgleich", + "An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same": "Ein HDR aus RAW-Dateien, die LightCraft anhand des Kamera-JPEGs einfärbt (CR2, NEF, ARW…), öffnet jetzt mit derselben", + "As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW": "Temperatur und Tönung „Wie Aufnahme“ wie seine Fotos, und eine Änderung des Weißabgleichs färbt es nicht mehr grün. Sony-ARW-", + "merges used to open at about 7000 K / +115 where the photos show 4000–5300 K / +8. HDRs merged before this keep": "Zusammenfügungen öffneten bisher mit etwa 7000 K / +115, wo die Fotos 4000–5300 K / +8 zeigen. Vorher zusammengefügte HDRs behalten", + "their old white balance; merge them again.": "ihren alten Weißabgleich; füge sie erneut zusammen.", + "2026-10-13": "13.10.2026", + "Edits keep up in a large library": "Bearbeitungen halten auch in großen Bibliotheken Schritt", + "A small edit (a keyword, a rating, a flag, a slider) no longer has the grid's list worked out again from every": "Eine kleine Bearbeitung (ein Stichwort, eine Bewertung, eine Markierung, ein Regler) berechnet die Liste des Rasters nicht mehr aus allen", + "photo: only the photos the edit touched are asked about again and put at their place in the order. In a library of": "Fotos neu: Nur die von der Bearbeitung betroffenen Fotos werden erneut geprüft und an ihren Platz in der Reihenfolge gesetzt. In einer Bibliothek mit", + "67,000 photos that step went from 37 ms to under 1 ms. Still worked out whole: an edit to more than 256 photos, a": "67.000 Fotos sank dieser Schritt von 37 ms auf unter 1 ms. Weiterhin vollständig neu berechnet werden: eine Bearbeitung von mehr als 256 Fotos, eine", + "change to an album, a stack or the keyword list, an album shown in its own order, Recently Added, Missing Photos,": "Änderung an einem Album, einem Stapel oder der Stichwortliste, ein Album in seiner eigenen Reihenfolge, „Zuletzt hinzugefügt“, „Fehlende Fotos“", + "and a view that asks about \"the last N days\".": "und eine Ansicht, die nach „den letzten N Tagen“ fragt.", + "With no filter set, the count of the source is taken from the list instead of from a second pass over the library.": "Ohne gesetzten Filter wird die Anzahl der Quelle aus der Liste übernommen statt aus einem zweiten Durchlauf über die Bibliothek.", + "The side panels keep a tally an edit cannot have changed: a rating or a slider changes none of the folders,": "Die Seitenleisten behalten Zählungen, die eine Bearbeitung nicht verändert haben kann: Eine Bewertung oder ein Regler ändert keinen der Ordner,", + "keywords, dates, people and counts; a keyword leaves the folders, the dates and the counts as they are. With both": "Stichwörter, Daten, Personen und Zählungen; ein Stichwort lässt Ordner, Daten und Zählungen unverändert. Mit beiden", + "side panels open, the frame after a keyword went from about 156 ms to about 54 ms in that library (2 ms with the": "geöffneten Seitenleisten sank das Frame nach einem Stichwort in dieser Bibliothek von etwa 156 ms auf etwa 54 ms (2 ms bei", + "panels closed, from 71 ms).": "geschlossenen Leisten, vorher 71 ms).", + "The count beside each smart album in the left panel follows an edit the same way: the album asks only the photos": "Die Anzahl neben jedem Smart-Album in der linken Leiste folgt einer Bearbeitung genauso: Das Album prüft nur die Fotos,", + "the edit touched, where it asked every photo of the library after each edit (about 25 ms an album in that library).": "die die Bearbeitung betraf, wo es bisher nach jeder Bearbeitung jedes Foto der Bibliothek prüfte (etwa 25 ms pro Album in dieser Bibliothek).", + "With that, the frame after a keyword is at about 29 ms there. New rules, a change to an album or the keyword list,": "Damit liegt das Frame nach einem Stichwort dort bei etwa 29 ms. Neue Regeln, eine Änderung an einem Album oder der Stichwortliste,", + "an edit to more than 256 photos, and each new minute for an album that asks about \"the last N days\" are still": "eine Bearbeitung von mehr als 256 Fotos und jede neue Minute bei einem Album, das nach „den letzten N Tagen“ fragt, werden weiterhin", + "counted from every photo.": "aus allen Fotos gezählt.", + "Still tallied from every photo on each edit: the filter bar's lists of cameras, lenses and keywords.": "Weiterhin bei jeder Bearbeitung aus allen Fotos ermittelt: die Listen der Kameras, Objektive und Stichwörter in der Filterleiste.", + "A view can hold its photos until you refresh it": "Eine Ansicht kann ihre Fotos bis zum Aktualisieren behalten", + "a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords,": "das durch eine Bearbeitung nicht mehr zur Ansicht passt, in der Ansicht: ein Stichwort, das in einem Smart-Album für Fotos ohne Stichwörter vergeben wird,", + "a flag taken off in Picks, a rating that falls under the filter bar's. With it off, as before, the photo leaves the": "eine in „Ausgewählte Fotos“ entfernte Markierung, eine Bewertung unter dem Wert der Filterleiste. Ist es aus, verschwindet das Foto wie bisher", + "Reshuffle, another source, filter or sort, or new rules for the smart album shows what matches now.": "Neu mischen, eine andere Quelle, ein anderer Filter oder eine andere Sortierung oder neue Regeln für das Smart-Album zeigen, was jetzt passt.", + "Photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder": "Fotos, die neu passen, erscheinen weiterhin sofort, und ein gelöschtes Foto oder eines, das aus dem angezeigten Album oder Ordner", + "shown still takes it out of the view.": "genommen wird, verschwindet weiterhin aus der Ansicht.", + "The window opens at the size you left it": "Das Fenster öffnet sich in der Größe, in der du es verlassen hast", + "LightCraft's window opens at the size it had when you last used it, and zoomed (macOS) / maximized if it was: it": "Das Fenster von LightCraft öffnet sich in der Größe, die es bei der letzten Nutzung hatte, und gezoomt (macOS) bzw. maximiert, wenn es das war:", + "used to open at 1600 × 1000 every time. Double-click the top bar to zoom the window, quit, and it is zoomed when": "Bisher öffnete es jedes Mal mit 1600 × 1000. Doppelklicke auf die obere Leiste, um das Fenster zu zoomen, beende die App, und es ist gezoomt, wenn", + "you come back; un-zoom it and it goes back to the size it had before.": "du zurückkommst; hebst du den Zoom auf, kehrt es zu seiner vorherigen Größe zurück.", + "Full screen is not remembered (the app opens as a window), and neither is where on the desktop the window was:": "Der Vollbildmodus wird nicht gespeichert (die App öffnet als Fenster), ebenso wenig, wo auf dem Desktop das Fenster war:", + "the system places it.": "Das System platziert es.", + "On macOS the system does not tell LightCraft when a window is zoomed from the Window menu or resized after a": "Unter macOS teilt das System LightCraft nicht mit, wenn ein Fenster über das Menü „Fenster“ gezoomt oder nach einem", + "zoom, so it goes by the window's size, and when in doubt takes the window for a window of the size it has: it": "Zoomen in der Größe geändert wird. Deshalb richtet es sich nach der Fenstergröße und hält im Zweifel das Fenster für ein Fenster in seiner Größe:", + "comes back as large as you left it, but not as a zoomed window. That is so after a zoom from the Window menu": "Es kommt so groß zurück, wie du es verlassen hast, aber nicht als gezoomtes Fenster. Das gilt nach einem Zoomen über das Menü „Fenster“", + "with the Dock at a side of the screen, or of a window already as wide as the screen. Zoomed by a double-click": "mit dem Dock an einer Bildschirmseite oder bei einem Fenster, das schon so breit wie der Bildschirm ist. Per Doppelklick", + "on the top bar it comes back zoomed. The other way round, a window you drag out until it is as wide as the": "auf die obere Leiste gezoomt, kommt es gezoomt zurück. Umgekehrt gilt ein Fenster, das du aufziehst, bis es so breit wie der", + "screen and nearly as tall counts as zoomed.": "Bildschirm und fast so hoch ist, als gezoomt.", + "For scripts: a size set with `ui.resize` is remembered like any other, so a script that needs a size sets it.": "Für Skripte: Eine mit `ui.resize` gesetzte Größe wird wie jede andere gespeichert; ein Skript, das eine Größe braucht, setzt sie also.", + "HDR merges keep the lens correction": "HDR-Zusammenfügungen behalten die Objektivkorrektur", + "does the photos it was made from. HDRs merged before this don't have it; merge them again.": "die Fotos, aus denen es entstand. Vorher zusammengefügte HDRs haben sie nicht; füge sie erneut zusammen.", + "Portable mode": "Portabler Modus", + "A file named `portable.txt` next to the LightCraft program makes it keep its settings, logs and downloaded": "Eine Datei namens `portable.txt` neben dem LightCraft-Programm sorgt dafür, dass Einstellungen, Protokolle und heruntergeladene", + "models in a `LightCraftData` folder beside it instead of the per-user settings folder (issue #1200), as": "Modelle in einem Ordner `LightCraftData` daneben liegen statt im benutzereigenen Einstellungsordner (Issue #1200), wie", + "PhotoCraft does. If that folder can't be written, the usual folder is used and the log says why.": "PhotoCraft es macht. Lässt sich dieser Ordner nicht beschreiben, wird der übliche Ordner verwendet, und das Protokoll nennt den Grund.", + "Importing a preset zip that unpacks to far more than any preset (a \"zip bomb\") is refused with a message naming": "Das Importieren eines Vorgaben-ZIPs, das sich auf weit mehr entpackt als jede Vorgabe (eine „Zip-Bombe“), wird mit einer Meldung abgelehnt, die", + "the file, instead of asking for gigabytes of memory, which could end LightCraft.": "die Datei nennt, statt Gigabytes an Speicher anzufordern, was LightCraft beenden konnte.", + "A crop of no size (from an agent or a sidecar) no longer gives the Detail view a photo of no size to lay out": "Ein Zuschnitt ohne Größe (von einem Agenten oder aus einer Begleitdatei) gibt der Detailansicht kein Foto ohne Größe mehr zum Anordnen", + "(its width over its height was 0 / 0, which stopped debug builds); it shows as a single pixel's colour.": "(sein Seitenverhältnis war 0 / 0, was Debug-Builds anhielt); es erscheint als Farbe eines einzelnen Pixels.", + "A crop a hair wide (or of no size), set by an agent or read from a sidecar, no longer crashes or hangs the": "Ein haarbreiter Zuschnitt (oder einer ohne Größe), von einem Agenten gesetzt oder aus einer Begleitdatei gelesen, lässt das Rendering", + "render with Clarity, Dehaze, Texture or Highlights / Shadows on: their blurs are held to the picture's size.": "mit Klarheit, Dunst entfernen, Struktur oder Lichter/Tiefen nicht mehr abstürzen oder hängen: Ihre Weichzeichnung bleibt auf die Bildgröße begrenzt.", + "A crop of no size with healing spots or masks no longer crashes the render: samples outside the picture read its": "Ein Zuschnitt ohne Größe mit Reparaturstellen oder Masken lässt das Rendering nicht mehr abstürzen: Werte außerhalb des Bildes lesen dessen", + "edge.": "Rand.", + "A library whose catalog file was damaged (or edited by hand) in a way that still reads no longer crashes": "Eine Bibliothek, deren Katalogdatei beschädigt (oder von Hand bearbeitet) wurde, aber noch lesbar ist, lässt", + "LightCraft when it shows a stack, or hangs it when a folder is moved: on opening, records are put back under": "LightCraft nicht mehr abstürzen, wenn ein Stapel angezeigt wird, oder hängen, wenn ein Ordner verschoben wird: Beim Öffnen kommen Einträge zurück unter", + "their own ids, stacks keep only photos that exist (at least two), folders inside themselves move to the top,": "ihre eigenen IDs, Stapel behalten nur existierende Fotos (mindestens zwei), Ordner, die in sich selbst liegen, wandern nach oben,", + "and new photos never reuse an id in use. What was repaired is written to the log.": "und neue Fotos verwenden nie eine bereits vergebene ID. Was repariert wurde, steht im Protokoll.", + "Nikon lens corrections": "Nikon-Objektivkorrekturen", + "Raws from the Nikon Z 6II, Z 8 and Z f now open with the camera's own distortion correction, as Lightroom applies a": "RAW-Dateien von Nikon Z 6II, Z 8 und Z f öffnen jetzt mit der Verzeichnungskorrektur der Kamera, so wie Lightroom ein", + "built-in profile: straight lines near the edges stay straight and the framing matches the camera's JPEG. It works": "eingebautes Profil anwendet: Gerade Linien nahe den Rändern bleiben gerade, und der Bildausschnitt entspricht dem JPEG der Kamera. Das funktioniert", + "with Z lenses and with F-mount lenses on the FTZ adapter, follows the in-body stabilisation's shift of the optical": "mit Z-Objektiven und mit F-Bajonett-Objektiven am FTZ-Adapter, folgt der Verschiebung der optischen Mitte durch den Bildstabilisator", + "centre, and can be turned off under Lens Corrections. Vignetting correction from the file comes later.": "und lässt sich unter Objektivkorrekturen abschalten. Die Vignettierungskorrektur aus der Datei folgt später.", + "Thumbnail cache stays in its folder": "Der Miniaturbild-Cache bleibt in seinem Ordner", + "The thumbnail cache no longer follows a shard folder (`thumbs/ab/`) that is a symlink or junction to somewhere": "Der Miniaturbild-Cache folgt keinem Unterordner (`thumbs/ab/`) mehr, der ein symbolischer Link oder eine Verknüpfung woandershin", + "else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read": "ist (Issue #1504): Beim Kürzen oder Leeren des Caches können keine Dateien außerhalb gelöscht werden, und Miniaturen werden nie", + "from or written through such a link.": "über einen solchen Link gelesen oder geschrieben.", + "Synchronize Folder asks a network share less": "„Ordner synchronisieren“ fragt eine Netzwerkfreigabe seltener", + "A Synchronize Folder scan no longer asks the disk about every photo of the library in the folder (is its file": "Ein Scan mit „Ordner synchronisieren“ fragt den Datenträger nicht mehr zu jedem Foto der Bibliothek im Ordner (ist seine Datei", + "there, has it a sidecar): it has just listed the folder, and reads the answers off the listing. On a network share": "da, hat es eine Begleitdatei): Er hat den Ordner gerade aufgelistet und liest die Antworten aus der Liste. Auf einer Netzwerkfreigabe", + "each of those questions is a round trip. Measured on a hard disk over SMB, for folders of 1,600 to 2,400 photos": "ist jede dieser Fragen ein Hin und Zurück. Gemessen auf einer Festplatte über SMB, bei Ordnern mit 1.600 bis 2.400 Fotos,", + "that had not changed: 4.6 to 7.1 s of questions after the listing (about 3 ms a photo), now under 0.05 s. The": "die sich nicht geändert hatten: 4,6 bis 7,1 s an Abfragen nach dem Auflisten (etwa 3 ms pro Foto), jetzt unter 0,05 s. Das", + "listing itself took 4 to 5 s and is as it was.": "Auflisten selbst dauerte 4 bis 5 s und ist unverändert.", + "The disk is still asked wherever a listing can't be sure what a file system would say: about a name that is": "Der Datenträger wird weiterhin gefragt, wo eine Liste nicht sicher sagen kann, was ein Dateisystem antworten würde: bei einem Namen, der", + "there in another case, a link, a name Windows reads another way (`aux.jpg`, a `~`, a `:`, a dot or a space at the": "in anderer Groß-/Kleinschreibung vorhanden ist, bei einem Link, bei einem Namen, den Windows anders liest (`aux.jpg`, ein `~`, ein `:`, ein Punkt oder ein Leerzeichen am", + "end), a folder the scan did not walk to its end, and, in a folder that holds any name with a character outside": "Ende), bei einem Ordner, den der Scan nicht bis zum Ende durchlaufen hat, und in einem Ordner, der einen Namen mit einem Zeichen außerhalb von", + "ASCII (an umlaut, a curly apostrophe, a dash), about every name the folder does not list. Names it does list are": "ASCII enthält (ein Umlaut, ein typografischer Apostroph, ein Gedankenstrich), bei jedem Namen, den der Ordner nicht auflistet. Aufgelistete Namen", + "answered there too. In such a folder the scan saves less, and its checks now start after the listing where they": "werden auch dort aus der Liste beantwortet. In einem solchen Ordner spart der Scan weniger, und seine Prüfungen beginnen jetzt nach dem Auflisten, statt", + "ran beside it.": "parallel dazu zu laufen.", + "A photo the folder lists is there for the scan, also where its file could not be opened by its path (a folder": "Ein Foto, das der Ordner auflistet, ist für den Scan vorhanden, auch wenn seine Datei nicht über ihren Pfad geöffnet werden konnte (ein Ordner,", + "that may be listed but not searched, a path longer than the system takes): such photos used to be listed as": "der aufgelistet, aber nicht durchsucht werden darf, ein Pfad, der länger ist, als das System erlaubt): Solche Fotos wurden bisher als", + "missing. And a share that drops after the scan has listed a folder no longer turns that folder's photos into": "fehlend aufgeführt. Und eine Freigabe, die wegfällt, nachdem der Scan einen Ordner aufgelistet hat, macht die Fotos dieses Ordners nicht mehr zu", + "missing ones.": "fehlenden.", + "Large libraries: Synchronize Folder, import and sidecars start sooner": "Große Bibliotheken: „Ordner synchronisieren“, Import und Begleitdateien starten schneller", + "Opening Synchronize Folder, an import review or an import no longer makes a table of every photo in the library": "Das Öffnen von „Ordner synchronisieren“, einer Importprüfung oder eines Imports legt nicht mehr zuerst eine Tabelle aller Fotos der Bibliothek an,", + "before it looks at the first file. In a library of 3.5 million photos that held the window for about 6 s before": "bevor die erste Datei angesehen wird. In einer Bibliothek mit 3,5 Millionen Fotos blockierte das das Fenster etwa 6 s, bevor", + "a Synchronize scan began (now under 1 s), 2.6 s before an import review (now 0.05 s) and 1.7 s before an import": "ein Synchronisierungsscan begann (jetzt unter 1 s), 2,6 s vor einer Importprüfung (jetzt 0,05 s) und 1,7 s vor einem Import", + "(now 0.05 s). The scan looks up only the files it finds and the contents it reads, on its own thread.": "(jetzt 0,05 s). Der Scan schlägt nur die gefundenen Dateien und gelesenen Inhalte nach, in einem eigenen Thread.", + "Writing one photo's XMP sidecar (Save Metadata to File, or automatically after an edit) no longer works out who": "Das Schreiben der XMP-Begleitdatei eines Fotos („Metadaten in Datei speichern“ oder automatisch nach einer Bearbeitung) ermittelt nicht mehr, wem", + "owns every shared sidecar name in the library: 2.4 s a write in that library, now under 1 s.": "jeder gemeinsam genutzte Begleitdateiname in der Bibliothek gehört: 2,4 s pro Schreibvorgang in dieser Bibliothek, jetzt unter 1 s.", + "Nothing else changes: the same files are found new, already there, moved or missing, and sidecars get the same": "Sonst ändert sich nichts: Dieselben Dateien gelten als neu, bereits vorhanden, verschoben oder fehlend, und Begleitdateien erhalten dieselben", + "names.": "Namen.", + "Faces load faster in the People view": "Gesichter laden in der Personenansicht schneller", + "Faces on unedited raws are cut from the camera's preview inside the file instead of developing the raw (issue": "Gesichter auf unbearbeiteten RAW-Fotos werden aus der Kameravorschau in der Datei ausgeschnitten, statt die RAW-Datei zu entwickeln (Issue", + "#1440): a Sony α1 face reads 8 MB instead of 70 MB, an A7S III face about 1 MB instead of 24 MB, several times": "#1440): Ein Gesicht der Sony α1 liest 8 MB statt 70 MB, eines der A7S III etwa 1 MB statt 24 MB, auf einer externen Festplatte", + "faster on an external hard disk. A face whose preview is too small, and any edited photo, is still developed from": "um ein Mehrfaches schneller. Ein Gesicht mit zu kleiner Vorschau und jedes bearbeitete Foto werden weiterhin aus", + "the raw, so it keeps its look.": "der RAW-Datei entwickelt, damit der Look erhalten bleibt.", + "Raws whose preview is in Adobe RGB (Sony α1 among others) no longer read the whole file for their grid thumbnail:": "RAW-Dateien mit einer Vorschau in Adobe RGB (unter anderem Sony α1) lesen für ihre Rasterminiatur nicht mehr die ganze Datei:", + "only the header and the preview are read, as for other raws.": "Nur Header und Vorschau werden gelesen, wie bei anderen RAW-Dateien.", + "Faces are kept in the thumbnail cache at a few fixed sizes, so moving the thumbnail slider no longer renders them": "Gesichter liegen im Miniaturbild-Cache in einigen festen Größen, sodass das Verschieben des Miniaturreglers sie nicht mehr", + "all again.": "alle neu berechnet.", + "A raw file whose black level is stored as \"not a number\" (or infinity), or a floating-point DNG with such": "Eine RAW-Datei, deren Schwarzwert als „keine Zahl“ (oder unendlich) gespeichert ist, oder ein Gleitkomma-DNG mit solchen", + "samples, no longer crashes LightCraft at 1:1 or on export: the bad values read as black.": "Werten lässt LightCraft bei 1:1 oder beim Export nicht mehr abstürzen: Die ungültigen Werte gelten als Schwarz.", + "Exporting a photo more than 65,535 times as long as it is wide (a one-pixel strip), or opening the Export dialog's": "Das Exportieren eines Fotos, das mehr als 65.535-mal so lang wie breit ist (ein Streifen von einem Pixel), oder das Öffnen der Wasserzeichenvorschau", + "watermark preview for it, no longer crashes LightCraft; the export is capped at the encoders' 65,535 pixels.": "des Exportdialogs dafür lässt LightCraft nicht mehr abstürzen; der Export wird auf die 65.535 Pixel der Encoder begrenzt.", + "Dragging a Tone Curve point that sits very close to its neighbours (two clicks side by side, or a curve from a": "Das Ziehen eines Gradationskurvenpunkts, der sehr nah an seinen Nachbarn liegt (zwei Klicks nebeneinander oder eine Kurve aus einer", + "sidecar, a preset or an agent) no longer crashes LightCraft; the point stays between them. Curve points sent to": "Begleitdatei, einer Vorgabe oder von einem Agenten), lässt LightCraft nicht mehr abstürzen; der Punkt bleibt zwischen ihnen. An", + "`develop.curve` are kept in order, and a sidecar curve with a \"NaN\" or \"inf\" point is ignored.": "`develop.curve` gesendete Kurvenpunkte bleiben geordnet, und eine Kurve aus einer Begleitdatei mit einem „NaN“- oder „inf“-Punkt wird ignoriert.", + "A demo photo whose stored size was damaged (a width of 4294967295) is rendered at its own size instead of": "Ein Demofoto mit beschädigter gespeicherter Größe (Breite 4294967295) wird in seiner eigenen Größe gerendert statt in einer,", + "one that overflows.": "die überläuft.", + "DNG camera identity": "Kameraidentität in DNGs", + "DNG export and Convert to DNG no longer duplicate the camera make when the model already": "DNG-Export und „In DNG konvertieren“ verdoppeln den Kamerahersteller nicht mehr, wenn das Modell ihn bereits", + "contains it: `Canon EOS R6` stays `Canon EOS R6`, rather than `Canon Canon EOS R6` (the camera": "enthält: `Canon EOS R6` bleibt `Canon EOS R6` statt `Canon Canon EOS R6` (der Teil zur Kameraidentität", + "identity subissue in #840). The separate Make and Model tags keep their original values.": "in #840). Die getrennten Make- und Model-Tags behalten ihre ursprünglichen Werte.", + "This does not change colour matrices, camera calibration or RAW noise rendering.": "Farbmatrizen, Kamerakalibrierung und RAW-Rauschwiedergabe ändern sich dadurch nicht.", + "Building from source": "Aus dem Quellcode bauen", + "The English and Simplified Chinese build guides now describe command-local job and release": "Die englische und die vereinfacht-chinesische Build-Anleitung beschreiben jetzt befehlslokale Job- und Release-", + "profile overrides for memory-constrained builders (issue #1196), including why packaging": "Profil-Einstellungen für Rechner mit wenig Speicher (Issue #1196), einschließlich der Frage, warum Paketierungs-", + "recipes can override inherited settings. Default release profiles and application behavior": "Rezepte geerbte Einstellungen überschreiben können. Standard-Release-Profile und Verhalten der Anwendung", + "are unchanged; the guidance does not guarantee a build will fit every 16 GB machine.": "sind unverändert; die Anleitung garantiert nicht, dass ein Build auf jeden Rechner mit 16 GB passt.", + "Lightroom catalog import": "Import von Lightroom-Katalogen", + "A Lightroom Classic catalog whose changes are all still in its `.lrcat-wal` file (a new catalog Lightroom has": "Ein Lightroom-Classic-Katalog, dessen Änderungen alle noch in seiner `.lrcat-wal`-Datei stehen (ein neuer Katalog, den Lightroom", + "open and hasn't written back yet) imports instead of failing with \"unsupported SQLite schema format\".": "geöffnet und noch nicht zurückgeschrieben hat), wird importiert, statt mit „unsupported SQLite schema format“ zu scheitern.", + "An export enlarged far past the photo's size (20000 typed for 2000 with Don't Enlarge off) no longer ends": "Ein Export, der weit über die Fotogröße vergrößert wird (20000 statt 2000 eingegeben, mit „Nicht vergrößern“ aus), beendet", + "LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the": "LightCraft nicht mehr: Eine Vergrößerung endet wie in Lightroom bei 512 Megapixeln, und eine, die mehr Speicher braucht, als der", + "computer has is refused with the size it would take, before it starts.": "Computer hat, wird vor dem Start mit der Größe abgelehnt, die sie bräuchte.", + "Confirm matching faces together": "Passende Gesichter gemeinsam bestätigen", + "On a person's page, the ↑ control on a suggested face confirms it and all shown suggestions with equal or higher": "Auf der Seite einer Person bestätigt das ↑-Steuerelement an einem vorgeschlagenen Gesicht dieses und alle angezeigten Vorschläge mit gleicher oder höherer", + "similarity as one undo step. Hover or focus that face and press A for the same action; hidden suggestions stay hidden.": "Ähnlichkeit in einem Rückgängig-Schritt. Zeige auf das Gesicht oder fokussiere es und drücke A für dieselbe Aktion; ausgeblendete Vorschläge bleiben ausgeblendet.", + "Noise reduction looks the same at every size": "Rauschreduzierung sieht in jeder Größe gleich aus", + "Luminance and colour noise reduction no longer look much stronger in grid thumbnails and the filmstrip than in": "Luminanz- und Farbrauschreduzierung wirken in Rasterminiaturen und im Filmstreifen nicht mehr viel stärker als in", + "the loupe (issue #960). Their radii are now set in the original photo's pixels, so a thumbnail, the fitted loupe": "der Lupenansicht (Issue #960). Ihre Radien werden jetzt in Pixeln des Originalfotos festgelegt, sodass eine Miniatur, die eingepasste Lupenansicht", + "and a smaller export look like the full-size result scaled down; 1:1 views and full-size exports are unchanged.": "und ein kleinerer Export wie das verkleinerte Ergebnis in voller Größe aussehen; 1:1-Ansichten und Exporte in voller Größe sind unverändert.", + "Like in Lightroom, judge noise reduction at 1:1: at small sizes the noise it removes is mostly averaged away.": "Beurteile die Rauschreduzierung wie in Lightroom bei 1:1: In kleinen Größen mittelt sich das entfernte Rauschen größtenteils weg.", + "Cached thumbnails are redrawn.": "Zwischengespeicherte Miniaturen werden neu gezeichnet.", + "When the graphics driver resets the GPU while LightCraft is exporting or rendering (issue #1337, seen on Windows": "Setzt der Grafiktreiber die GPU zurück, während LightCraft exportiert oder rendert (Issue #1337, gesehen unter Windows", + "with NVIDIA), LightCraft still stops, because its window can't keep drawing on the lost device, but it now says": "mit NVIDIA), beendet sich LightCraft weiterhin, weil sein Fenster auf dem verlorenen Gerät nicht weiterzeichnen kann, meldet jetzt aber", + "\"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", + "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.", + "Slider drags on large raws": "Reglerbewegungen bei großen RAW-Dateien", + "Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you": "Das Ziehen eines Reglers berechnet die Filmstreifen-Miniatur des bearbeiteten Fotos nicht mehr in jedem Frame neu; sie folgt, sobald du", + "let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's": "loslässt. Bei einer 64-MP-RAW-Datei dekodierte die erste dieser Berechnungen die ganze Datei erneut, was die", + "previews for up to a second.": "Vorschauen der Lupenansicht bis zu einer Sekunde aufhielt.", + "A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file": "Eine Miniatur des in Detail geöffneten Fotos entsteht aus seiner bereits dekodierten Vorschau, statt die Datei", + "again.": "erneut zu dekodieren.", + "Dehaze while zoomed in": "Dunst entfernen in der Zoomansicht", + "With Dehaze on, panning a photo zoomed to 1:1 or moving a slider while zoomed no longer samples the whole": "Mit eingeschaltetem „Dunst entfernen“ tastet das Verschieben eines auf 1:1 gezoomten Fotos oder das Bewegen eines Reglers beim Zoomen nicht mehr das ganze", + "original again to find the haze colour. On a 64 MP raw (Sony A7R VI) that took ~0.2 s of CPU per pan and per": "Original erneut ab, um die Dunstfarbe zu finden. Bei einer 64-MP-RAW-Datei (Sony A7R VI) kostete das ~0,2 s CPU-Zeit pro Verschieben und pro", + "slider step; a slider step now takes ~5 ms on the GPU.": "Reglerschritt; ein Reglerschritt dauert jetzt ~5 ms auf der GPU.", + "Thumbnails of raw files appear sooner": "Miniaturen von RAW-Dateien erscheinen früher", + "Grid thumbnails and the first picture in the loupe of an unedited raw now come from the smallest JPEG the camera": "Rasterminiaturen und das erste Bild in der Lupenansicht einer unbearbeiteten RAW-Datei kommen jetzt aus dem kleinsten JPEG, das die Kamera", + "stored that is big enough, not always from the largest one. On a Sony A7R VI file a 512 px thumbnail decodes": "gespeichert hat und das groß genug ist, nicht immer aus dem größten. Bei einer Datei der Sony A7R VI dekodiert eine 512-px-Miniatur", + "0.3 MB of JPEG instead of 5.6 MB and is ready several times sooner; Nikon and Panasonic raws with a mid-sized": "0,3 MB JPEG statt 5,6 MB und ist um ein Mehrfaches früher fertig; auch RAW-Dateien von Nikon und Panasonic mit einem mittelgroßen", + "JPEG get faster too.": "JPEG werden schneller.", + "2026-10-12": "12.10.2026", + "Canon CR2 highlights": "Lichter in Canon-CR2-Dateien", + "Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),": "Ausgefressene Lichter in CR2-Dateien der Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),", + "1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so": "1200D (Rebel T5) und 100D (Rebel SL1) gelten jetzt als abgeschnitten (Issue #1272). Sie endeten bisher bei 81–98 % von Weiß, sodass", + "highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey.": "die Lichterwiederherstellung sie übersah und sie beim Abdunkeln einen Farbstich bekamen, statt neutralgrau zu werden.", + "Pentax raws without black borders": "Pentax-RAW-Dateien ohne schwarze Ränder", + "Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer": "RAW-Dateien der Pentax KP, KF, K-3 Mark III (und Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 und 645Z zeigen keinen", + "show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is": "schwarzen (bei der 645Z weißen) Streifen mehr an einem oder mehreren Rändern (Issue #1271). Der maskierte Rand des Sensors wird", + "left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos": "weggelassen, sodass diese Fotos etwas kleiner und näher am eigenen JPEG der Kamera ausfallen. Zuschnitte und Masken auf früher", + "edited earlier may shift by a few pixels.": "bearbeiteten Fotos können sich um ein paar Pixel verschieben.", + "Camera-look deep shadows": "Tiefe Schatten im Kameralook", + "The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307).": "Der aus dem Kamera-JPEG einer RAW-Datei angepasste Ausgangslook nutzt jetzt auch dessen dunkelste Töne (Issue #1307).", + "Photos with deep shadows no longer have to extend the first measured tone in a straight line to black.": "Fotos mit tiefen Schatten müssen den ersten gemessenen Tonwert nicht mehr geradlinig bis Schwarz verlängern.", + "Existing smart previews are refreshed when their originals are online, and cached renders are redrawn.": "Vorhandene Smart-Vorschauen werden aufgefrischt, wenn ihre Originale verfügbar sind, und zwischengespeicherte Renderings neu gezeichnet.", + "Slider drags follow the pointer more closely": "Reglerbewegungen folgen dem Mauszeiger enger", + "With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later,": "Mit einer GPU wird die Vorschau beim Ziehen eines Reglers im selben Frame gezeichnet, in dem sich der Regler bewegt, statt ein Frame später,", + "when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as": "wenn die Vorschau in wenigen Millisekunden rendert (es wird höchstens 40 % eines Frames darauf gewartet; auf langsamere Renderings, etwa", + "on the CPU, are not waited for).": "auf der CPU, wird nicht gewartet).", + "Kelvin white balance for more Sony cameras": "Kelvin-Weißabgleich für weitere Sony-Kameras", + "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too,": "RAW-Dateien von Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 und ILCE-7S zeigen den Weißabgleich jetzt ebenfalls in Kelvin,", + "read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads": "gelesen aus den Weißabgleichsvorgaben, die die Kamera in jeder ARW-Datei speichert: Ein Foto, das an der Kamera mit 3400 K aufgenommen wurde, zeigt", + "3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative": "3396 K, eines mit der Vorgabe „Schatten“ 7493 K. Wie bei #730 ändern sich nur die Zahlen: Mit der alten relativen", + "scale keep their look, and As Shot shows the real temperature once the file is opened.": "Skala vorgenommene Bearbeitungen behalten ihren Look, und „Wie Aufnahme“ zeigt die echte Temperatur, sobald die Datei geöffnet wird.", + "Bold text in other scripts": "Fettschrift in anderen Schriftsystemen", + "Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text": "Arabische Namen und anderer arabischer Text in Überschriften, ausgewählten Tabs und anderen fetten Stellen werden jetzt fett gezeichnet, wie der lateinische Text", + "beside them is; they used to stay at regular weight.": "daneben; bisher blieben sie normal.", + "Older raw cameras": "Ältere RAW-Kameras", + "neutral default.": "neutralen Standardwert.", + "the shadows: the black level recorded in the file is now subtracted.": "den Tiefen: Der in der Datei gespeicherte Schwarzwert wird jetzt abgezogen.", + "Told when there is no GPU": "Hinweis, wenn keine GPU da ist", + "When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead": "Ist GPU-Rendering eingeschaltet, aber kein Grafikprozessor zu finden, meldet LightCraft das jetzt einmal beim Start, statt", + "when a `cargo` build runs outside `nix develop` (no Vulkan loader); start it from there.": "wenn ein `cargo`-Build außerhalb von `nix develop` läuft (kein Vulkan-Loader); starte ihn von dort.", + "Leaf and Hasselblad raws": "RAW-Dateien von Leaf und Hasselblad", + "The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool,": "Die RAW-Dateien der Hasselblad Lunar (im Inneren eine Sony NEX-7) werden jetzt aus ihren Sensordaten mit allen Bearbeitungswerkzeugen entwickelt,", + "An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports": "Ein unkomprimiertes Leaf Aptus 22 MOS öffnet sich nicht mehr als dunkles, farbloses Bild seines Sensormosaiks: Es wird", + "as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do.": "als RAW-Datei importiert und zeigt seine eingebettete Vorschau (nur Vorschau, mit Begründung), wie die komprimierten MOS-Dateien.", + "Zoomed 64 MP raws stay decoded": "Gezoomte 64-MP-RAW-Dateien bleiben dekodiert", + "Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on": "Das Verschieben eines auf 1:1 gezoomten Fotos oder das Bewegen eines Reglers beim Zoomen dekodiert nicht mehr jedes Mal die ganze RAW-Datei neu, bei", + "high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the": "hochauflösenden Kameras wie der Sony A7R VI (64 MP). Ihr dekodiertes Original (~800 MB) ist größer als der", + "default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail": "Standardanteil des Caches (768 MB) und wurde daher sofort nach dem Speichern verdrängt. Das Original des in Detail angezeigten Fotos", + "now stays, like its preview, until another photo or view is shown.": "bleibt jetzt wie seine Vorschau erhalten, bis ein anderes Foto oder eine andere Ansicht gezeigt wird.", + "Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy": "Das Hineinzoomen mit Vorher/Nachher nebeneinander dekodiert das Original einmal: Die Vorher-Seite dekodierte bisher ihre eigene Kopie", + "beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling)": "neben der der Nachher-Seite, und ein Fenster, das erneut angefordert wurde, während das erste noch dekodierte (ein Verschieben, das Layout beim Einpendeln),", + "started yet another decode.": "startete eine weitere Dekodierung.", + "A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no": "Eine gezoomte Ansicht behält die GPU-Kopie des Originals, solange es zu sehen ist: Ein paar Sekunden Pause zwischen zwei Verschiebungen", + "longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed": "geben sie nicht mehr frei, was beim nächsten Verschieben ein erneutes Hochladen von etwa einer Sekunde kostete (800 MB bei einer 64-MP-RAW-Datei). Sie wird freigegeben,", + "once you zoom out or move on.": "sobald du herauszoomst oder weitergehst.", + "The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share": "Das behaltene Original verdrängt nicht mehr alles andere aus dem Cache: Bei einer 64-MP-RAW-Datei füllte es den Cache-Anteil", + "alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every": "allein, sodass die Vorschau- und Miniaturquellen des Fotos und die seiner Nachbarn bei jeder", + "edit at 1:1, every zoom out and every step to the next photo.": "Bearbeitung in 1:1, jedem Herauszoomen und jedem Schritt zum nächsten Foto erneut aus der RAW-Datei dekodiert wurden.", + "2026-10-11": "11.10.2026", + "Keywords panel": "Stichwörter-Bedienfeld", + "The titles **Keywords** and **Keyword List** in the Keywords panel fold their sections shut and open again, so": "Die Titel **Stichwörter** und **Stichwortliste** im Stichwörter-Bedienfeld klappen ihre Abschnitte zu und wieder auf, sodass", + "the list of every keyword is one click away however many keywords the selected photos have. What is folded is": "die Liste aller Stichwörter nur einen Klick entfernt ist, egal wie viele Stichwörter die ausgewählten Fotos haben. Was zugeklappt ist, wird", + "remembered.": "gespeichert.", + "the keyword they are inside: \"Places › Italy\" once, on a line of its own, with Rome, Venice and the others side by": "dem Stichwort, in dem sie liegen: „Orte › Italien“ einmal, in einer eigenen Zeile, mit Rom, Venedig und den anderen nebeneinander", + "side under it. A long list stops after eight lines with \"+N more\", and the suggestions say only the keyword's own": "darunter. Eine lange Liste endet nach acht Zeilen mit „+N weitere“, und die Vorschläge nennen nur den eigenen Namen des Stichworts.", + "name. Whole names, one chip per keyword, stays the default.": "„Vollständige Namen“, ein Chip pro Stichwort, bleibt die Vorgabe.", + "A selection without keywords now says \"No keywords yet\" where its chips would be.": "Eine Auswahl ohne Stichwörter zeigt jetzt „Noch keine Stichwörter“ an der Stelle ihrer Chips.", + "Typing in **Add keyword** offers the library's matching keywords right under the box. ↓ and Return (or a click)": "Beim Tippen in **Stichwort hinzufügen** werden die passenden Stichwörter der Bibliothek direkt unter dem Feld angeboten. ↓ und Eingabetaste (oder ein Klick)", + "give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed.": "vergeben das angebotene Stichwort und lassen den Cursor für das nächste im Feld; die Eingabetaste allein fügt weiterhin hinzu, was du getippt hast.", + "A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer": "Ein Name, den die Bibliothek nicht kennt, wird vor dem Drücken der Eingabetaste als neues Stichwort markiert, sodass ein Tippfehler nicht mehr", + "becomes a keyword unnoticed. The suggestions further down stay put while you type.": "unbemerkt zum Stichwort wird. Die Vorschläge weiter unten bleiben beim Tippen an ihrem Platz.", + "With several photos selected, the Keywords panel says how many photos its keywords belong to. The × on a keyword": "Bei mehreren ausgewählten Fotos zeigt das Stichwörter-Bedienfeld, wie vielen Fotos seine Stichwörter gehören. Das × an einem Stichwort", + "is a larger target that lights up on its own, and taking a keyword off several photos at once now says which": "ist eine größere Zielfläche, die für sich allein aufleuchtet, und das Entfernen eines Stichworts von mehreren Fotos auf einmal meldet jetzt, welches", + "HDR and panorama merges of CR2 keep their colour": "HDR- und Panorama-Zusammenfügungen von CR2 behalten ihre Farbe", + "Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and": "„Fotos zusammenfügen“ bei Canon-CR2 (und anderen RAW-Dateien, deren Farbe LightCraft aus dem eigenen JPEG der Kamera anpasst, etwa NEF und", + "ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come": "ARW) gibt dem zusammengefügten DNG die Farbe, die jedes Foto einzeln geöffnet hat (Issue #965). Eine HDR-Zusammenfügung von CR2-Dateien wurde bisher", + "out green and dark.": "grün und dunkel.", + "\"Rendering…\" on the photo": "„Wird gerendert…“ auf dem Foto", + "The loupe says \"Rendering…\" at the bottom of the photo when what it shows isn't the finished photo yet: the": "Die Lupenansicht zeigt unten auf dem Foto „Wird gerendert…“, solange noch nicht das fertige Foto zu sehen ist: das", + "camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame": "eingebettete JPEG der Kamera, während eine RAW-Datei dekodiert wird, das vorherige Rendering, während eine Bearbeitung rendert, oder das vergrößerte Bild,", + "while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it.": "während das 1:1-Detail unterwegs ist. Der Hinweis erscheint erst nach einer halben Sekunde, sodass schnelle Renderings ihn nie zeigen.", + "If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has": "Kann LightCraft ein Foto nicht öffnen (sein Original liegt auf einem nicht angeschlossenen Laufwerk oder die Datei ist beschädigt), hat aber", + "its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo (\"Original offline · no": "sein eingebettetes JPEG oder eine Miniatur zum Anzeigen, sagt die Lupenansicht das jetzt unten auf dem Foto („Original offline · keine", + "smart preview\" or \"This photo can't be opened\", the reason on hover) instead of showing that preview as if it were": "Smart-Vorschau“ oder „Dieses Foto kann nicht geöffnet werden“, der Grund beim Darüberfahren), statt diese Vorschau anzuzeigen, als wäre sie", + "the photo.": "das Foto.", + "`--import ` takes HEIC": "`--import ` nimmt HEIC", + "`lightcraft-cli run --import ` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW": "`lightcraft-cli run --import ` (und die MCP-Werkzeuge, die Ordner nehmen) erfassen jetzt HEIC-, HEIF- und Nikon-NRW-", + "files, the same files as `library.import` (issue #1080). They used to be skipped without a word.": "Dateien, dieselben Dateien wie `library.import` (Issue #1080). Sie wurden bisher kommentarlos übersprungen.", + "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "Die Bereiche von Vergleichen und Referenzansicht zeigen „Wird gerendert…“ (oder dass das Foto nicht geöffnet werden kann) auf dieselbe Weise, sodass eine", + "magnified preview isn't taken for the photo when checking focus at 1:1.": "vergrößerte Vorschau beim Prüfen der Schärfe in 1:1 nicht für das Foto gehalten wird.", + "Lens corrections": "Objektivkorrekturen", + "Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file,": "RAW-Dateien der Sony A7R VI (ILCE-7RM6) werden anhand der Tabelle, die die Kamera in jeder Datei speichert, um die Objektivverzeichnung korrigiert,", + "correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at": "Kamerakorrektur aufgenommen wurden (24–70 mm, 100 mm Makro, 200–600 mm), deckt sich das Rendering mit dem JPEG der Kamera auf 0,68 px genau bei", + "1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read": "1440 px in den Ecken, wo es bisher bis zu 17 px abwich. Die Tabelle der Kamera für den lateralen Farbfehler wird", + "too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made": "ebenfalls gelesen, aber standardmäßig nicht angewendet: Unser Demosaicing verbirgt den Großteil dieser Farbsäume bereits, und die Tabelle obendrauf machte", + "it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This": "es etwas schlechter. Die Vignettierungstabelle wird gelesen, aber nicht angewendet: Die eigenen JPEGs der Kamera wenden sie auch nicht an. Das", + "covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion": "gilt für verlustfreie, komprimierte und komprimierte HQ-ARWs (ARW 6.0) gleichermaßen. Die A7R IVA (ILCE-7RM4A) hat eine eigene Verzeichnungs-", + "correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until": "korrektur, geprüft an Exporten von Sony; andere Sony-Modelle speichern dieselben Tabellen, bleiben aber unkorrigiert, bis", + "at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`.": "ihre Importeinstellungen haben). Siehe `docs/sony-ilce7rm6-lens-corrections.md`.", + "The thumbnail cache is no longer taken for your photos": "Der Miniaturbild-Cache gilt nicht mehr als deine Fotos", + "LightCraft's cached thumbnails no longer show up in Windows Photos / Gallery or other photo apps as pictures of": "Die zwischengespeicherten Miniaturen von LightCraft erscheinen nicht mehr in Windows Fotos / Galerie oder anderen Foto-Apps als Bilder von", + "yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually": "dir (Issues #610, #1066). Sie waren gewöhnliche `.jpg`-Dateien im Ordner `thumbs` der Bibliothek, der meist", + "inside Pictures, so every thumbnail (one for each photo, size and state of its edits) was listed as a photo.": "unter „Bilder“ liegt, sodass jede Miniatur (eine pro Foto, Größe und Bearbeitungsstand) als Foto aufgeführt wurde.", + "They are now kept under a name that is no picture's. The ones already there are taken over, not rendered again:": "Sie liegen jetzt unter einem Namen, der zu keinem Bild gehört. Vorhandene werden übernommen, nicht neu gerendert:", + "the first time a library opens after this update, \"Tidying the thumbnail cache\" shows in the activity stack": "Beim ersten Öffnen einer Bibliothek nach diesem Update erscheint „Miniaturcache wird aufgeräumt“ im Aktivitätsstapel,", + "while they are renamed (it can be stopped, and goes on at the next open).": "während sie umbenannt werden (das lässt sich anhalten und geht beim nächsten Öffnen weiter).", + "Opening a library no longer rewrites the date of every thumbnail it shows, which made them all look new at each": "Das Öffnen einer Bibliothek schreibt nicht mehr das Datum jeder angezeigten Miniatur neu, wodurch sie bei jedem", + "launch. Reading a thumbnail writes nothing (but for one that went unused for a week, whose date is brought": "Start alle neu wirkten. Das Lesen einer Miniatur schreibt nichts (außer bei einer, die eine Woche ungenutzt war: Deren Datum wird", + "forward); what was read is remembered for the session, so a full cache still lets go of what is not in use first.": "vorgezogen); was gelesen wurde, merkt sich die Sitzung, sodass ein voller Cache weiterhin zuerst freigibt, was nicht in Gebrauch ist.", + "A `thumbs` folder that holds nothing but the cache says that it is one (`CACHEDIR.TAG`), so backup programs that": "Ein `thumbs`-Ordner, der nur den Cache enthält, kennzeichnet sich als solcher (`CACHEDIR.TAG`), sodass Backup-Programme, die", + "go by that can leave it out. It is safe to delete while LightCraft is closed, as before. A `thumbs` folder that": "sich danach richten, ihn auslassen können. Er kann wie bisher gelöscht werden, während LightCraft geschlossen ist. Ein `thumbs`-Ordner, der", + "also holds files of yours (a library opened on a photo folder that had one) is not tagged, and your files in it": "auch eigene Dateien enthält (eine Bibliothek, die auf einem Fotoordner geöffnet wurde, der einen hatte), wird nicht gekennzeichnet, und deine Dateien darin", + "are left as they are. Note that files there named exactly like the cache's own (`ab/ab…` with 32 hex digits and": "bleiben, wie sie sind. Beachte: Dateien dort, die genau wie die des Caches benannt sind (`ab/ab…` mit 32 Hex-Ziffern und", + "`.jpg`) have always been taken for the cache's, and are renamed with it.": "`.jpg`), wurden schon immer für Cache-Dateien gehalten und werden mit umbenannt.", + "If you go back to an older version, it renders its thumbnails again as `.jpg`. This version then takes those": "Kehrst du zu einer älteren Version zurück, rendert sie ihre Miniaturen wieder als `.jpg`. Diese Version übernimmt diese dann", + "over one by one as they are used; the rest stay until the cache makes room.": "einzeln, sobald sie verwendet werden; der Rest bleibt, bis der Cache Platz schafft.", + "of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914). The": "im Hochformat): Maskierte Sensorspalten wurden als Teil des Bildes entwickelt (Issues #976, #914). Die", + "D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X,": "D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X,", + "D60, D80 and D2Xs now show the area the camera itself frames, a few pixels smaller than before.": "D60, D80 und D2Xs zeigen jetzt den Bereich, den die Kamera selbst einrahmt, ein paar Pixel kleiner als bisher.", + "JPEG XL export": "JPEG-XL-Export", + "(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a": "(`render in.raw -o out.jxl`). Qualität wie bei JPEG oder verlustfrei; der Aufwand (Schnell / Normal) tauscht Zeit gegen eine", + "smaller file; Progressive files show a coarse version first. 8- or 16-bit, sRGB, Display P3 or Rec. 2020 (lossless": "kleinere Datei; progressive Dateien zeigen zuerst eine grobe Fassung. 8 oder 16 Bit, sRGB, Display P3 oder Rec. 2020 (verlustfreie", + "files also Adobe RGB and ProPhoto), metadata as for JPEG. With HDR output, photos edited in HDR are written as": "Dateien auch Adobe RGB und ProPhoto), Metadaten wie bei JPEG. Mit HDR-Ausgabe werden in HDR bearbeitete Fotos als", + "16-bit Rec. 2020 PQ. Not in the web version yet.": "16-Bit-Rec.-2020-PQ geschrieben. Noch nicht in der Webversion.", + "Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in": "Verlustbehaftete JPEG-XL-Dateien in Display P3 oder Rec. 2020 öffnen jetzt in ihren echten Farben; gesättigte Farben wirkten bisher", + "noticeably duller.": "deutlich stumpfer.", + "Export next to the originals": "Export neben die Originale", + "The Export dialog has Export to: Specific folder (as before) or Same folder as original photo, like Lightroom": "Der Exportdialog bietet unter „Exportieren nach“: Bestimmter Ordner (wie bisher) oder Im selben Ordner wie das Originalfoto, wie Lightroom", + "Classic (issue #956). With the second, each photo is exported into the folder its original is in, plus the": "Classic (Issue #956). Mit der zweiten Option wird jedes Foto in den Ordner seines Originals exportiert, plus", + "Subfolder if you give one (say `Exports`), so a batch from several folders lands next to each set of originals.": "Unterordner, falls du einen angibst (etwa `Exports`), sodass ein Stapel aus mehreren Ordnern neben dem jeweiligen Satz Originale landet.", + "\"If file exists\" works per folder as usual, and an original is never written over.": "„Bei vorhandener Datei“ wirkt wie üblich pro Ordner, und ein Original wird nie überschrieben.", + "A photo that has no folder on disk (a demo photo, or one in the web version's browser storage) or whose folder": "Ein Foto ohne Ordner auf dem Datenträger (ein Demofoto oder eines im Browserspeicher der Webversion) oder dessen Ordner", + "is missing (moved, drive unplugged) is not exported; it gets its own error naming it.": "fehlt (verschoben, Laufwerk getrennt), wird nicht exportiert; es erhält einen eigenen Fehler, der es nennt.", + "The choice is saved in export presets and Export with Previous. Agents pass": "Die Wahl wird in Exportvorgaben und in „Mit vorherigen Einstellungen exportieren“ gespeichert. Agenten übergeben", + "`destinationMode: \"sameAsOriginal\"` (or `\"specificFolder\"`) to `app.export`; `dir` is then not needed.": "`destinationMode: \"sameAsOriginal\"` (oder `\"specificFolder\"`) an `app.export`; `dir` ist dann nicht nötig.", + "Open Library knows a Lightroom catalog": "„Bibliothek öffnen“ erkennt einen Lightroom-Katalog", + "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.", + "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.", + "The open library's name shows under My Photos at the top of the left panel and in the window title.": "Der Name der geöffneten Bibliothek erscheint unter „Meine Fotos“ oben in der linken Leiste und im Fenstertitel.", + "`lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with": "`lightcraft-cli --library` mit der eigenen Datei einer Bibliothek (`catalog.snap`) öffnet deren Ordner, statt mit", + "\"File exists\".": "„File exists“ zu scheitern.", + "Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`.": "Agenten verwenden `app.newLibrary {path, name}`, `app.recentLibraries` und `app.clearRecentLibraries`.", + "with the original and opens it in PhotoCraft. LightCraft finds PhotoCraft where its installer put it (Windows,": "mit dem Original und öffnet sie in PhotoCraft. LightCraft findet PhotoCraft dort, wo sein Installationsprogramm es abgelegt hat (Windows,", + "macOS, Linux packages, AppImage, Flatpak), and says so if it isn't installed, before making a copy.": "macOS, Linux-Pakete, AppImage, Flatpak), und meldet vor dem Erstellen einer Kopie, wenn es nicht installiert ist.", + "LightCraft the copy shows what you saved.": "LightCraft zurück, zeigt die Kopie, was du gespeichert hast.", + "the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy.": "den anderen Editor („In anderem Editor bearbeiten“, jetzt ⌥⌘E) sowie der Ort von PhotoCraft, falls du eine portable Kopie verwendest.", + "Export can write PSD too (flat, 8- or 16-bit).": "Der Export kann jetzt auch PSD schreiben (flach, 8 oder 16 Bit).", + "Faster GPU exports": "Schnellere GPU-Exporte", + "Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU,": "Exporte mit Rauschreduzierung, Struktur, Klarheit, Dunst entfernen oder Lichter und Tiefen können auf der GPU deutlich schneller sein,", + "depending on the graphics card. On a Windows laptop with an AMD Radeon 780M, a full-size export of a 24 MP raw": "je nach Grafikkarte. Auf einem Windows-Laptop mit AMD Radeon 780M sank ein Export in voller Größe einer 24-MP-RAW-Datei", + "with those edits went from about 3.7 s to 1.9 s. Photos look the same as before.": "mit diesen Bearbeitungen von etwa 3,7 s auf 1,9 s. Die Fotos sehen aus wie vorher.", + "Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed": "Ein Upgrade von einem Windows-Installationsprogramm älter als 0.6 (etwa 0.5.0) belässt LightCraft in dem Ordner, in den es installiert", + "in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade": "wurde, statt es zurück nach „Programme“ zu verschieben (Issue #399). Wurde dieser Ordner inzwischen gelöscht, installiert das Upgrade", + "installs to Program Files.": "nach „Programme“.", + "A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails": "Ein Foto, das beim Rendern auf einen Fehler in LightCraft stößt (in einem RAW-Decoder oder der Bearbeitungspipeline), schlägt jetzt", + "like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too.": "wie ein nicht zu öffnendes Foto mit Begründung fehl, statt endlos zu rendern; auch seine Miniatur wartet nicht mehr.", + "Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until": "Jeder solche Fehler riss bisher einen der Render-Threads mit sich, sodass nach ein paar davon kein Foto mehr gerendert wurde, bis", + "Library sorting": "Sortierung der Bibliothek", + "Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by": "Fotos mit gleichem Sortierschlüssel (gleiche Bewertung, ein Importstapel, eine Serie innerhalb derselben Sekunde) werden jetzt nach", + "file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a": "Dateiname und dann ID sortiert statt nach Importreihenfolge. „Zuletzt hinzugefügt“ hält einen Stapel in Dateinamen-Reihenfolge, und Auto-Stapeln ordnet eine", + "burst by file name, so the first file is the cover.": "Serie nach Dateiname, sodass die erste Datei das Titelbild ist.", + "Large preset collections": "Große Vorgabensammlungen", + "A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB": "Eine Bibliothek mit Tausenden von Vorgaben (eine vollständig importierte Lightroom-Sammlung: 11.000 Vorgaben, eine 42 MB große", + "presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed": "presets.json) bleibt reaktionsschnell. Jeder Befehl serialisierte bisher die ganze Liste neu, um zu prüfen, ob sie sich geändert hatte", + "(~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms.": "(~0,6 s pro Reglerschritt); jetzt wird die Liste nur nach einer Änderung gespeichert, und ein Reglerschritt dauert ~2 ms.", + "The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the": "Das Bedienfeld „Vorgaben“ baut nur die sichtbaren Zeilen (und ihre Miniaturen) statt in jedem Frame jede Vorgabe auf; die", + "Importing a large collection and opening its library no longer compare every preset with every other.": "Das Importieren einer großen Sammlung und das Öffnen ihrer Bibliothek vergleichen nicht mehr jede Vorgabe mit jeder anderen.", + "Slideshow export: PDF, JPEG and video": "Diashow-Export: PDF, JPEG und Video", + "Lightroom Classic's Export PDF, Export JPEG and Export Video: the photos in view as their slides, with the layout,": "„PDF exportieren“, „JPEG exportieren“ und „Video exportieren“ von Lightroom Classic: die Fotos der Ansicht als ihre Dias, mit Layout,", + "overlays and the intro and ending screens, in view order.": "Überlagerungen sowie Intro- und Abschlussbildschirm, in der Reihenfolge der Ansicht.", + "**PDF:** one page per slide at 1024, 1920 or 3840 pixels on the long side, at a JPEG quality you choose. Each page": "**PDF:** eine Seite pro Dia mit 1024, 1920 oder 3840 Pixeln an der langen Seite, in einer wählbaren JPEG-Qualität. Jede Seite", + "carries the slide duration and fade, so a PDF viewer's presentation mode plays it as a slideshow, and \"Automatically": "enthält Dia-Dauer und Überblendung, sodass der Präsentationsmodus eines PDF-Betrachters sie als Diashow abspielt, und „Automatisch", + "show full screen\" asks the viewer to open it that way.": "im Vollbild anzeigen“ bittet den Betrachter, sie so zu öffnen.", + "**JPEG:** numbered files (`Holiday-001.jpg`, `Holiday-002.jpg`…) in a new folder.": "**JPEG:** nummerierte Dateien (`Holiday-001.jpg`, `Holiday-002.jpg`…) in einem neuen Ordner.", + "**Video:** AV1 video in an MP4 (or WebM) file at 720p or 1080p, 24 or 30 frames per second: each slide stays up for": "**Video:** AV1-Video in einer MP4- (oder WebM-)Datei in 720p oder 1080p, 24 oder 30 Bilder pro Sekunde: Jedes Dia bleibt für", + "its duration and the fades are rendered frame by frame (crossfades, or fades through the fade colour). The video has": "seine Dauer stehen, und die Überblendungen werden Bild für Bild gerendert (Überblendungen oder Blenden über die Blendenfarbe). Das Video hat", + "no sound yet. It is AV1 rather than Lightroom's H.264, which has no open encoder we can ship; current browsers,": "noch keinen Ton. Es ist AV1 statt H.264 wie bei Lightroom, für das es keinen offenen Encoder gibt, den wir ausliefern können; aktuelle Browser,", + "VLC and recent phones and TVs play it.": "VLC sowie neuere Smartphones und Fernseher spielen es ab.", + "The export runs in the background with its row in the activity stack; ✕ stops it and leaves no half-written file": "Der Export läuft im Hintergrund mit einer Zeile im Aktivitätsstapel; × hält ihn an und hinterlässt keine halb geschriebene Datei.", + "behind. A photo that can't be read is skipped and named in the message at the end. Agents use `slideshow.export`": "Ein Foto, das nicht gelesen werden kann, wird übersprungen und in der Meldung am Ende genannt. Agenten verwenden `slideshow.export`", + "(`slideshow.exportStatus` for the result).": "(`slideshow.exportStatus` für das Ergebnis).", + "Slideshow templates": "Diashow-Vorlagen", + "everything about how a slideshow looks and plays: the playback timing and fades, the layout, options and backdrop,": "alles, wie eine Diashow aussieht und abläuft: Wiedergabetiming und Überblendungen, Layout, Optionen und Hintergrund,", + "the overlays and the intro and ending screens. Click one to apply it; point at one to see it on the selected photo in": "Überlagerungen sowie Intro- und Abschlussbildschirm. Klicke auf eine, um sie anzuwenden; zeige auf eine, um sie am ausgewählten Foto in", + "the preview beside it without changing anything.": "der Vorschau daneben zu sehen, ohne etwas zu ändern.", + "Five built-in templates of our own: Plain Black (the photo on black), Gallery Wall (a light wall, a white mat and a": "Fünf eigene integrierte Vorlagen: Schlicht schwarz (das Foto auf Schwarz), Galeriewand (eine helle Wand, ein weißes Passepartout und ein", + "soft shadow, with the caption and rating under the photo), Edge to Edge (photos cropped to fill the screen), Cinema": "weicher Schatten, mit Bildunterschrift und Bewertung unter dem Foto), Randlos (Fotos auf den ganzen Bildschirm beschnitten), Kino-", + "Wide (16:9 with black bars, the title in the corner, fades through black and a closing card) and Shooting Data (the": "Breitbild (16:9 mit schwarzen Balken, der Titel in der Ecke, Überblendungen über Schwarz und eine Abschlusskarte) und Aufnahmedaten (Belichtung,", + "exposure, ISO, focal length, camera and lens under each photo on a dark backdrop).": "ISO, Brennweite, Kamera und Objektiv unter jedem Foto auf dunklem Hintergrund).", + "Save the current settings as a template of your own; right-click it to update it with the current settings, rename,": "Speichere die aktuellen Einstellungen als eigene Vorlage; ein Rechtsklick darauf aktualisiert sie mit den aktuellen Einstellungen, benennt sie um,", + "export or delete it, and import templates others share with you. Your templates are `.json` files in the settings": "exportiert oder löscht sie, und du kannst Vorlagen importieren, die andere mit dir teilen. Deine Vorlagen sind `.json`-Dateien im Ordner", + "folder's `slideshow-templates` folder, shared by all your libraries.": "`slideshow-templates` des Einstellungsordners und gelten für alle deine Bibliotheken.", + "replaced are listed as \"Previous settings\" until you quit: click that to go back.": "ersetzte, als „Vorherige Einstellungen“ aufgeführt, bis du die App beendest: Ein Klick darauf stellt sie wieder her.", + "A template file that can't be used (damaged, from a newer LightCraft, too large) is listed as skipped instead of": "Eine Vorlagendatei, die nicht verwendet werden kann (beschädigt, von einem neueren LightCraft, zu groß), wird als übersprungen aufgeführt, statt", + "stopping the others, and a template whose identity plate image or background photo isn't there still applies, with a": "die anderen aufzuhalten, und eine Vorlage, deren Bild der Erkennungstafel oder Hintergrundfoto fehlt, wird trotzdem angewendet, mit einem", + "note saying what is missing. Agents use `slideshow.templates.*`, and `slideshow.preview {template}` renders a template": "Hinweis, was fehlt. Agenten verwenden `slideshow.templates.*`, und `slideshow.preview {template}` rendert eine Vorlage,", + "without applying it.": "ohne sie anzuwenden.", + "Slideshow intro and ending screens": "Intro- und Abschlussbildschirm der Diashow", + "A slideshow can now open with an intro screen and close with an ending screen, like Lightroom Classic's Titles": "Eine Diashow kann jetzt mit einem Introbildschirm beginnen und mit einem Abschlussbildschirm enden, wie im Bedienfeld „Titel“ von Lightroom Classic.", + "panel. Each is switched on on its own and has a background colour and, centred on it, your identity plate (the one": "Jeder wird für sich eingeschaltet und hat eine Hintergrundfarbe und, darauf zentriert, deine Erkennungstafel (die", + "set under Overlays: its text or image) or a line of text of its own, in the plate's colour or one you pick, at a": "unter „Überlagerungen“ festgelegte: ihr Text oder Bild) oder eine eigene Textzeile, in der Farbe der Tafel oder einer gewählten, in einer", + "scale. Each stays up for its own duration (by default the slide duration) and fades in and out like a slide.": "Skalierung. Jeder bleibt für seine eigene Dauer stehen (standardmäßig die Dia-Dauer) und wird wie ein Dia ein- und ausgeblendet.", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off (while repeating": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn „Wiederholen“ aus ist (beim Wiederholen", + "the show runs until you stop it, so the ending doesn't come up). Random order never moves them, ←/→ step across them,": "läuft die Show, bis du sie anhältst, sodass der Abschluss nicht erscheint). „Zufällige Reihenfolge“ verschiebt sie nie, ←/→ springen über sie hinweg,", + "and the control bar says Intro or End while one is up (the photo count only counts photos).": "und die Steuerleiste zeigt „Intro“ oder „Ende“, solange einer zu sehen ist (die Fotoanzahl zählt nur Fotos).", + "ending. They are saved with the library. Agents use `slideshow.titles`, and `slideshow.preview {title: \"intro\"}`": "Abschluss. Sie werden mit der Bibliothek gespeichert. Agenten verwenden `slideshow.titles`, und `slideshow.preview {title: \"intro\"}`", + "renders a screen as a PNG.": "rendert einen Bildschirm als PNG.", + "Slideshow overlays": "Diashow-Überlagerungen", + "Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an": "Dias können jetzt die Diashow-Überlagerungen von Lightroom Classic tragen: eine Erkennungstafel (eine Textzeile in einer Farbe oder eine", + "image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the": "Bilddatei wie ein PNG-Logo mit Transparenz, in einer Größe und Deckkraft, wahlweise hinter das Foto gezeichnet), die", + "photo's rating as stars, and any number of text overlays (up to 16) built from tokens: `{caption}`, `{title}`,": "Bewertung des Fotos als Sterne und beliebig viele Textüberlagerungen (bis zu 16) aus Platzhaltern: `{caption}`, `{title}`,", + "`{file}`, `{date}`, `{exposure}` (\"1/250 s at f/8\"), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` and the": "`{file}`, `{date}`, `{exposure}` („1/250 s bei f/8“), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` und die", + "other file-naming tokens, with a size, colour, opacity and an optional soft shadow (opacity, offset, radius, angle).": "übrigen Platzhalter der Dateibenennung, mit Größe, Farbe, Deckkraft und optionalem weichem Schatten (Deckkraft, Versatz, Radius, Winkel).", + "Each sits at one of nine anchors of the slide or of the photo, moved by an offset, and never leaves the slide.": "Jede sitzt an einem von neun Ankerpunkten des Dias oder des Fotos, um einen Versatz verschoben, und verlässt das Dia nie.", + "The export watermark can be drawn on every slide's photo too (\"Use export watermark\" takes the one you last": "Das Export-Wasserzeichen kann auch auf das Foto jedes Dias gezeichnet werden („Export-Wasserzeichen verwenden“ nimmt das zuletzt", + "exported with).": "beim Export verwendete).", + "share of the slide, so slides look the same on any screen. They are saved with the library. Agents use": "Anteil am Dia, sodass Dias auf jedem Bildschirm gleich aussehen. Sie werden mit der Bibliothek gespeichert. Agenten verwenden", + "`slideshow.overlays`; `slideshow.preview` now also returns where each overlay landed.": "`slideshow.overlays`; `slideshow.preview` liefert jetzt auch, wo jede Überlagerung gelandet ist.", + "Slideshow layout, options and backdrop": "Layout, Optionen und Hintergrund der Diashow", + "Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four": "Dias lassen sich jetzt wie mit den Bedienfeldern „Layout“, „Optionen“ und „Hintergrund“ der Diashow von Lightroom Classic gestalten: Ränder (alle vier", + "linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width),": "verknüpft oder jeder einzeln), „Zoomen, um Rahmen zu füllen“ (das Foto wird beschnitten, um seine Zelle zu füllen), eine Konturlinie (Farbe, Breite),", + "a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a": "ein Schlagschatten (Deckkraft, Versatz, Radius, Winkel) und eine Hintergrundfarbe mit optionalem Farbverlauf (ein Verlauf aus einer", + "second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity).": "zweiten Farbe, in einem Winkel und einer Deckkraft) und ein Hintergrundbild (ein Foto aus der Bibliothek, in einer Deckkraft).", + "guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any": "Hilfslinien, im Seitenverhältnis des Bildschirms oder in 16:9 / 4:3). Größen sind ein Anteil am Dia, sodass ein Dia auf jedem", + "screen. They are saved with the library.": "Bildschirm gleich aussieht. Sie werden mit der Bibliothek gespeichert.", + "controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as": "Wiedergabesteuerung funktionieren wie bisher. Mit dem Standardlayout (keine Ränder, Konturen, Schatten oder Hintergründe) spielt sie die Fotos wie", + "before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG.": "bisher ab. Agenten verwenden `slideshow.layout`, und `slideshow.preview` liefert ein gestaltetes Dia als PNG.", + "Slideshow playback": "Diashow-Wiedergabe", + "between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per": "zwischen den Dias (eine Überblendung oder über Schwarz, Weiß oder eine beliebige Farbe; 0 schneidet hart), zufällige Reihenfolge (jedes Foto einmal pro", + "pass, a new order each time round), repeat (off: the slideshow ends after the last photo) and preparing previews in": "Durchlauf, jede Runde eine neue Reihenfolge), Wiederholen (aus: Die Diashow endet nach dem letzten Foto) und Vorschauen im", + "advance (the next two slides render while the current one is up, so a fade never waits or flashes).": "Voraus berechnen (die nächsten zwei Dias rendern, während das aktuelle zu sehen ist, sodass eine Überblendung nie wartet oder flackert).", + "`slideshow.playback`; `view.slideshow` takes any of them for one run (`interval` still sets the slide duration).": "`slideshow.playback`; `view.slideshow` nimmt jede davon für einen Durchlauf (`interval` setzt weiterhin die Dia-Dauer).", + "While it plays, moving the pointer shows a small control bar: previous, play / pause, next, the slide number and": "Während sie läuft, blendet eine Mausbewegung eine kleine Steuerleiste ein: zurück, Wiedergabe/Pause, weiter, die Dianummer und", + "end. Space pauses, ← / → step (a cut), Esc ends. A slide's time starts once it is shown sharp.": "Beenden. Leertaste pausiert, ← / → springen (harter Schnitt), Esc beendet. Die Zeit eines Dias beginnt, sobald es scharf zu sehen ist.", + "2026-10-10": "10.10.2026", + "Dialogs fit the window": "Dialoge passen ins Fenster", + "A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title": "Ein Dialog, der höher als das Fenster ist, ragt nicht mehr darüber hinaus (Issue #781): Auf einem 1366×768-Bildschirm lagen Titel", + "and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and": "und die Schaltflächen Abbrechen / OK des Exportdialogs außerhalb des Bildschirms, sodass er nicht bestätigt werden konnte. Jetzt scrollen seine Optionen, und Titel und", + "buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit.": "Schaltflächen bleiben sichtbar. Lange Listen in Dialogen (Neuigkeiten, Alle Metadaten, Tastenkürzel) werden passend gekürzt.", + "Dialogs that fit look as before.": "Dialoge, die passen, sehen aus wie vorher.", + "Previews match exports on Mac screens": "Vorschauen entsprechen auf Mac-Bildschirmen den Exporten", + "On macOS, photos are shown through the profile of the screen the window is on while no monitor profile is chosen in": "Unter macOS werden Fotos über das Profil des Bildschirms angezeigt, auf dem das Fenster liegt, solange unter", + "Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after": "Vorschau oder Fotos. Ein Wechsel des Fensters auf einen anderen Bildschirm wird verfolgt; **Bildschirmprofil verwenden** kehrt nach der", + "choosing a profile file.": "Wahl einer Profildatei dazu zurück.", + "Sidebar sections": "Seitenleistenbereiche", + "Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line": "Ordne die linke Seitenleiste nach deinen Wünschen: Ziehe Alben, Lokal, Nach Datum, Ordner oder Stichwörter an der Überschrift, um sie zu verschieben (eine Linie", + "shows where it will go), or right-click a header for Move Up / Move Down.": "zeigt, wohin sie kommt), oder klicke mit der rechten Maustaste auf eine Überschrift für Nach oben verschieben / Nach unten verschieben.", + "the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered.": "den Titel „Meine Fotos“, um einen zurückzuholen, oder wähle „Seitenleistenbereiche zurücksetzen“. Die Anordnung wird gespeichert.", + "In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see": "In einem langen Bereich bleibt die Überschrift oben in der Seitenleiste, während du durch seine Zeilen scrollst, sodass du immer siehst,", + "which section you are in and can fold it from there.": "in welchem Bereich du bist, und ihn von dort einklappen kannst.", + "Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows'": "Bereiche sind leichter zu unterscheiden: eine Haarlinie dazwischen, der Einklapppfeil in derselben Spalte wie die", + "triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter,": "Dreiecke der Zeilen, eine Hervorhebung unter dem Mauszeiger. Ein eingeklappter Bereich zeigt, wie viele Zeilen er enthält, Anzahlen von 0 sind blasser,", + "and a section with nothing in it says so instead of disappearing.": "und ein leerer Bereich sagt das, statt zu verschwinden.", + "Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.": "„Zuletzt gelöscht“ steht unter „Meine Fotos“ zusammen mit „Alle Fotos“, „Zuletzt hinzugefügt“ und „Ausgewählte Fotos“.", + "Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.": "Stichwörter hat wie Alben ein + in der Überschrift: Stichwort erstellen, ohne die Stichwortliste zu öffnen.", + "Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.": "Agenten: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` nimmt `sidebar`.", + "Kelvin white balance for raws whose camera has colour matrices": "Kelvin-Weißabgleich für RAW-Dateien von Kameras mit Farbmatrizen", + "Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,": "RAW-Dateien der 52 Kameramodelle, deren Farbmatrizen LightCraft gemessen hat (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,", + "Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom": "Nikon D850, Canon EOS 5D Mark III und andere), zeigen den Weißabgleich jetzt in Kelvin, wie ein DNG und wie Lightroom", + "does: As Shot reads the file's white (an ILCE-7M3 sample: 4268 K / +7), the Temp slider runs 2000–50000 K and the": "es tut: „Wie Aufnahme“ liest das Weiß der Datei (ein ILCE-7M3-Beispiel: 4268 K / +7), der Temp.-Regler reicht von 2000–50000 K, und die", + "Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices": "Vorgaben Tageslicht, Bewölkt, Schatten, Glühlampenlicht, Leuchtstofflicht und Blitz setzen echte Temperaturen (Issue #730). Die Matrizen", + "only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "rechnen nur zwischen den Multiplikatoren der Kamera und Kelvin um; der Farblook bleibt, wie er war.", + "Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as": "Nichts bereits Bearbeitetes ändert sich: Ein benutzerdefinierter Weißabgleich auf der alten relativen Skala rendert pixelgenau wie", + "before, and becomes the Kelvin value with the same look the next time it is edited. A photo imported earlier shows": "bisher und wird bei der nächsten Bearbeitung zum Kelvin-Wert mit demselben Look. Ein früher importiertes Foto zeigt", + "its real As Shot temperature once the file is opened (or Reload reads it).": "seine echte Temperatur „Wie Aufnahme“, sobald die Datei geöffnet wird (oder „Neu laden“ sie liest).", + "Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets);": "Lightrooms `crs:Temperature` wird für diese RAW-Dateien als das Kelvin gelesen, das es ist (Begleitdateien, Lightroom-Kataloge, Vorgaben);", + "raws of other cameras keep the as-shot shift of issue #510. The two Kelvin scales come from different matrices and": "RAW-Dateien anderer Kameras behalten die Verschiebung „Wie Aufnahme“ aus Issue #510. Die beiden Kelvin-Skalen stammen aus unterschiedlichen Matrizen und", + "differ by about 7–10 % on the ILCE-7M3; which one is off is still open.": "weichen bei der ILCE-7M3 um etwa 7–10 % voneinander ab; welche danebenliegt, ist noch offen.", + "Fujifilm lens corrections": "Objektivkorrekturen für Fujifilm", + "Fujifilm RAFs from 59 camera models now get the lens correction the camera stores in the file: distortion (with the": "Fujifilm-RAFs von 59 Kameramodellen erhalten jetzt die Objektivkorrektur, die die Kamera in der Datei speichert: Verzeichnung (mit dem", + "camera's own zoom to fill the frame) and vignetting, as with Panasonic and Sony files. The Optics profile controls": "kamerainternen Zoom zum Füllen des Bildes) und Vignettierung, wie bei Dateien von Panasonic und Sony. Die Profilregler unter „Optik“", + "adjust or switch them off, and DNG export keeps them. Lateral chromatic aberration data is not applied: our": "passen sie an oder schalten sie ab, und der DNG-Export behält sie. Daten zum lateralen Farbfehler werden nicht angewendet: Unser", + "demosaic already lines the colour planes up. Details and measurements: `docs/fuji-lens-corrections.md`.": "Demosaicing richtet die Farbebenen bereits aus. Details und Messungen: `docs/fuji-lens-corrections.md`.", + "and a dark theme card, each previewing its theme (dark: Charcoal, the look so far, or Midnight;": "und einer dunklen Designkarte, die jeweils ihr Design in der Vorschau zeigen (dunkel: Anthrazit, der bisherige Look, oder Mitternacht;", + "light: Silver or Paper). New and existing installs stay dark; following the system is opt-in.": "hell: Silber oder Papier). Neue und bestehende Installationen bleiben dunkel; das Folgen des Systems muss man einschalten.", + "Appearance lists the modes and themes. On Linux, Auto follows the desktop portal's colour scheme": "Erscheinungsbild listet die Modi und Designs. Unter Linux folgt Auto dem Farbschema des Desktop-Portals,", + "as it changes, without polling.": "sobald es sich ändert, ohne ständiges Abfragen.", + "Add to Apple Photos (macOS)": "Zu Apple Fotos hinzufügen (macOS)", + "The Export dialog has **Add to Apple Photos**, with an optional album. The files are exported to your folder as": "Der Exportdialog bietet **Zu Apple Fotos hinzufügen**, mit optionalem Album. Die Dateien werden wie üblich in deinen Ordner exportiert", + "usual and then added to Photos, into a top-level album of that name (made when it's missing). The toast says how": "und dann zu Fotos hinzugefügt, in ein Album dieses Namens auf oberster Ebene (das bei Bedarf angelegt wird). Die Meldung sagt, wie", + "many Photos added, or why it added none. Export with Previous and export presets repeat it.": "viele Fotos hinzugefügt wurden oder warum keines. „Mit vorherigen Einstellungen exportieren“ und Exportvorgaben wiederholen das.", + "The first time, macOS asks whether LightCraft may control Photos. Photos may ask about duplicates; files it skips": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf. Fotos fragt eventuell nach Duplikaten; übersprungene Dateien", + "show as a partial import.": "erscheinen als Teilimport.", + "Agents: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` for files already on disk, and": "Agenten: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` für Dateien, die schon auf dem Datenträger liegen, und", + "`export.photosImports`. See [Add to Apple Photos](apple-photos.md).": "`export.photosImports`. Siehe [Add to Apple Photos](apple-photos.md).", + "Activity stack": "Aktivitätsstapel", + "Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and": "Lang laufende Aufgaben erscheinen an einer Stelle, oben links unter der oberen Leiste, wie in Lightroom Classic (Issue #345): Importe und", + "folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,": "Ordnerscans, Exporte (und das Kontaktabzug-PDF), Ordner synchronisieren, Vorschauen / Smart-Vorschauen erstellen, Import von Lightroom-Katalogen, Fotos zusammenfügen, Modell-Downloads, der Gesichtsscan,", + "AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working": "KI-Entrauschen und Fehlende Fotos suchen. Jede Zeile zeigt den Namen der Aufgabe, einen Fortschrittsbalken, die Anzahl und woran", + "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "gerade gearbeitet wird; × hält die Aufgaben an, die sich anhalten lassen. Drei Zeilen sind sichtbar, dann „+N weitere“. Das separate Exportfeld sowie die Fortschritts-", + "windows and toasts are gone.": "fenster und -meldungen gibt es nicht mehr.", + "Quitting while a task that can be stopped is running asks first (\"Quit Anyway\" stops it).": "Das Beenden während einer laufenden, anhaltbaren Aufgabe fragt zuerst nach („Trotzdem beenden“ hält sie an).", + "Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`.": "Agenten listen Aufgaben mit `activity.list` auf und halten sie mit `activity.cancel` an; `ui.inspect` meldet sie als `activity`.", + "Importing a deleted photo again": "Ein gelöschtes Foto erneut importieren", + "Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums": "Das Importieren einer Datei, deren Foto in „Zuletzt gelöscht“ liegt, holt das Foto mit seinen Bearbeitungen, seiner Bewertung und seinen Alben zurück", + "(issue #706; before, the import skipped it as \"already in the library\" while the grid showed it deleted). The": "(Issue #706; bisher übersprang der Import es als „bereits in der Bibliothek“, während das Raster es als gelöscht zeigte). Die", + "import review checks such files, marks them \"Will be restored\" and counts them as \"in Recently Deleted\"; the": "Importprüfung prüft solche Dateien, markiert sie mit „Wird wiederhergestellt“ und zählt sie als „in Zuletzt gelöscht“; die", + "choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window": "Auswahl daneben (Wiederherstellen / Neu importieren / Beibehalten) steht jetzt anfangs auf Wiederherstellen. Auf das Fenster gezogene Dateien", + "restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "stellen das Foto ebenfalls wieder her und melden „1 Foto aus Zuletzt gelöscht wiederhergestellt“. Ein gelöschtes Foto, dessen Datei verschoben wurde, wird", + "relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import": "mit der Datei neu verknüpft, aus der es importiert wird. Das Durchsuchen eines Ordners unter „Lokal“, der automatische Import und der Import eines Lightroom-Katalogs", + "never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old": "stellen nie etwas wieder her. Agenten: `library.import` → `onDeleted` ist standardmäßig `restore` (`skip` behält das alte", + "behaviour, `fresh` imports a clean copy and deletes the old record).": "Verhalten, `fresh` importiert eine saubere Kopie und löscht den alten Eintrag).", + "Formats": "Formate", + "File extensions match whatever their letter case: `IMG_0001.JPG` and `DSC_0002.NEF` as Nikon writes them, `.Jpg`": "Dateiendungen passen unabhängig von ihrer Groß-/Kleinschreibung: `IMG_0001.JPG` und `DSC_0002.NEF`, wie Nikon sie schreibt, `.Jpg`", + "after a rename, an `.XMP` sidecar. Adding photos or a folder, Browse Folder (Local), drag-and-drop, the CLI and the": "nach einer Umbenennung, eine `.XMP`-Begleitdatei. Das Hinzufügen von Fotos oder eines Ordners, „Ordner durchsuchen“ (Lokal), Drag & Drop, die Befehlszeile und die", + "sidecar lookup always did; the Add Photos dialog on Linux did not until 0.5.0 (GTK and the desktop portal match": "Suche nach Begleitdateien taten das schon immer; der Dialog „Fotos hinzufügen“ unter Linux erst ab 0.5.0 (GTK und das Desktop-Portal vergleichen", + "`*.jpg` case-sensitively, so `.JPG` and `.NEF` files were hidden — issues #342 and #705).": "`*.jpg` mit Beachtung der Groß-/Kleinschreibung, sodass `.JPG`- und `.NEF`-Dateien ausgeblendet waren – Issues #342 und #705).", + "Closing a library releases its lock explicitly (issue #680). On Linux and macOS the lock used to go with the file": "Das Schließen einer Bibliothek gibt ihre Sperre ausdrücklich frei (Issue #680). Unter Linux und macOS hing die Sperre bisher am Datei-", + "handle, and a program being started by another thread at that moment held a copy of the handle until it was fully": "Handle, und ein Programm, das in diesem Moment von einem anderen Thread gestartet wurde, hielt eine Kopie des Handles, bis es vollständig", + "launched, so reopening the library straight away could be refused with \"This library is already open in another": "gestartet war, sodass ein sofortiges erneutes Öffnen der Bibliothek mit „Diese Bibliothek ist bereits in einem anderen", + "LightCraft program\" although nothing had it open. Nothing in the shipped app is known to hit the window today; it": "LightCraft-Programm geöffnet“ abgelehnt werden konnte, obwohl sie niemand geöffnet hatte. In der ausgelieferten App ist derzeit nichts bekannt, das dieses Zeitfenster trifft; es", + "made a lock test flaky, and the fix closes the window for good.": "machte einen Sperrtest unzuverlässig, und die Korrektur schließt das Zeitfenster endgültig.", + "2026-10-09": "09.10.2026", + "HDR PNG export": "HDR-PNG-Export", + "With **HDR output** on, a photo edited in HDR can now be exported as a **16-bit PNG** with the PQ curve in Rec. 2020,": "Mit eingeschalteter **HDR-Ausgabe** lässt sich ein in HDR bearbeitetes Foto jetzt als **16-Bit-PNG** mit PQ-Kurve in Rec. 2020 exportieren,", + "like the HDR AVIF: the same brightness, with SDR white at 203 cd/m², and content light levels recorded.": "wie das HDR-AVIF: dieselbe Helligkeit, SDR-Weiß bei 203 cd/m², mit gespeicherten Lichtpegeln des Inhalts.", + "The colour is described by the PNG `cICP` chunk, not an ICC profile. HDR-aware viewers (current Chromium-based": "Die Farbe wird durch den PNG-Chunk `cICP` beschrieben, nicht durch ein ICC-Profil. HDR-fähige Betrachter (etwa aktuelle Chromium-basierte", + "browsers, for example) show it in HDR; **viewers that don't read `cICP` show the PQ values as if they were sRGB**,": "Browser) zeigen es in HDR; **Betrachter, die `cICP` nicht lesen, zeigen die PQ-Werte, als wären sie sRGB**,", + "so the picture looks flat and washed out there. That's how PQ PNG works, not a broken file: for a picture that": "sodass das Bild dort flau und ausgewaschen wirkt. So funktioniert PQ-PNG, die Datei ist nicht defekt: Für ein Bild, das", + "looks right everywhere, export a JPEG with HDR output on (an SDR image plus a gain map).": "überall richtig aussieht, exportiere ein JPEG mit eingeschalteter HDR-Ausgabe (ein SDR-Bild plus Gain Map).", + "Smart album rules": "Regeln für Smart-Alben", + "The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then": "Das Feldmenü des Regel-Editors ist gruppiert: Bewertung, Auswahlmarkierung, Farbmarkierung und Beliebiger durchsuchbarer Text oben, dann", + "submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted": "Untermenüs für Quelle, Datei, Datum, Stichwörter & Personen, Beschreibung, Kamera-Info, Ort, Größe, Bearbeitung und Unterstützte", + "Culling.": "Fotoauswahl.", + "New fields: Keyword Count (\"2 or more\", \"exactly 2\"…), People and People Count (named faces), Shutter Speed (written": "Neue Felder: Anzahl Stichwörter („2 oder mehr“, „genau 2“…), Personen und Anzahl Personen (benannte Gesichter), Belichtungszeit (geschrieben", + "as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long": "als 1/250), Dateiendung, Videodauer, Kopiename, In einem Stapel, Alternativtext, Stadt, Bundesland / Provinz, Land, Lange", + "Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white).": "Kante, Kurze Kante, Seitenverhältnis (Querformat / Hochformat / quadratisch), Zugeschnitten und Behandlung (farbig / schwarzweiß).", + "Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and": "Größenregeln messen das Foto, wie es angezeigt wird: Megapixel zählt jetzt das zugeschnittene Foto, wie Lange Kante, Kurze Kante und", + "Aspect Ratio (a 24 MP photo cropped to a square is 16 MP).": "Seitenverhältnis (ein 24-MP-Foto, quadratisch zugeschnitten, hat 16 MP).", + "Choice values read as words in every language: \"Public Domain\", \"Picked\", \"Black & White\" rather than ids like": "Auswahlwerte lesen sich in jeder Sprache als Wörter: „Gemeinfrei“, „Ausgewählt“, „Schwarzweiß“ statt IDs wie", + "`publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`).": "`publicDomain` (Regeln und Agenten verwenden weiterhin die IDs; `album.ruleFields` ergänzt `choiceLabels`).", + "Rule values are checked: a rating of 9, a date like 2026-13 or \"banana\", a \"between\" whose dates are the wrong": "Regelwerte werden geprüft: eine Bewertung von 9, ein Datum wie 2026-13 oder „Banane“, ein „zwischen“, dessen Daten verkehrt", + "way round, \"in the last 0 days\", a colour label that doesn't exist, \"contains\" with nothing to look for, an": "herum sind, „in den letzten 0 Tagen“, eine nicht vorhandene Farbmarkierung, „enthält“ ohne Suchbegriff, ein", + "album that is gone, or an empty group is reported with its rule (\"rule 2.1: no rating 0–5 is 9\")": "nicht mehr vorhandenes Album oder eine leere Gruppe werden mit ihrer Regel gemeldet („Regel 2.1: keine Bewertung 0–5 ist 9“),", + "instead of quietly matching nothing or everything. New date rules start at the current year.": "statt stillschweigend nichts oder alles zu treffen. Neue Datumsregeln beginnen beim aktuellen Jahr.", + "A smart album can include or exclude another smart album: \"Keywords contain travel\" and \"Album isn't Excluded Photos\"": "Ein Smart-Album kann ein anderes Smart-Album ein- oder ausschließen: „Stichwörter enthalten Reise“ und „Album ist nicht Ausgeschlossene Fotos“", + "leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar": "lässt aus, was „Ausgeschlossene Fotos“ trifft, auch wenn sich dessen Regeln ändern. Die Albumauswahl zeigt deine Alben wie in der Seitenleiste", + "(folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that": "(Ordner öffnen sich per Klick) oder findet sie, während du einen Teil eines Namens oder Ordners tippst (↑ / ↓ und Eingabetaste zum Auswählen); Alben, die", + "can't be picked, such as one that would make an album include itself, are greyed with the reason. Esc with a dropdown or calendar open in a dialog": "nicht gewählt werden können, etwa eines, durch das ein Album sich selbst einschließen würde, sind mit Begründung ausgegraut. Esc bei geöffnetem Dropdown oder Kalender in einem Dialog", + "closes just that.": "schließt nur diesen.", + "Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the": "Datumsregeln haben eine Kalenderschaltfläche: Wähle einen Tag, einen Monat oder ein ganzes Jahr (Jahr / Monat / Tag), in deiner Sprache; die", + "two dates of a \"between\" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a": "beiden Daten eines „zwischen“ können nicht in falscher Reihenfolge gewählt werden; Esc schließt nur den Kalender. Er wählt Daten, keine Uhrzeiten (eine", + "time is typed, and picking a day replaces one). Typing still works, and \"2026-10-01 10:00\" (a space for the": "Uhrzeit wird getippt, und die Wahl eines Tages ersetzt sie). Tippen funktioniert weiterhin, und „2026-10-01 10:00“ (ein Leerzeichen statt des", + "T) now matches.": "T) passt jetzt.", + "Rule problems are shown in your language and name the field the way the menu does (\"Title: needs something to look": "Regelprobleme werden in deiner Sprache angezeigt und nennen das Feld so wie das Menü („Titel: braucht einen", + "for\").": "Suchbegriff“).", + "A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip": "Ein Smart-Album, dessen Regeln nicht mehr stimmen (ein geprüftes Album wurde gelöscht), wird in der Seitenleiste mit ⚠ markiert; sein Tooltip", + "says what to fix, and `albums.list` reports `problems`.": "sagt, was zu korrigieren ist, und `albums.list` meldet `problems`.", + "Editing a smart album's name and rules is one step: both or neither, and one undo takes both back": "Das Bearbeiten von Name und Regeln eines Smart-Albums ist ein Schritt: beides oder keines, und ein Rückgängig nimmt beides zurück", + "(`album.setRules` takes `name`).": "(`album.setRules` nimmt `name`).", + "The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An": "Der Regel-Editor markiert jede Regel, die korrigiert werden muss, direkt darunter und lässt OK deaktiviert, bis sie korrigiert sind. Eine", + "Album rule is \"is\" or \"isn't\" an album, picked with the album picker.": "Album-Regel lautet „ist“ oder „ist nicht“ ein Album, gewählt mit der Albumauswahl.", + "Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `\"false\"` now": "Ja/Nein-Felder (Enthält Bearbeitungen, Zugeschnitten, Enthält GPS…) zeigen Ja / Nein statt true / false. Eine als `\"false\"` gesendete Regel bedeutet", + "means no (it used to count as yes), and a value that is neither yes nor no is refused.": "jetzt Nein (sie zählte bisher als Ja), und ein Wert, der weder Ja noch Nein ist, wird abgelehnt.", + "Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.": "„Beliebiger durchsuchbarer Text“ findet jetzt auch Bundesland / Provinz, Alternativtext, Personen und den Namen einer virtuellen Kopie.", + "Keyword lists": "Stichwortlisten", + "a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe": "Zeile, ein Tabulator pro Ebene, Synonyme in geschweiften Klammern, vom Export ausgeschlossene Stichwörter in eckigen Klammern). Capture One, darktable, Adobe", + "Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and": "Bridge und Photo Supreme (dessen Formatted Vocabulary File) verwenden dieselben Dateien, sodass Stichwortlisten zwischen ihnen und", + "LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or": "LightCraft wandern. Der Import fügt die Stichwörter hinzu, die du noch nicht hast, in einem Rückgängig-Schritt; eine Datei, die nicht gelesen werden kann, nennt die Zeile oder", + "to save it as UTF-8. Exporting warns about keywords Capture One won't import, and names any keyword the format can't": "empfiehlt, sie als UTF-8 zu speichern. Der Export warnt vor Stichwörtern, die Capture One nicht importiert, und nennt jedes Stichwort, das das Format nicht", + "hold (such as a name in brackets), which it leaves out so the file always reads back.": "aufnehmen kann (etwa einen Namen in Klammern), und lässt es weg, damit die Datei sich immer zurücklesen lässt.", + "Keyword sets": "Stichwortsätze", + "set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming": "Satz). Ein leeres Feld lässt seine Taste leer, statt die folgenden Stichwörter aufrücken zu lassen; das Bearbeiten von „Letzte Stichwörter“ mit", + "them saves a new set.": "einem Namen speichert einen neuen Satz.", + "Keywording box": "Stichwortfeld", + "The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a": "Die Chips oben im Stichwörter-Bedienfeld sind die Stichwörter aller ausgewählten Fotos, nicht nur die des aktiven; ein", + "keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from": "Stichwort, das nur einige haben, ist mit einem Sternchen markiert (beim Darüberfahren: wie viele). Nur das × entfernt ein Stichwort, und zwar von", + "every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer": "allen ausgewählten Fotos; das Menü eines Chips wirkt auf die Auswahl (allen hinzufügen, entfernen, Fotos anzeigen, bearbeiten) und löscht", + "deletes a keyword from the whole library.": "kein Stichwort mehr aus der ganzen Bibliothek.", + "Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what": "Wechsle zu „& Übergeordnete“, um die Stichwörter mit den übergeordneten Stichwörtern zu sehen, oder zu „Wird exportiert“, um zu sehen, was", + "exported files will carry for the selection.": "exportierte Dateien für die Auswahl enthalten werden.", + "The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste": "Die Schaltflächen des Stichwortsatzes und die Vorschläge folgen ebenfalls der Auswahl; die Eingabefelder erhalten das Menü Ausschneiden / Kopieren / Einfügen,", + "menu, and Return in the painter's box starts painting.": "und die Eingabetaste im Feld des Stichwortpinsels beginnt das Auftragen.", + "The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no": "Das Stichwörter-Bedienfeld endet mit einer Stichwortliste, wie in Lightroom Classic: jedes Stichwort mit seiner Fotoanzahl, auch die, die noch kein", + "photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click": "Foto hat. Filtere sie, hake ein Stichwort an, um es den ausgewählten Fotos zu geben (ein Strich: nur einige haben es), oder klicke", + "the arrow to see its photos.": "auf den Pfeil, um seine Fotos zu sehen.", + "Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one": "Erstelle Stichwörter mit + (innerhalb des gewählten, mit Synonymen und auf Wunsch der Auswahl zugewiesen), bearbeite eines", + "by double-clicking it, delete one with − after a confirmation. Right-click for more: create inside, Put New Keywords": "per Doppelklick, lösche eines mit − nach einer Bestätigung. Rechtsklick für mehr: darin erstellen, Neue Stichwörter in dieses Stichwort", + "Inside This Keyword, Purge Unused Keywords.": "einfügen, Nicht verwendete Stichwörter löschen.", + "Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged": "Ziehe ein Stichwort auf ein anderes, um es einzuordnen, oder auf den Titel der Liste, um es auf die oberste Ebene zurückzuholen; aus dem", + "from the grid onto a keyword get it.": "Raster auf ein Stichwort gezogene Fotos erhalten es.", + "it. The list is there with no photo selected too, to set up keywords first.": "Die Liste ist auch ohne ausgewähltes Foto da, um Stichwörter vorab anzulegen.", + "Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export": "Stichwort-Optionen legen fest, was exportierte Dateien enthalten: Beim Export einschließen, Übergeordnete Stichwörter exportieren, Synonyme", + "Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords": "exportieren. Exporte schreiben jetzt Stichwortnamen in `dc:subject` und ihre Pfade in `lr:hierarchicalSubject`, und aus Dateien", + "imported from files (or read from sidecars) keep the hierarchy `lr:hierarchicalSubject` gives them.": "importierte (oder aus Begleitdateien gelesene) Stichwörter behalten die Hierarchie, die `lr:hierarchicalSubject` ihnen gibt.", + "Text fields": "Textfelder", + "Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename": "Rechtsklick auf „Fotos suchen“, den getippten Wert eines Reglers, die Felder des Info-Bedienfelds, das Namensfeld von Neues Album, Album", + "Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for": "umbenennen, Neues Smart-Album, Stichwort umbenennen, Stichwörter zusammenführen und ähnlichen Dialogen oder das Namensfeld unter unbenannten Gesichtern für", + "Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it": "Ausschneiden, Kopieren, Einfügen und Alles auswählen. Esc stellt darin den Text von vor dem Tippen wieder her (im Info-Bedienfeld", + "no longer saves it). Return in those dialogs now confirms them, and their name opens selected. More fields will": "speichert es ihn nicht mehr). Die Eingabetaste bestätigt diese Dialoge jetzt, und ihr Name ist beim Öffnen ausgewählt. Weitere Felder", + "follow.": "folgen.", + "On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste": "Unter Windows und Linux kopiert Strg+C außerhalb eines Textfelds die Bearbeitungseinstellungen, und Strg+V fügt sie ein (⇧Strg+V: Ausgewählte", + "Settings.": "Einstellungen einfügen.", + "Dialogs": "Dialoge", + "System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK": "Systeminformationen, Alle Metadaten und Neuigkeiten enden wie „Über“ mit einer einzigen Schaltfläche „Schließen“ statt „Abbrechen“ und „OK“", + "(issue #782).": "(Issue #782).", + "Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose": "Der Ordner und die Ordner darin werden im Hintergrund nach Fotos durchsucht, die auf dem Datenträger hinzugekommen sind, nach Fotos, deren", + "file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then": "Datei fehlt, nach Fotos, deren Datei innerhalb des Ordners umbenannt oder verschoben wurde, und nach XMP-Begleitdateien, die andere Apps geändert haben; dann", + "choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently": "wählst du, was geschehen soll: die neuen Fotos importieren und die verschobenen neu verknüpfen (die Vorgaben), die fehlenden nach „Zuletzt", + "Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing": "gelöscht“ verschieben, die Metadaten-Änderungen lesen. Ein Ordner auf einem nicht angeschlossenen Laufwerk wird als solcher gemeldet. Das Synchronisieren", + "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "läuft im Hintergrund mit einer Zeile im Aktivitätsstapel (× hält es an); alles, was es tut, ist ein Rückgängig-Schritt, und keine Datei auf dem Datenträger", + "is touched. Agents use `folder.scanChanges` and `folder.synchronize`.": "wird angefasst. Agenten verwenden `folder.scanChanges` und `folder.synchronize`.", + "Folder colour labels": "Farbmarkierungen für Ordner", + "shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`": "zeigt den Punkt der Markierung vor der Fotoanzahl. Agenten verwenden `folder.label` (`path`, `label`); `library.folders`", + "reports each row's `label`.": "meldet für jede Zeile das `label`.", + "A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders": "Eine Markierung folgt ihrem Ordner, wenn er in LightCraft umbenannt oder verschoben wird, zusammen mit den Markierungen der Ordner", + "inside it; undo and redo carry them back and forth.": "darin; Rückgängig und Wiederholen bringen sie hin und her.", + "The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has": "Das Katalogformat hat jetzt Version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Sobald diese Version eine Bibliothek", + "opened a library, older versions refuse it, as with every format change.": "geöffnet hat, lehnen ältere Versionen sie ab, wie bei jeder Formatänderung.", + "Monitor profiles": "Monitorprofile", + "colours are right on wide-gamut and calibrated displays instead of oversaturated. The Detail view renders straight": "die Farben auf Wide-Gamut- und kalibrierten Bildschirmen stimmen, statt übersättigt zu sein. Die Detailansicht rendert direkt", + "into the display's own gamut; thumbnails and other previews are converted to it. Histograms, the preview caches": "in den Farbraum des Bildschirms; Miniaturen und andere Vorschauen werden in ihn umgerechnet. Histogramme, die Vorschau-Caches", + "and exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).": "und Exporte ändern sich nicht. Agenten verwenden `app.displayProfile`. Siehe [display profiles](display-profiles.md).", + "Highlights and Shadows (process 2)": "Lichter und Tiefen (Prozess 2)", + "Highlights and Shadows keep the fine detail of the tones they move (issue #632): pulling Highlights down on a bright": "Lichter und Tiefen erhalten das feine Detail der Tonwerte, die sie verschieben (Issue #632): Das Absenken der Lichter in einem hellen", + "sky no longer flattens the twigs, lines and texture in it, and lifting Shadows keeps the texture of dark areas, much": "Himmel macht Zweige, Linien und Struktur darin nicht mehr flach, und das Anheben der Tiefen erhält die Struktur dunkler Bereiche, ganz", + "as Exposure would; the edges between bright and dark regions stay as sharp as before. This is rendering process 2:": "wie es die Belichtung täte; die Kanten zwischen hellen und dunklen Bereichen bleiben so scharf wie bisher. Das ist Rendering-Prozess 2:", + "Process. See [process versions](process-versions.md).": "Prozessversion aktualisieren“ wählst. Siehe [process versions](process-versions.md).", + "Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated": "Ziehe irgendwo innerhalb der Ellipse des ausgewählten radialen Verlaufs, um ihn zu verschieben, auch bei gedrehten", + "ellipses and radial components in compound masks. Edge and rotation handles keep their existing": "Ellipsen und radialen Komponenten in zusammengesetzten Masken. Kanten- und Drehgriffe behalten ihre bisherigen", + "functions, and one Undo restores the entire drag.": "Funktionen, und ein einziges Rückgängig stellt die ganze Ziehbewegung wieder her.", + "Crop rotation": "Drehen beim Zuschneiden", + "Outside the crop box, where dragging rotates the photo, the pointer is a curved double arrow, and the angle is shown": "Außerhalb des Zuschnittrahmens, wo Ziehen das Foto dreht, ist der Mauszeiger ein gebogener Doppelpfeil, und der Winkel wird beim Drehen", + "next to it while you rotate (issue #534). For an exact angle, type it in the new Angle field under Straighten and press Return": "daneben angezeigt (Issue #534). Für einen exakten Winkel gib ihn in das neue Feld „Winkel“ unter „Gerade ausrichten“ ein und drücke die Eingabetaste", + "(Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed.": "(Esc behält den alten Winkel; ein Dezimalkomma funktioniert auch), oder klicke auf den Wert von „Gerade ausrichten“; beim Darüberfahren über den Wert eines Reglers wird jetzt angezeigt, dass er sich eintippen lässt.", + "2026-10-08": "08.10.2026", + "Albums tree": "Albenbaum", + "Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.": "Albenordner klappen mit demselben Dreieck auf und zu wie Lokal, Nach Datum, Ordner und Stichwörter.", + "makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`).": "legt es in diesem Ordner an (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` nehmen `parent`).", + "Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a": "Ziehe ein Album oder einen Ordner auf einen Ordner, um es dorthin zu verschieben (ein geschlossener Ordner öffnet sich nach 0,6 s unter dem Mauszeiger; die", + "\"Albums\" header takes it back to the top level; Esc cancels; holding a dragged album or photos near the top or bottom edge of the sidebar scrolls it). Drop on the top or bottom half of another album,": "Überschrift „Alben“ holt es auf die oberste Ebene zurück; Esc bricht ab; ein gezogenes Album oder Fotos nahe dem oberen oder unteren Rand der Seitenleiste lassen sie scrollen). Lege es auf der oberen oder unteren Hälfte eines anderen Albums", + "folder, fold that folder first (the bottom of an open folder means \"inside\"). Agents use `album.reorder`": "Ordner ein, klappe diesen Ordner zuerst zu (der untere Rand eines offenen Ordners bedeutet „darin“). Agenten verwenden `album.reorder`", + "(`id`, `parent?`, `before?`) and `album.sort`.": "(`id`, `parent?`, `before?`) und `album.sort`.", + "The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library,": "Das Katalogformat hat jetzt Version 3 (`Album.order`, `Op::SetAlbumOrder`). Sobald diese Version eine Bibliothek geöffnet hat,", + "older versions refuse it, as with every format change.": "lehnen ältere Versionen sie ab, wie bei jeder Formatänderung.", + "AI RAW denoise": "KI-Entrauschen für RAW-Dateien", + "Detail has a per-photo AI Denoise switch and Amount. Bayer RAW photos use a disposable cache, with matching previews and exports and pure-Rust CPU/GPU inference.": "Detail hat pro Foto einen Schalter „KI-Entrauschen“ und eine Stärke. Bayer-RAW-Fotos nutzen einen verwerfbaren Cache, mit übereinstimmenden Vorschauen und Exporten und Inferenz in reinem Rust auf CPU/GPU.", + "Models are installed separately after accepting their terms; no weights are bundled. See [setup and limitations](denoise.md).": "Modelle werden separat installiert, nachdem ihre Bedingungen akzeptiert wurden; es werden keine Gewichte mitgeliefert. Siehe [setup and limitations](denoise.md).", + "Library keyboard culling": "Fotoauswahl per Tastatur in der Bibliothek", + "Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour.": "Farbmarkierungen tönen die Umrandung der Miniaturen im Quadratischen Raster und im Filmstreifen der Detailansicht sowie eine durchscheinende Fußzeile am unteren Rand des Fotos im Fotoraster. Bestätigungen von Markierungen verwenden eine passende blasse Farbe.", + "Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too.": "Das Setzen oder Entfernen einer Farbmarkierung zeigt unten eine kurze Bestätigung, wie das Bewerten eines Fotos; eigene Markierungsnamen erscheinen ebenfalls in der Meldung.", + "On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261).": "Unter macOS erreichen Bewertungen `0–5`, Markierungen `6–9` und Auswählen/Markierung aufheben `P/U` die App jetzt auch, wenn sie im nativen Menü stehen (Issue #283; übernommen aus PR #261).", + "`Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.": "`Shift+6–9` setzt Markierungen und schaltet weiter. `Shift+P` wählt im Fotoraster und Quadratischen Raster aus und schaltet weiter; in anderen Ansichten öffnet es die Vorgaben. Mit „Automatisch weiterschalten“ bewegt Shift trotzdem nur einmal.", + "Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).": "Hilfe → Tastenkürzel enthält die Belegung der Zifferntasten. Siehe [library shortcuts](library-shortcuts.md).", + "press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that": "und drücke die neuen Tasten (Esc bricht ab), × entfernt es, ↺ stellt das ursprüngliche wieder her, „Alle zurücksetzen“ macht jede Änderung rückgängig. Eine Taste, die", + "belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.": "zu einem anderen Befehl gehörte, wandert zum neuen. Menüs zeigen die neuen Tasten; Agenten verwenden `app.setShortcut`.", + "Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them": "Die Eingabetaste drückt OK in „Zu kopierende Bearbeitungseinstellungen auswählen“ (⇧⌘C) und „Ausgewählte Einstellungen einfügen“ (⇧⌘V), und Esc bricht sie ab", + "(issue #735).": "(Issue #735).", + "HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind": "HEIC-/HEIF-Fotos (iPhone und Mac) öffnen sich jetzt: das optionale Crate `lightcraft-heif` (heic-rs, reines Rust) hinter", + "codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP.": "dem Feature `heif` von codecs – 8 und 10 Bit, Alpha, Kachelraster, Drehung/Spiegelung/Zuschnitt des Containers, ICC, EXIF und XMP.", + "Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.": "Standardmäßig aus (HEVC-Patente sind Sache des Distributors, wie bei PhotoCraft); offizielle Builds übergeben `--features heif`.", + "HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own": "HEIC-Farben stimmen jetzt bis auf einen Codewert mit libheif überein (sie wichen um bis zu ~10 ab): Die eigene", + "full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids": "Full-Range-/Matrix-Signalisierung des HEVC-Streams wird beachtet (iPhone-Fotos sind Full Range), Chroma wird wie bei libheif hochgerechnet, Raster", + "take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from": "übernehmen das ICC-Profil ihrer Kacheln, und `imir` spiegelt so, wie libheif es schreibt. Das Importieren eines HEIC liest seine Größe aus", + "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,", + "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,", + "lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and": "beleuchtet mit dem gewählten Weiß, wird grau, und gesättigte Farben bewegen sich so, wie die Kamera sie aufzeichnet. Die Pipette und", + "Auto use the same model. Custom white balances on these photos render slightly differently than before.": "Auto verwenden dasselbe Modell. Benutzerdefinierte Weißabgleiche auf diesen Fotos rendern etwas anders als bisher.", + "Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and": "Zuschneiden (Issue #295): Ein Sperrschalter hält das Seitenverhältnis an jedem Griff fest, „Benutzerdefiniert“ nimmt ein eigenes Verhältnis an (Anwenden), und", + "dragging a handle into the image edge stops there instead of pushing the crop out of shape.": "ein Griff, der an den Bildrand gezogen wird, hält dort an, statt den Zuschnitt zu verformen.", + "Library and views": "Bibliothek und Ansichten", + "Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo": "Trackpads: Aufziehen/Zusammenziehen zoomt um den Mauszeiger, und Scrollen mit zwei Fingern verschiebt das Foto; beim Verschieben bleibt das Foto", + "inside the view. A plain mouse wheel over a zoomed photo pans it too.": "in der Ansicht. Auch ein einfaches Mausrad über einem gezoomten Foto verschiebt es.", + "A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos.": "Ein Bereich „Ordner“ in der Seitenleiste listet die Ordner, aus denen deine Fotos importiert wurden; wähle einen, um seine Fotos zu sehen.", + "Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one": "„Alles auswählen“ und Mehrfachauswahl zeigen jedes ausgewählte Foto in Raster und Filmstreifen, nicht nur das aktive", + "(issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them": "(Issues #187, #298). Das Importieren von Dateien, die in „Zuletzt gelöscht“ liegen, fragt, ob sie dort bleiben, wiederhergestellt werden", + "(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.": "(mit ihren Bearbeitungen) oder neu importiert werden sollen; das Menü „Foto“ der Papierkorbansicht enthält „Zuletzt gelöscht“ leeren.", + "The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks": "Die Prüfung von „Fotos importieren“ öffnet größer und lässt sich in der Größe ändern; ihr Fotoraster füllt sie aus (Issue #337). Umschalt-Klick hakt", + "or unchecks a range of photos (issue #338).": "einen Bereich von Fotos an oder ab (Issue #338).", + "When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):": "Enthält ein Ordner mehrere Dateitypen, hat die Prüfung von „Fotos importieren“ einen Schalter pro Typ (`ARW · 120`, `JPG · 120`):", + "import only the raws and leave the JPEGs beside them (issue #344).": "Importiere nur die RAW-Dateien und lass die JPEGs daneben liegen (Issue #344).", + "Languages": "Sprachen", + "Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.": "Chinesisch (vereinfacht und traditionell), Japanisch und Portugiesisch (Brasilien).", + "Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)": "Gib einen exakten Wert in einen beliebigen Regler ein (Issue #322): Klicke auf die Zahl neben seinem Namen, tippe (`1.5`, `-20`, `5600`)", + "and press Return; Esc keeps the old value.": "und drücke die Eingabetaste; Esc behält den alten Wert.", + "The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,": "Das Auge in den Abschnittsüberschriften Licht, Farbe und Detail blendet jetzt deren Anpassungen aus, wie bisher schon bei Effekte,", + "Optics, Geometry and Calibration (issue #316).": "Optik, Geometrie und Kalibrierung (Issue #316).", + "Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts": "Die Wahl eines Datums unter „Nach Datum“ oder eines Stichworts unter „Stichwörter“ zeigt diese Fotos aus „Alle Fotos“, wie ihre Anzahlen", + "promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).": "es versprechen, statt das gerade geöffnete Album oder den Ordner zu filtern, was oft nichts zeigte (Issue #341).", + "2026-10-07": "07.10.2026", + "Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the": "Olympus-ORF-RAW-Dateien erhalten jetzt ebenfalls einen an das eigene JPEG der Kamera angepassten Ausgangslook, statt flach und grau zu öffnen (die", + "E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other": "Korpusdateien von E-1, E-400 und XZ-2: alle angenommen), mit einem Weißabgleich relativ zum Look „Wie Aufnahme“ wie bei den anderen", + "formats. On an E-1 photo the starting render moved from ΔE00 11.3 to 8.3 against Lightroom's. Photos already": "Formaten. Bei einem E-1-Foto verbesserte sich das Ausgangsrendering gegenüber dem von Lightroom von ΔE00 11,3 auf 8,3. Bereits", + "imported pick it up when re-rendered.": "importierte Fotos übernehmen das beim erneuten Rendern.", + "Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1": "Samsung-SRW-Dateien ohne Komprimierung öffnen jetzt als RAW-Dateien: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1", + "and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their": "und WB2000. Die komprimierten (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) öffnen weiterhin aus ihrem", + "camera JPEG. Photos already imported pick the change up on Reload.": "Kamera-JPEG. Bereits importierte Fotos übernehmen die Änderung beim Neuladen.", + "Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) whose": "RAW-Dateien von 52 Kameramodellen (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm und 5 weitere, plus ihre anderen Namen), deren", + "camera JPEG can't be fitted no longer open with the neutral fallback: they start from colour matrices fitted to the": "Kamera-JPEG sich nicht anpassen lässt, öffnen nicht mehr mit dem neutralen Notbehelf: Sie starten mit Farbmatrizen, die an die", + "camera's measured spectral sensitivities, from the Academy Software Foundation's rawtoaces-data. A camera profile": "gemessenen spektralen Empfindlichkeiten der Kamera angepasst sind, aus rawtoaces-data der Academy Software Foundation. Ein Kameraprofil", + "and the fit to the photo's own JPEG still come first, so photos that had a fitted look keep it. See": "und die Anpassung an das eigene JPEG des Fotos haben weiterhin Vorrang, sodass Fotos mit angepasstem Look ihn behalten. Siehe", + "[camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).": "[camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).", + "Canon CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns": "Canon-CR2-RAW-Dateien öffnen nicht mehr zu dunkel mit zugelaufenen Tiefen: Der Schwarzwert wurde über Randspalten gemessen,", + "that light already reaches (left of the image area Canon records), 4 % of the range too high on the EOS 6D. It is": "die bereits Licht abbekommen (links vom Bildbereich, den Canon angibt), bei der EOS 6D um 4 % des Bereichs zu hoch. Er wird", + "now measured on the masked columns alone (8 corpus bodies, EOS 40D to 5DS R). On a 6D photo the starting render": "jetzt nur an den maskierten Spalten gemessen (8 Korpusmodelle, EOS 40D bis 5DS R). Bei einem 6D-Foto verbesserte sich das Ausgangsrendering", + "moved from ΔE00 7.6 to 5.2 against Lightroom's.": "gegenüber dem von Lightroom von ΔE00 7,6 auf 5,2.", + "Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF": "Canon-CR2- und Pentax-PEF-RAW-Dateien erhalten denselben an das eigene JPEG der Kamera angepassten Ausgangslook wie ARW, NEF, RW2, RAF", + "and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.": "und CR3, statt flach und entsättigt zu öffnen (Issue #310). Bereits importierte Fotos übernehmen das beim erneuten Rendern.", + "JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);": "JPEG-XL-komprimierte DNGs (DNG 1.7) öffnen sich jetzt: Verlustfreie Kacheln werden Wert für Wert dekodiert (an synthetischen Dateien geprüft);", + "lossy tiles decode too, keeping the raw values above 1.0 instead of clipping them (checked on one real file). A tile that cannot be decoded makes the file open from its embedded": "verlustbehaftete Kacheln werden ebenfalls dekodiert und behalten RAW-Werte über 1,0, statt sie abzuschneiden (an einer echten Datei geprüft). Eine Kachel, die nicht dekodiert werden kann, lässt die Datei aus ihrer eingebetteten", + "preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an": "Vorschau öffnen, mit Begründung, statt eine schwarze Kachel zu zeigen. Eine im DNG gespeicherte JPEG-XL-Vorschau wird wie ein", + "embedded JPEG.": "eingebettetes JPEG verwendet.", + "Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is": "Die Gain Table Map von Apple ProRAW (seine lokale Tonwertzuordnung, `ProfileGainTableMap`) wird jetzt gelesen und beibehalten, wenn ein Foto", + "exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a": "exportiert oder in DNG konvertiert wird. Sie wird standardmäßig nicht angewendet: Lightroom Classic rendert ProRAW ohne sie. Um ein", + "map); the option is per photo, so presets and Copy Settings carry it.": "Gain Map tragen); die Option gilt pro Foto, sodass Vorgaben und „Einstellungen kopieren“ sie übertragen.", + "iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select": "iPhone ProRAW und andere DNGs mit eigenen Segmentierungsmasken (semantische DNG-Masken) verwenden diese für die Masken", + "Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it": "Himmel, Motiv und Hintergrund auswählen statt unserer Heuristik, sodass der Himmel dort ausgewählt wird, wo die Kamera ihn gefunden hat", + "(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.": "(an einem CC0-ProRAW eines iPhone 12 Pro geprüft). Fotos ohne solche Masken sind unverändert.", + "Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG": "RAW-Dateien von Panasonic und Leica (RW2, RWL) werden jetzt so um die Objektivverzeichnung korrigiert, wie die Kamera ihr eigenes JPEG korrigiert", + "Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the": "angewendet, für neu importierte Fotos standardmäßig an, mit demselben Bildausschnitt wie das JPEG der Kamera. Bei 12 mm", + "12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off": "lag das Kit-Zoom 12–32 mm in den Ecken bisher um etwa 5 % der Bildbreite daneben. Mit ausgeschalteter Korrektur aufgenommene Dateien", + "are unchanged; photos imported before this change get it when imported again.": "sind unverändert; vor dieser Änderung importierte Fotos erhalten sie bei einem erneuten Import.", + "Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS": "RAW-Dateien der Canon CR3 werden jetzt aus ihren Sensordaten entwickelt: verlustfreies RAW und C-RAW, Wert für Wert geprüft an EOS", + "M50, R100, R8 and RP (on the RP also in 1.6× crop mode). CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100, R8 und RP (bei der RP auch im 1,6×-Crop-Modus). CR3-Dateien, die der Decoder noch nicht lesen kann, öffnen wie bisher aus ihrem eingebetteten JPEG.", + "Canon EOS R7 C-RAW files develop from their sensor data too, instead of opening from their embedded JPEG. For R7": "C-RAW-Dateien der Canon EOS R7 werden ebenfalls aus ihren Sensordaten entwickelt, statt aus ihrem eingebetteten JPEG zu öffnen. Bei früher", + "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet": "Dateien von Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS und Epson ERF, die LightCraft noch nicht dekodieren kann,", + "now import as \"preview only\" with their embedded JPEG instead of failing. A raw whose data is damaged but whose": "werden jetzt als „Nur Vorschau“ mit ihrem eingebetteten JPEG importiert, statt zu scheitern. Eine RAW-Datei mit beschädigten Daten, aber", + "preview is intact does the same.": "intakter Vorschau, verhält sich genauso.", + "Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS": "RAW-Dateien, deren Rohdaten in einem privaten Block einer TIFF-Datei liegen (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds und Kodak-DCS-", + "TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode": "TIFFs), werden nicht mehr als gewöhnliches Bild in Miniaturgröße geöffnet. Sie werden als RAW-Dateien erkannt, die LightCraft noch nicht dekodieren kann,", + "yet and import as \"preview only\", with the reason.": "und als „Nur Vorschau“ mit Begründung importiert.", + "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.", + "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.", + "2026-10-05": "05.10.2026", + "The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you": "Das Windows-Installationsprogramm fragt, wohin LightCraft installiert werden soll („Programme“ als Vorgabe; Upgrades behalten den gewählten", + "chose) and ends on a page saying it was installed, with a \"Launch LightCraft\" box. It used to finish without a word,": "Ordner), und endet mit einer Seite, die die Installation bestätigt, mit einem Kästchen „LightCraft starten“. Bisher endete es kommentarlos,", + "so a successful install looked like nothing had happened (issues #18, #399). Silent installs (`/qn`, `/passive`)": "sodass eine erfolgreiche Installation aussah, als sei nichts passiert (Issues #18, #399). Stille Installationen (`/qn`, `/passive`)", + "show no dialogs and take `INSTALLFOLDER=...`.": "zeigen keine Dialoge und nehmen `INSTALLFOLDER=...` an.", + "If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a": "Kann die Desktop-App ihr Fenster nicht öffnen (etwa wenn kein Grafikgerät nutzbar ist), meldet sie das jetzt in einem", + "message box that names the log file, instead of quitting without a trace (issue #260).": "Meldungsfenster, das die Protokolldatei nennt, statt sich spurlos zu beenden (Issue #260).", + "On Linux and FreeBSD, file dialogs (Import Photos…, Open Library, Export, presets, keyword lists…) are children of": "Unter Linux und FreeBSD sind Dateidialoge (Fotos importieren…, Bibliothek öffnen, Export, Vorgaben, Stichwortlisten…) Kindfenster", + "the LightCraft window. The xdg-desktop-portal picker used to open as a separate top-level window on Wayland": "des LightCraft-Fensters. Die Auswahl von xdg-desktop-portal öffnete sich unter Wayland bisher als eigenes Fenster oberster Ebene", + "(\"Failed to associate portal window with parent window\" in the portal's log): tiling compositors put it beside the": "(„Failed to associate portal window with parent window“ im Protokoll des Portals): Kachelnde Compositoren setzten sie neben die", + "app, and with LightCraft fullscreen it was hidden altogether, so Import Photos… looked like a hang until the": "App, und bei LightCraft im Vollbild war sie ganz verborgen, sodass „Fotos importieren…“ wie ein Hänger aussah, bis der", + "dialog was found and closed.": "Dialog gefunden und geschlossen wurde.", + "On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,": "Unter macOS funktionieren Einzeltasten-Kürzel, die in der Menüleiste stehen, jetzt: E, C, H, M, ⇧P, I, K, D, Bewertungen 0–5,", + "labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys": "Markierungen 6–9, P / U und der Rest taten nichts, weil macOS nur ⌘- / ⌃-Kombinationen und Funktionstasten", + "to the menu bar and the app ignored those keys, assuming the menu bar would handle them. Keys outside the menus": "an die Menüleiste weitergibt und die App diese Tasten ignorierte, in der Annahme, die Menüleiste würde sie verarbeiten. Tasten außerhalb der Menüs", + "(Space, G, X, ⌫) and ⌘ shortcuts were not affected.": "(Leertaste, G, X, ⌫) und ⌘-Kürzel waren nicht betroffen.", + "`--memory` sessions keep their promise to save nothing (issues #164, #169): UI changes made in one no longer": "`--memory`-Sitzungen halten ihr Versprechen, nichts zu speichern (Issues #164, #169): Darin vorgenommene Änderungen an der Oberfläche landen nicht mehr", + "land in `ui.json` (where they replaced the saved settings), and the GPU crash sentinel no longer creates the": "in `ui.json` (wo sie die gespeicherten Einstellungen ersetzten), und der GPU-Absturzwächter legt dort keinen", + "settings folder there. The same goes for the temporary session offered when the library can't be opened.": "Einstellungsordner mehr an. Dasselbe gilt für die vorübergehende Sitzung, die angeboten wird, wenn die Bibliothek nicht geöffnet werden kann.", + "The desktop app keeps a log file: `logs/lightcraft.log` in its settings folder (Linux `~/.config/lightcraft/logs/`),": "Die Desktop-App führt eine Protokolldatei: `logs/lightcraft.log` in ihrem Einstellungsordner (Linux `~/.config/lightcraft/logs/`),", + "with the logs of the two previous runs beside it, so warnings and crashes of a run started from a desktop menu or the": "mit den Protokollen der beiden vorherigen Läufe daneben, sodass Warnungen und Abstürze eines aus einem Desktop-Menü oder dem", + "Dock can be attached to a bug report. `LIGHTCRAFT_LOG` works as before; `RUST_LOG` takes env_logger-style": "Dock gestarteten Laufs einem Fehlerbericht beigefügt werden können. `LIGHTCRAFT_LOG` funktioniert wie bisher; `RUST_LOG` nimmt Direktiven", + "directives. See README → Quick start → Logs.": "im Stil von env_logger. Siehe README → Quick start → Logs.", + "Help → Open Log Folder shows that log file in the file manager (Finder, Explorer, or the folder on Linux), so it": "Hilfe → Protokollordner öffnen zeigt diese Protokolldatei im Dateimanager (Finder, Explorer oder den Ordner unter Linux), sodass sie", + "can be attached to a report without hunting for the settings folder (issue #260).": "einem Bericht beigefügt werden kann, ohne den Einstellungsordner suchen zu müssen (Issue #260).", + "`lightcraft-cli` logs warnings on stderr too (issue #168); `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level.": "`lightcraft-cli` protokolliert Warnungen jetzt auch auf stderr (Issue #168); `LIGHTCRAFT_LOG` oder `RUST_LOG` wählt eine andere Stufe.", + "The Windows app no longer quits at launch with \"Parent device is lost\" when another program's `dxcompiler.dll` is": "Die Windows-App beendet sich beim Start nicht mehr mit „Parent device is lost“, wenn die `dxcompiler.dll` eines anderen Programms", + "on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "ohne ihre `dxil.dll` im DLL-Suchpfad liegt (Issue #471): DirectX-12-Shader werden jetzt immer mit dem in Windows eingebauten", + "compiler built into Windows (FXC). `WGPU_DX12_COMPILER=dxc` uses a `dxcompiler.dll` instead.": "Compiler (FXC) kompiliert. `WGPU_DX12_COMPILER=dxc` verwendet stattdessen eine `dxcompiler.dll`.", + "2026-10-02": "02.10.2026", + "Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot": "Beim Importieren von XMP-Vorgaben werden Verwaltungsfelder (`Cluster`, `SortName`, `SupportsAmount2`, das Weiß „Wie Aufnahme“,", + "white, empty Point Color slots…) as settings that couldn't be carried over.": "leere Punktfarben-Plätze…) nicht mehr als Einstellungen aufgeführt, die sich nicht übernehmen ließen.", + "A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW": "Eine Vorgabe mit ausgeschaltetem Objektivprofil-Schalter schaltet die in ein DNG eingebauten Objektivkorrekturen nicht mehr ab (iPhone ProRAW", + "and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.": "und andere Dateien mit eingebetteter Verzeichnungs- / Vignettierungskorrektur), passend zu dem, was die Vorgabe sonst tut.", + "XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel": "XMP-Vorgaben wenden ihre Rot- / Grün- / Blau-Kurven nur an, wenn sie auch die Hauptkurve und alle drei Kanal-", + "curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone.": "kurven haben, wie Lightroom: Eine Vorgabe mit nur einer Kanalkurve (oder ohne Hauptkurve) lässt die Kanäle unverändert.", + "Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay": "Importierte `.cube`-LUT-Profile erscheinen im Profilmenü und im Profilbrowser, nach ihrem Ordner gruppiert, und bleiben", + "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)." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 5ea0e969a..fd1540169 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", "· {in_source} identical copies": "· {in_source} copias idénticas", "{count} more warnings.": "{count} advertencias más.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "No se importaron {skipped} imágenes del catálogo porque sus registros en el catálogo están incompletos.", "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.", "Applied {name}": "Se aplicó {name}", @@ -295,5 +296,6 @@ "Already in “{}”": "Ya está en “{}”", "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." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77ba4c08e..46965c778 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -420,6 +420,7 @@ "Detect Faces": "Detectar caras", "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar caras (menú Foto) lo necesita: una pequeña descarga única.", "Diagonal": "Diagonal", + "Dimensions": "Dimensiones", "Discard Preview Cache": "Descartar caché de vistas previas", "Display P3": "Display P3", "Display name": "Nombre visible", @@ -1441,6 +1442,7 @@ "Show in Explorer": "Mostrar en el Explorador", "Show in File Manager": "Mostrar en el gestor de archivos", "Show in Finder": "Mostrar en el Finder", + "Show in Folders": "Mostrar en Carpetas", "Show on Map": "Mostrar en el mapa", "Show overlay (O)": "Mostrar superposición (O)", "Show ratings, flags and edit badges": "Mostrar valoraciones, marcas e indicadores de edición", @@ -1749,7 +1751,7 @@ "Width": "Ancho", "Width (% of photo)": "Ancho (% de la foto)", "Width (px)": "Ancho (px)", - "Width × Height": "Ancho × alto", + "Width & Height": "Ancho & alto", "Will be replaced": "Se reemplazará", "Will be restored": "Se restaurará", "Window": "Ventana", diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json new file mode 100644 index 000000000..698080e10 --- /dev/null +++ b/crates/ui-egui/locales/fi-formats.json @@ -0,0 +1,301 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} usealla samannäköisellä tiedostolla: käytä Paikanna)", + " ({} in Recently Deleted)": " ({} Äskettäin poistetuissa)", + " in {g} group{}": " · ryhmiä: {g}{:.0}", + " · kept, not applied: {}": " · säilytetty, ei käytetty: {}", + " · {:.0}% clipped": " · {:.0}% leikkautunut", + " · {failed} couldn't be rendered": " · renderöinti epäonnistui: {failed}", + " · {n} skipped (could not be rendered)": " · {n} ohitettu (renderöinti ei onnistunut)", + " · {queued} waiting": " · odottaa: {queued}", + " · {skipped} skipped (file exists)": " · {skipped} ohitettu (tiedosto on olemassa)", + " · {} duplicate{} skipped": " · ohitettuja kaksoiskappaleita: {}{:.0}", + " · {} failed: {}": " · {} epäonnistui: {}", + " · {} moved": " · {} siirretty", + " · {} not readable": " · {} ei luettavissa", + " · {} original{} left at the source": " · alkuperäisiä jäi lähteeseen: {}{:.0}", + " · {} restored from Recently Deleted": " · {} palautettu Äskettäin poistetuista", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One ei tuo luetteloa, jonka avainsanassa on ; , < tai >: {names}", + " — kept but not applied: {}": " — säilytetty mutta ei käytetty: {}", + " — left out, as a keyword list can't hold them: {names}": " — jätetty pois, koska avainsanaluettelo ei voi sisältää niitä: {names}", + " — not carried over: {}{}": " — ei siirretty: {}{}", + "+{n} more": "+{n} lisää", + "+{} more": "+{} lisää", + ", {failed} file{} not readable": ", ei luettavissa olevia tiedostoja: {failed}{:.0}", + ", {} label": ", {} merkintä", + ", {} star{}": ", tähtiä {}{:.0}", + "1 task is still running: {names}. Quitting stops it.": "1 tehtävä on yhä käynnissä: {names}. Lopettaminen pysäyttää sen.", + "; {kept} already had a location": "; jo sijaintitiedolliset: {kept}", + "; {n} gained synonyms": "; {n} sai synonyymejä", + "; {outside} outside its time range": "; {outside} aika-alueen ulkopuolella", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR:t {}, commitit {}, rivit +{} / −{} (Δ {}), binääriresurssit +{} / −{}, {} – {}", + "Add {local_n} to My Photos": "Lisää {local_n} Omiin kuviin", + "Add {} Photo{}": "Lisää kuvia: {}{:.0}", + "Add {} Photo{}, restore {}": "Lisää kuvia: {}{:.0}, palauta {}", + "Add “{}” to Favorites": "Lisää ”{}” suosikkeihin", + "Added {n} keyword{}": "Lisättyjä avainsanoja: {n}{:.0}", + "Added {n} photo{} to My Photos": "Omiin kuviin lisättyjä kuvia: {n}{:.0}", + "Added {n} photo{} to “{}”": "Lisättyjä kuvia kohteeseen ”{1}”: {n}{0:.0}", + "Added {what} to {name}": "{what} lisätty kohteeseen {name}", + "Added “{keyword}” to {n} photo{}": "Avainsana ”{keyword}” lisätty kuviin: {n}{:.0}", + "Adding photos… {} of {}": "Lisätään kuvia… {} / {}", + "All {n} selected photos move by the same amount.": "Kaikkia valittuja kuvia ({n}) siirretään saman verran.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Jo kirjastossa, Äskettäin poistetuissa: valittuja kuvia {}{:.0} · napsauta hiiren kakkospainikkeella → Palauta tai Poista pysyvästi tuodaksesi uudelleen", + "Already in the library: {} photo{} selected in All Photos": "Jo kirjastossa (Kaikki kuvat): valittuja kuvia {}{:.0}", + "Already in “{}”": "Jo kohteessa ”{}”", + "Applied {name}": "{name} käytetty", + "Apply to “{mask}” on Selected Photos": "Käytä maskiin ”{mask}” valituissa kuvissa", + "Auto ({})": "Auto ({})", + "Auto Import: added {} photo{}": "Automaattinen tuonti: lisättyjä kuvia {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Automaattinen synkronointi: muokkaukset koskevat kuvia: {n}", + "Automatic ({} MB)": "Automaattinen ({} Mt)", + "Build 1:1 Previews ({scope})": "Luo 1:1-esikatselut ({scope})", + "Build Smart Previews ({scope})": "Luo älyesikatselut ({scope})", + "Build Standard-Sized Previews ({scope})": "Luo vakiokokoiset esikatselut ({scope})", + "Building previews for {} photos…": "Luodaan esikatseluja {} kuvalle…", + "Click {}:1": "Napsautus {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Väriavaruus: Rec. 2020 {curve} (HDR AVIF)", + "Couldn't open the editor: {e}": "Muokkausohjelmaa ei voitu avata: {e}", + "Create Keyword Tag Inside “{name}”…": "Luo avainsanatunniste kohteen ”{name}” sisään…", + "Create the keyword “{name}”": "Luo avainsana ”{name}”", + "Create {n} Virtual Copies": "Luo {n} virtuaalikopiota", + "Creates {} stacks from {} of {scope}": "Luo pinoja: {} · kuvia: {} ({scope})", + "Decoder: {reason}": "Purkaja: {reason}", + "Delete \"{label}\"": "Poista ”{label}”", + "Delete \"{label}\" (and the mask)": "Poista ”{label}” (ja maski)", + "Delete the effect preset “{name}”?": "Poistetaanko tehosteesiasetus ”{name}”?", + "Delete the keyword “{name}”?": "Poistetaanko avainsana ”{name}”?", + "Delete these {n} effect presets?": "Poistetaanko nämä {n} tehosteesiasetusta?", + "Delete {n} Photos": "Poista {n} kuvaa", + "Delete {n} effect preset{}? This can't be undone.": "Poistetaanko tehosteesiasetuksia: {n}{:.0}? Tätä ei voi kumota.", + "Delete “{current}”": "Poista ”{current}”", + "Delete “{name}”?": "Poistetaanko ”{name}”?", + "Denoising {name}: {done} of {total} tiles{}": "Kohinanpoisto {name}: {done} / {total} ruutua{}", + "Discard Smart Previews ({scope})": "Hylkää älyesikatselut ({scope})", + "Done ({what}): {done} photo{plural}": "Valmis ({what}): kuvia {done}{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Latautuu osoitteesta {host}. Kun se on saapunut ja tarkistettu, LightCraft asentaa sen, {after}.", + "Duplicate \"{label}\"": "Monista ”{label}”", + "Effect: {name}": "Tehoste: {name}", + "Example: {example}": "Esimerkki: {example}", + "Exported to {path}": "Viety kohteeseen {path}", + "Exported {n} keyword{}": "Viety avainsanoja: {n}{:.0}", + "Exported {n} photo{}": "Viety kuvia: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Viety kuvia: {ok} / {total}{:.0}", + "Exported {} effect preset{}": "Viety tehosteesiasetuksia: {}{:.0}", + "Exported {} point curve preset{}": "Viety pistekäyrän esiasetuksia: {}{:.0}", + "Exported {} preset{}": "Viety esiasetuksia: {}{:.0}", + "Exporting {} of {total}": "Viedään {} / {total}", + "Filter bar — {active} active filter{}": "Suodatinpalkki — aktiivisia suodattimia: {active}{:.0}", + "Filtered: {label}": "Suodatettu: {label}", + "Focus {:.0}": "Tarkennus {:.0}", + "Folder “{}” (and its subfolders)": "Kansio ”{}” (ja sen alikansiot)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Löydettyjä puuttuvia kuvia: {n}{:.0}; edelleen puuttuu: {left}{unsure}", + "Healed {n} dust spot{}": "Korjattuja pölyhiukkasia: {n}{:.0}", + "Import cancelled · {} photo{} added": "Tuonti peruttu · lisättyjä kuvia: {}{:.0}", + "Import failed: {e}": "Tuonti epäonnistui: {e}", + "Import warnings ({n}):\n{warnings}": "Tuontivaroitukset ({n}):\n{warnings}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Tuotuja maskitehosteita: {e}{:.0} (Maskaus → Tehoste)", + "Imported {name}": "{name} tuotu", + "Imported {n} point curve preset{}": "Tuotuja pistekäyrän esiasetuksia: {n}{:.0}", + "Imported {n} preset{}": "Tuotuja esiasetuksia: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Tuotuja esiasetuksia: {n}{:.0}, ei luettavissa olevia tiedostoja: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Tuotuja profiileja: {profiles}{:.0} (Profiiliselain → niiden ryhmät)", + "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 {})", + "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}", + "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 ei vielä osaa purkaa tätä raw-muunnelmaa ({}). Muokkaat kameran upotettua JPEG-esikatselua, joka sisältää jo kameran kuvatyylin (esim. Monochrome) ja valkotasapainon.", + "LightCraft could not open its window: {e}": "LightCraft ei voinut avata ikkunaansa: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft ei voinut avata ikkunaansa: {e}\n\nLokitiedostossa on lisätietoja: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft pysähtyi odottamatta: {e}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft pysähtyi odottamatta: {e}\n\nLokitiedostossa on lisätietoja: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-katalogi tarkastettu: kuvia {photos}, kokoelmia {collections}, puuttuvia {missing}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; Ohjatun Uprightin tallennettua muunnosta ei tueta.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; tallennettua Upright-muunnosta ei tueta.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; tallennettu muunnos ohitetaan, koska Upright on pois päältä.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; {mode} lasketaan uudelleen.", + "Lightroom import complete: {imported} photos added": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}, epäonnistuneita {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}; hakemistovaroitus: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom-tuonti epäonnistui: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom-tarkastelu epäonnistui: {e}{warning}", + "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontit: {} (OFL). Kuvakkeet: omat.", + "Making the denoised picture… {}%": "Tehdään kohinanpoistettua kuvaa… {}%", + "Matches: {}": "Täsmäävät: {}", + "Merging… {stage} {:.0}%": "Yhdistetään… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Siirrä kuvia kohteeseen ”{1}”: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Siirretäänkö kuvat kohteeseen ”{1}” ({n}){0:.0}?", + "Move {what} to Recently Deleted?": "Siirretäänkö {what} Äskettäin poistettuihin?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Siirretty kuvia kohteeseen ”{1}”: {n}{0:.0}. Muokkaa → Kumoa siirtää ne takaisin.", + "Moved {} photo(s)": "Siirretty kuvia: {}", + "Moved; {} photo(s) relinked": "Siirretty; yhdistetty uudelleen kuvia: {}", + "Named ({named_n})": "Nimetyt ({named_n})", + "New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”", + "On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa", + "Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään", + "Preset: {name}": "Esiasetus: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Vain esikatselu — muokataan kameran upotettua JPEG-kuvaa ({})", + "Preview only — {}": "Vain esikatselu — {}", + "Previews ready for {done} photo{plural}": "Esikatselut valmiina {done} kuvalle{plural:.0}", + "Profile: {}": "Profiili: {}", + "Proof Copy ({})": "Vedoskopio ({})", + "Proof Preview · {}": "Vedoksen esikatselu · {}", + "Put inside “{parent}”": "Laita kohteen ”{parent}” sisään", + "Quick Develop · {n} photos": "Pikakehitys · kuvia: {n}", + "Quick Develop: {name}": "Pikakehitys: {name}", + "Rated {}": "Luokitus {}", + "Reading Lightroom catalog… {done} of {total}": "Luetaan Lightroom-katalogia… {done} / {total}", + "Reading photos… {done} of {total}": "Luetaan kuvia… {done} / {total}", + "Reading photos… {} of {}": "Luetaan kuvia… {} / {}", + "Redo {}": "Tee uudelleen {}", + "Remove “{name}”": "Poista ”{name}”", + "Remove “{name}” and its {count} photo{} from the library?": "Poistetaanko ”{name}” ja sen kuvat ({count}{:.0}) kirjastosta?", + "Remove “{}” from Favorites": "Poista ”{}” suosikeista", + "Removed from {}": "Poistettu kohteesta {}", + "Removed {what} from {name}": "{what} poistettu kohteesta {name}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "”{name}” poistettu {n} kuvalta. Muokkaa → Kumoa palauttaa sen.", + "Rename {n} Photos…": "Nimeä uudelleen {n} kuvaa…", + "Rename “{name}”": "Nimeä uudelleen ”{name}”", + "Renames {n} face{} across the library. The files are not changed.": "Nimeää uudelleen kasvoja koko kirjastossa: {n}{:.0}. Tiedostoja ei muuteta.", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Nimeää ”{from}” uudelleen {n} kuvassa{:.0} (myös sen alla olevat avainsanat). Käytä |-merkkiä tasojen erottamiseen, esim. Matka|Italia. Olemassa oleva nimi yhdistää nämä kaksi.", + "Rendering on the CPU: {why}": "Renderöidään suorittimella: {why}", + "Replace {} with:": "Korvaa {} seuraavalla:", + "Reset {name}": "Nollaa {name}", + "Reset {} Channel": "Nollaa kanava {}", + "Restore {}": "Palauta {}", + "Restore {} Photo{}": "Palauta kuvia: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Äskettäin poistetuista palautettu kuvia: {}{:.0}", + "Running on the processor: {}.": "Käytössä suoritin: {}.", + "Running on {}.": "Käytössä: {}.", + "Saved export preset “{}”": "Vientiesiasetus ”{}” tallennettu", + "Saved template {name}": "Mallipohja {name} tallennettu", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Pisteyttää {scope} tarkennuksen ja valotuksen mukaan ja etsii samankaltaiset otokset, jotka on otettu sekuntien sisällä toisistaan (sarjat).", + "Show {hidden} hidden location{}": "Näytä piilotettuja sijainteja: {hidden}{:.0}", + "Showing {n} of {}": "Näytetään {n} / {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Näyttää mallin ehdot, lataa sitten {} osoitteesta {host}, asentaa sen ja ottaa sen käyttöön. Mitään muuta ei lähetetä.", + "Skipped {problem}": "Ohitettu {problem}", + "Slideshow exported: {what}": "Diaesitys viety: {what}", + "Smart album: {}": "Älyalbumi: {}", + "Tagged {n} photo{} from the tracklog": "Reittilokista merkittyjä kuvia: {n}{:.0}", + "Template preview: {name}": "Mallipohjan esikatselu: {name}", + "The SAM 3 download failed: {e}": "SAM 3:n lataus epäonnistui: {e}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Kuvien XMP-sivutiedostoja ei voitu kirjoittaa ({n}; onko niiden kansio offline-tilassa?). LightCraft yrittää edelleen.", + "There is a keyword set “{name}” already": "Avainsanajoukko ”{name}” on jo olemassa", + "There is a “{name}” at the top level already. Merge the two?": "Ylimmällä tasolla on jo ”{name}”. Yhdistetäänkö ne?", + "Trained on: {provenance}": "Koulutettu aineistolla: {provenance}", + "Undo {}": "Kumoa {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tuntemattomat tunnisteet{s:.0} {} pysyvät sellaisinaan{:.0} (katso Tunnisteet)", + "Update “{name}” with Current Settings": "Päivitä ”{name}” nykyisillä asetuksilla", + "Updated {name}": "{name} päivitetty", + "Updated “{name}”": "”{name}” päivitetty", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versio {} — clean-room-menetelmällä tehty, puhtaasti Rustilla toteutettu kuvakirjasto ja raw-kehitin.", + "Version: {}": "Versio: {}", + "Virtual copy “{name}” of {of}": "Virtuaalikopio ”{name}” kuvasta {of}", + "Working on {what} for {} photos…": "Käsitellään {what} {} kuvalle…", + "XMP adjustments were not imported for {path}: {fields}.": "XMP-säätöjä ei tuotu kohteelle {path}: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-profiilia ”{profile}” ei käytetty kohteelle {path}; värit voivat poiketa Lightroomista.", + "new, inside {parent}": "uusi, kohteen {parent} sisällä", + "none (CPU rendering): {why}": "ei mitään (suoritinrenderöinti): {why}", + "not added to Apple Photos: {e}": "ei lisätty Apple Kuviin: {e}", + "the {n} selected photos": "valitut kuvat ({n})", + "the {} photos in view": "näkymän kuvat ({})", + "these {count} photos": "nämä kuvat ({count})", + "{:.1} MB": "{:.1} Mt", + "{count} more warnings.": "Varoituksia lisäksi: {count}.", + "{count} smart preview{} · {:.1} MB": "Älyesikatseluita: {count}{:.0} · {:.1} Mt", + "{done} of {total}": "{done} / {total}", + "{done} of {total} MB": "{done} / {total} Mt", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "Kansioita: {f} (ja niiden alikansiot)", + "{label} Grading": "{label}: väriluokittelu", + "{label} {d:+} on every selected photo": "{label} {d:+} jokaisessa valitussa kuvassa", + "{label} {index} of {n}": "{label} {index} / {n}", + "{label}, {n} photos": "{label}, kuvia: {n}", + "{matching} of {t} photos": "Kuvia: {matching} / {t}", + "{matching} photos": "Kuvia: {matching}", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Puuttuvia alkuperäisiä: {missing}. Niiden katalogitietueet ovat käytettävissä; yhdistä ne uudelleen komennolla Etsi puuttuvat kuvat.", + "{mode} spot {} of {}": "{mode} täplä {} / {}", + "{month}": "{month}", + "{month} {year}": "{month}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} epäonnistui)", + "{name} Label": "Merkintä: {name}", + "{name} Off": "{name} pois", + "{name} On": "{name} päällä", + "{name} opened for editing; it is stacked with the original": "{name} avattu muokattavaksi; se pinotaan alkuperäisen kanssa", + "{name} opened in the other editor": "{name} avattu toisessa muokkausohjelmassa", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — kaksoisnapsauta nollataksesi sen", + "{n} JPEG files": "JPEG-tiedostoja: {n}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Ohitettuja paikallisia kuvia: {n}{:.0}: albumit sisältävät kirjaston kuvia (lisää ensin Omiin kuviin)", + "{n} added to Apple Photos": "{n} lisätty Apple Kuviin", + "{n} added to Apple Photos album “{album}”": "{n} lisätty Apple Kuvien albumiin “{album}”", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Muutoksia, joita ei voitu kirjoittaa levylle: {n}{:.0}: {e}\nJos lopetat nyt, menetät ne.{:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Muistiin tallennettuja, levylle kirjoittamattomia muutoksia: {n}{:.0}: {e}\nLightCraft yrittää automaattisesti uudelleen; nyt lopettaminen menettäisi ne.{:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Muistiin tallennettuja, levylle kirjoittamattomia muutoksia: {n}{:.0}: {e} — LightCraft yrittää uudelleen", + "{n} found · {sel} selected": "Löytyi: {n} · valittu: {sel}", + "{n} originals opened in the other editor": "Toisessa muokkausohjelmassa avattuja alkuperäisiä: {n}", + "{n} pages": "Sivuja: {n}", + "{n} photos": "Kuvia: {n}", + "{n} photos in view · {slides} slides · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}", + "{n} photos in {} are hidden by the filters above": "Yllä olevat suodattimet piilottavat kuvia kohteessa {}: {n}", + "{n} photos opened for editing; each is stacked with its original": "Muokattavaksi avattuja kuvia: {n}; kukin pinotaan alkuperäisensä kanssa", + "{n} photo{}": "Kuvia: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Täsmääviä kuvia: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa", + "{n} photo{} now · updates automatically as photos change": "Kuvia nyt: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa", + "{n} preset{}": "Esiasetuksia: {n}{:.0}", + "{n} raws can now be developed from their raw data": "Raw-kuvia, jotka voidaan nyt kehittää raakadatastaan: {n}", + "{n} selected photos · {pages} pages": "Valittuja kuvia: {n} · sivuja: {pages}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Samankaltaisia kuvia: {n}{:.0} · Näytä → Tyhjennä suodattimet näyttääksesi kaikki", + "{n} tasks are still running: {names}. Quitting stops them.": "Käynnissä olevia tehtäviä: {n}: {names}. Lopettaminen pysäyttää ne.", + "{n} {noun}": "{n} {noun}", + "{pct} %": "{pct} %", + "{photos} catalog records processed; {collections} collections.": "Käsiteltyjä katalogitietueita: {photos}; kokoelmia: {collections}.", + "{pid} (missing)": "{pid} (puuttuu)", + "{sel_n} selected · {counted}": "Valittu: {sel_n} · {counted}", + "{size} GB": "{size} Gt", + "{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.", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{s} s of AV1 video": "{s} s AV1-videota", + "{verb} {n} Photo{}": "{verb} kuvia: {n}{:.0}", + "{weekday}, {day}": "{weekday} {day}.", + "{weekday}, {day} {month} {year}": "{weekday} {day}.{month}.{year}", + "{what} merge added": "{what}: yhdistetty kuva lisätty", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: yhdistetty kuva lisätty koossa {mp}, joka on suurin mahdollinen panoraama (täysi koko on {full})", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: yhdistetty kuva lisätty koossa {mp}: sen täysi koko, {full}, vaatisi enemmän muistia kuin tällä tietokoneella on", + "{what} merge cancelled": "{what}: yhdistäminen peruttu", + "{what} merge failed: {e}": "{what}: yhdistäminen epäonnistui: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}}-ohjausmerkit: {}", + "{} KB": "{} kt", + "{} MB": "{} Mt", + "{} and higher": "{} ja ylöspäin", + "{} contributors · {} commits": "Tekijöitä: {} · committeja: {}", + "{} failed": "{} epäonnistui", + "{} files": "Tiedostoja: {}", + "{} files and {f} folder{}": "Tiedostoja: {}, kansioita: {f}{:.0}", + "{} item{}": "Kohteita: {}{:.0}", + "{} label (click more labels to show any of them)": "Merkintä: {} (napsauta lisää merkintöjä näyttääksesi minkä tahansa niistä)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} / 4 apuviivaa — vedä pysty- tai vaakasuoraksi tarkoitettujen viivojen suuntaisesti.", + "{} of {} from {host}": "{} / {} kohteesta {host}", + "{} photos": "Kuvia: {}", + "{} photos · exposures {} EV": "Kuvia: {} · valotukset {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Kuvia: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Ei luettavissa olevia kuvia: {}{:.0}", + "{} sample{}": "Näytteitä: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Täpliä tässä kuvassa: {}", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Säilytetään kirjaston ”denoise”-kansiossa; vanhimmat, joita mikään kuva ei käytä, poistuvat ensin, kun raja tulee vastaan.", + "{} — show their photos": "{} — näytä niiden kuvat", + "· {dups} already in the library": "· {dups} jo kirjastossa", + "· {in_source} identical copies": "· identtisiä kopioita: {in_source}", + "· {n} photo{}": "· kuvia: {n}{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} Äskettäin poistetuissa", + "“{parent}” has a “{name}” already. Merge the two?": "Kohteessa ”{parent}” on jo ”{name}”. Yhdistetäänkö ne?", + "“{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ää" +} diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json new file mode 100644 index 000000000..ff1707ac7 --- /dev/null +++ b/crates/ui-egui/locales/fi.json @@ -0,0 +1,2315 @@ +{ + " (library default)": " (kirjaston oletus)", + " (inverted)": " (käänteinen)", + " · best of its burst": " · sarjan paras", + " · in a burst": " · osa sarjaa", + "% of all commits": "% kaikista commiteista", + "& Containing": "& sisältää", + "(not in the library)": "(ei kirjastossa)", + "+ Group": "+ Ryhmä", + "+ Rule": "+ Sääntö", + ", picked": ", valittu kuva", + ", rejected": ", hylätty", + "0: nothing is rejected.": "0: mitään ei hylätä.", + "1 day": "1 päivä", + "1 raw can now be developed from its raw data": "1 raw-kuva voidaan nyt kehittää raakadatastaan", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · nykyiset muokkaukset · sovita rajaamatta", + "2026-04 or 2026-04-12": "2026-04 tai 2026-04-12", + "= exactly": "= täsmälleen", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9-kehys, jossa mustat palkit ylä- ja alapuolella, otsikko kulmassa, häivytys mustan kautta ja päätöskortti.", + "A blank template keeps the original names.": "Tyhjä mallipohja säilyttää alkuperäiset nimet.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Tumma liuskekivitausta, jossa kunkin kuvan alla valotus, ISO ja polttoväli sekä kamera ja objektiivi.", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Kasvomalli käyttää GPU:ta vain, jos tarkistus osoittaa sen antavan samat tulokset nopeammin.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Suodatin piilottaa tämän näkymän kuvat: muuta sitä tai tyhjennä se (Näytä → Tyhjennä suodattimet)", + "A folder is already being synchronized": "Kansio on jo synkronoitumassa", + "A folder is being synchronized": "Kansiota synkronoidaan", + "A keyword set needs a name": "Avainsanajoukolle täytyy antaa nimi", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Vaaleanharmaa seinä, valkoinen paspis ja pehmeä varjo; kuvateksti ja luokitus kuvan alla.", + "A one-time download of about": "Kertaluonteinen lataus, noin", + "A pen's pressure sets the size and flow of the brush": "Kynän paine määrittää sivellimen koon ja virtauksen", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Dia pysyy näkyvillä kestonsa ajan, häivytys mukaan lukien. Häivytys 0 vaihtaa suoraan.", + "AI Denoise": "Tekoälykohinanpoisto", + "AI Denoise Model": "Tekoälykohinanpoiston malli", + "AI Denoise is enabled on the requesting photo": "Tekoälykohinanpoisto on käytössä sitä pyytävällä kuvalla", + "AI Denoise is off: press Use on a model.": "Tekoälykohinanpoisto on pois päältä: paina mallin kohdalla Käytä.", + "AI Denoise needs a model. Install one in Settings.": "Tekoälykohinanpoisto tarvitsee mallin. Asenna sellainen asetuksissa.", + "AI Mask Model Status": "Tekoälymaskin mallin tila", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-video, jossa kutakin diaa näytetään sen keston ajan häivytyksineen. Ei ääntä: musiikkia ei vielä viedä.", + "About": "Tietoja", + "About LightCraft": "Tietoja LightCraftista", + "Accept & Download": "Hyväksy ja lataa", + "Active": "Aktiivinen", + "Activity": "Toiminta", + "Actual size": "Todellinen koko", + "Add": "Lisää", + "Add Catchlight": "Lisää silmänvalo", + "Add Face Model…": "Lisää kasvomalli…", + "Add Folder to Local": "Lisää kansio paikalliseen", + "Add Mask": "Lisää maski", + "Add Object Click": "Lisää kohdenapsautus", + "Add Point Color Sample": "Lisää pistevärin näyte", + "Add Red Eye Correction": "Lisää punasilmäisyyden korjaus", + "Add Remove Spot": "Lisää poistotäplä", + "Add Selected Photos": "Lisää valitut kuvat", + "Add a Face Recognition Model": "Lisää kasvojentunnistusmalli", + "Add a cell": "Lisää solu", + "Add a model file…": "Lisää mallitiedosto…", + "Add a rule (⌥-click: a group)": "Lisää sääntö (⌥-napsautus: ryhmä)", + "Add in place": "Lisää paikalleen", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Lisää paikalleen: kirjasto viittaa tiedostoihin niiden nykyisessä sijainnissa; mitään ei kopioida tai siirretä.", + "Add keyword": "Lisää avainsana", + "Add number": "Lisää numero", + "Add text overlay": "Lisää tekstipeittokuva", + "Add to": "Lisää kohteeseen", + "Add to Album": "Lisää albumiin", + "Add to All Selected Photos": "Lisää kaikkiin valittuihin kuviin", + "Add to Apple Photos": "Lisää Apple Kuviin", + "Add to Favorites": "Lisää suosikkeihin", + "Add to Local": "Lisää paikalliseen", + "Add to My Photos": "Lisää Omiin kuviin", + "Add to My Photos First": "Lisää ensin Omiin kuviin", + "Add to Selected Photos": "Lisää valittuihin kuviin", + "Add to Target Album": "Lisää kohdealbumiin", + "Add to this mask": "Lisää tähän maskiin", + "Add/Subtract/Intersect Mask": "Lisää/vähennä/leikkaa maski", + "Added to photos you import that don't already have it.": "Lisätään tuotaviin kuviin, joilla sitä ei vielä ole.", + "Adding": "Lisätään", + "Adding Lightroom photos…": "Lisätään Lightroom-kuvia…", + "Adding photos…": "Lisätään kuvia…", + "Adding to Apple Photos": "Lisätään Apple Kuviin", + "Adding to Apple Photos…": "Lisätään Apple Kuviin…", + "Advanced search…": "Tarkennettu haku…", + "After": "Jälkeen", + "After 30 days": "30 päivän kuluttua", + "After 7 days": "7 päivän kuluttua", + "After 90 days": "90 päivän kuluttua", + "After a year": "Vuoden kuluttua", + "After installation, AI Denoise will turn on for the requesting photo.": "Asennuksen jälkeen tekoälykohinanpoisto otetaan käyttöön sitä pyytävälle kuvalle.", + "Album": "Albumi", + "Albums": "Albumit", + "Albums hold library photos: Add to My Photos first": "Albumit sisältävät kirjaston kuvia: lisää ensin Omiin kuviin", + "All": "Kaikki", + "All Metadata": "Kaikki metatiedot", + "All Metadata…": "Kaikki metatiedot…", + "All Photos": "Kaikki kuvat", + "All rights reserved": "Kaikki oikeudet pidätetään", + "All the photos imported from this disk are included.": "Kaikki tältä levyltä tuodut kuvat sisältyvät.", + "All types": "Kaikki tyypit", + "Already installed. Installing again is harmless.": "On jo asennettu. Uudelleenasennus on vaaratonta.", + "Already installed. Remove it before installing a replacement.": "On jo asennettu. Poista se ennen korvaavan mallin asentamista.", + "Also the masks of the same name on the other selected photos": "Myös samannimiset maskit muissa valituissa kuvissa", + "Alt Text": "Vaihtoehtoinen teksti", + "Always": "Aina", + "Amount": "Määrä", + "Analyzing the photo for AI masks…": "Analysoidaan kuvaa tekoälymaskeja varten…", + "Anchored to": "Kiinnitetty kohteeseen", + "Angle": "Kulma", + "Animal Eye Tracking": "Eläinten silmien seuranta", + "Another library was opened since the photos were dropped: nothing was moved": "Toinen kirjasto avattiin kuvien pudottamisen jälkeen: mitään ei siirretty", + "Any Searchable Text": "Mikä tahansa haettava teksti", + "Any camera": "Mikä tahansa kamera", + "Any keyword": "Mikä tahansa avainsana", + "Any lens": "Mikä tahansa objektiivi", + "Aperture": "Aukko", + "Appearance": "Ulkoasu", + "Appearance Mode": "Ulkoasutila", + "Appearance Settings…": "Ulkoasuasetukset…", + "Apple Photos Imports": "Apple Kuvien tuonnit", + "Application": "Sovellus", + "Apply": "Käytä", + "Apply Color Label Set": "Käytä värimerkintäjoukkoa", + "Apply Filter Preset": "Käytä suodinesiasetusta", + "Apply Metadata Preset": "Käytä metatietoesiasetusta", + "Apply Point Curve Preset": "Käytä pistekäyrän esiasetusta", + "Apply Preset": "Käytä esiasetusta", + "Apply Settings JSON": "Käytä asetus-JSON:ia", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Käytä rawiin tallennettua paikallista sävykartoitusta (Apple ProRAW) sellaisena kuin kamera sen renderöi", + "Apply to All Masks": "Käytä kaikkiin maskeihin", + "Approved": "Hyväksytty", + "Aqua": "Turkoosi", + "Aqua Hue": "Turkoosin sävy", + "Aqua Luminance": "Turkoosin kirkkaus", + "Aqua Saturation": "Turkoosin värikylläisyys", + "Arrange Panel": "Järjestä paneeli", + "ArtCraft Website": "ArtCraftin verkkosivusto", + "ArtCraft — more creative apps": "ArtCraft — lisää luovia sovelluksia", + "As Shot": "Kuvattaessa", + "Ascending": "Nouseva", + "Aspect": "Kuvasuhde", + "Aspect Ratio": "Kuvasuhde", + "Aspect preview": "Kuvasuhteen esikatselu", + "Assisted Culling": "Avustettu valinta", + "Assisted Culling…": "Avustettu valinta…", + "Auto": "Auto", + "Auto Advance": "Automaattinen eteneminen", + "Auto Advance Off": "Automaattinen eteneminen pois", + "Auto Advance On": "Automaattinen eteneminen päällä", + "Auto Advance after rating or flagging": "Automaattinen eteneminen luokituksen tai merkitsemisen jälkeen", + "Auto Advance: move to the next photo after rating or flagging": "Automaattinen eteneminen: siirry seuraavaan kuvaan luokituksen tai merkitsemisen jälkeen", + "Auto Align": "Automaattinen kohdistus", + "Auto B&W Mix": "Automaattinen mustavalkosekoitus", + "Auto Crop": "Automaattinen rajaus", + "Auto Import": "Automaattinen tuonti", + "Auto Import Now": "Tuo automaattisesti nyt", + "Auto Import Settings": "Automaattisen tuonnin asetukset", + "Auto Mask": "Automaattinen maski", + "Auto Select": "Automaattinen valinta", + "Auto Settings": "Automaattiset asetukset", + "Auto Straighten": "Automaattinen suoristus", + "Auto Sync": "Automaattinen synkronointi", + "Auto settings applied": "Automaattiset asetukset käytetty", + "Auto-Stack by Capture Time": "Pinoa automaattisesti kuvausajan mukaan", + "Auto-Stack by Capture Time…": "Pinoa automaattisesti kuvausajan mukaan…", + "Auto-Tag Photos from Tracklog": "Merkitse kuvat automaattisesti reittilokista", + "Auto-Tag from Tracklog": "Merkitse automaattisesti reittilokista", + "Auto-Tag from Tracklog…": "Merkitse automaattisesti reittilokista…", + "Automatic": "Automaattinen", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automaattinen renderöi Tarkastelu-näkymän näytettävässä koossa, enintään kuvan omiin pikseleihin asti. Valitse koko rajoittaaksesi sitä: pienempi on nopeampi.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automaattinen käyttää näytönohjainta, kun se on prosessoria nopeampi: molemmat ajastetaan tällä tietokoneella, kun kuva muodostetaan ensimmäisen kerran.", + "Automatically Write Changes into XMP": "Kirjoita muutokset automaattisesti XMP:hen", + "Automatically show full screen": "Näytä automaattisesti koko näytöllä", + "Automatically write changes into XMP sidecars": "Kirjoita muutokset automaattisesti XMP-sivutiedostoihin", + "Autumn Gold": "Syyskulta", + "B&W": "M/V", + "B&W Mix": "Mustavalkosekoitus", + "B&W Mixer": "Mustavalkosekoitin", + "Back": "Takaisin", + "Back Up Library…": "Varmuuskopioi kirjasto…", + "Back to Edit": "Takaisin muokkaukseen", + "Back to Edit (E)": "Takaisin muokkaukseen (E)", + "Back to Grid": "Takaisin ruudukkoon", + "Back to this person": "Takaisin tähän henkilöön", + "Backdrop": "Tausta", + "Background": "Tausta", + "Background Tasks": "Taustatehtävät", + "Background colour": "Taustaväri", + "Background image": "Taustakuva", + "Balance": "Tasapaino", + "Basic": "Perus", + "Before": "Ennen", + "Before the last template": "Ennen viimeistä mallipohjaa", + "Before/After (Y)": "Ennen/jälkeen (Y)", + "Before/After Settings": "Ennen/jälkeen-asetukset", + "Before/After Split": "Ennen/jälkeen jaettuna", + "Before/After Split Top/Bottom": "Ennen/jälkeen jaettuna ylä/ala", + "Before/After Top/Bottom": "Ennen/jälkeen ylä/ala", + "Begin Interaction": "Aloita vuorovaikutus", + "Best of Similar Shots": "Paras samankaltaisista otoksista", + "Binary assets added": "Lisätyt binääriresurssit", + "Binary assets removed": "Poistetut binääriresurssit", + "Bit depth": "Bittisyvyys", + "Black & White": "Mustavalkoinen", + "Blacks": "Mustat", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Haalistettu", + "Blending": "Sekoitus", + "Blue": "Sininen", + "Blue Hour Boost": "Sininen tunti -tehostus", + "Blue Hue": "Sinisen sävy", + "Blue Luminance": "Sinisen kirkkaus", + "Blue Primary": "Sinisen pääväri", + "Blue Saturation": "Sinisen värikylläisyys", + "Blue channel": "Sininen kanava", + "Blue label": "Sininen merkintä", + "Blue/Yellow Fringe": "Sini/keltareunus", + "Blurry photos below the score are flagged as rejects.": "Pisteitä heikommat epäterävät kuvat merkitään hylätyiksi.", + "Bottom": "Alareuna", + "Boundary Warp": "Reunan vääntö", + "Bright & Airy": "Kirkas ja ilmava", + "Bring All to Front": "Tuo kaikki eteen", + "Bring the library up to date with this folder and the folders inside it:": "Päivitä kirjasto vastaamaan tätä kansiota ja sen alikansioita:", + "Browse Folder": "Selaa kansiota", + "Browse Folder…": "Selaa kansiota…", + "Browse Profiles": "Selaa profiileja", + "Browse…": "Selaa…", + "Brush": "Sivellin", + "Brush Settings": "Sivellinasetukset", + "Brush Stroke": "Siveltimenveto", + "Build Previews": "Luo esikatselut", + "Build Smart Previews": "Luo älyesikatselut", + "Building previews": "Luodaan esikatseluja", + "Building smart previews": "Luodaan älyesikatseluja", + "Built-in": "Sisäänrakennettu", + "By Date": "Päivämäärän mukaan", + "By day (YYYY/YYYY-MM-DD)": "Päivittäin (VVVV/VVVV-KK-PP)", + "By month (YYYY/YYYY-MM)": "Kuukausittain (VVVV/VVVV-KK)", + "CPU": "Suoritin", + "CPU threads": "Suoritinsäikeet", + "Cache": "Välimuisti", + "Calibration": "Kalibrointi", + "Camera": "Kamera", + "Camera Info": "Kameran tiedot", + "Camera local tone mapping": "Kameran paikallinen sävykartoitus", + "Camera make and model": "Kameran merkki ja malli", + "Cameras and Cards": "Kamerat ja kortit", + "Can't be moved here": "Tänne ei voi siirtää", + "Cancel": "Peruuta", + "Cancel AI Mask Model Download": "Peruuta tekoälymaskimallin lataus", + "Cancel Background Task": "Peruuta taustatehtävä", + "Cancel Denoise": "Peruuta kohinanpoisto", + "Cancel Denoise Model Download": "Peruuta kohinanpoistomallin lataus", + "Cancel Download": "Peruuta lataus", + "Cancel Face Model Download": "Peruuta kasvomallin lataus", + "Cancel Interaction": "Peruuta vuorovaikutus", + "Cancel Pending Photo Action": "Peruuta odottava kuvatoiminto", + "Cancel Preview Build": "Peruuta esikatselun luonti", + "Cancel pending action": "Peruuta odottava toiminto", + "Candidate": "Ehdokas", + "Caption": "Kuvateksti", + "Capture Date": "Kuvauspäivä", + "Capture date and time in your own format (directives below)": "Kuvauspäivä ja -aika omassa muodossasi (ohjausmerkit alla)", + "Capture date as YYYYMMDD": "Kuvauspäivä muodossa VVVVKKPP", + "Captured": "Kuvattu", + "Captured from": "Kuvattu alkaen", + "Captured until": "Kuvattu asti", + "Cast shadow": "Heitetty varjo", + "Catalog Statistics": "Katalogin tilastot", + "Catchlight": "Silmänvalo", + "Center": "Keskellä", + "Change": "Muuta", + "Change Component Mode": "Vaihda komponenttitilaa", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Tiedostonimissä kiellettyjä merkkejä (/ \\ : * ? \" < > |) korvataan merkillä -; olemassa oleva nimi saa lopun -1, -2….", + "Charcoal": "Hiili", + "Check All": "Valitse kaikki", + "Check Output Path": "Tarkista tulostepolku", + "Check the files of this type": "Tarkista tämän tyyppiset tiedostot", + "Checking and installing the model…": "Tarkistetaan ja asennetaan mallia…", + "Checking names…": "Tarkistetaan nimiä…", + "Checking raw files": "Tarkistetaan raw-tiedostoja", + "Checking the GPU for faces": "Tarkistetaan GPU:ta kasvoja varten", + "Checking the GPU…": "Tarkistetaan GPU:ta…", + "Checking…": "Tarkistetaan…", + "Choose Another Library…": "Valitse toinen kirjasto…", + "Choose Denoise Model": "Valitse kohinanpoistomalli", + "Choose Edit Settings to Copy": "Valitse kopioitavat muokkausasetukset", + "Choose Edit Settings to Copy…": "Valitse kopioitavat muokkausasetukset…", + "Choose Face Recognition Model": "Valitse kasvojentunnistusmalli", + "Choose Folder…": "Valitse kansio…", + "Choose Layout Image…": "Valitse asettelukuva…", + "Choose Monitor Profile": "Valitse näytön profiili", + "Choose Profile…": "Valitse profiili…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Valitse PDF:n kohde vahvistuksen jälkeen. Tulosta PDF katseluohjelmasta.", + "Choose a group": "Valitse ryhmä", + "Choose a model below (Use) to start.": "Aloita valitsemalla alta malli (Käytä).", + "Choose a photo in the grid or filmstrip": "Valitse kuva ruudukosta tai filminauhasta", + "Choose an album…": "Valitse albumi…", + "Choose at least one setting to include": "Valitse vähintään yksi mukaan otettava asetus", + "Choose…": "Valitse…", + "Cinema Wide": "Elokuvaleveä", + "Cinematic": "Elokuvamainen", + "City": "Kaupunki", + "Clarity": "Selkeys", + "Clean Studio": "Puhdas studio", + "Clear": "Tyhjennä", + "Clear Cache": "Tyhjennä välimuisti", + "Clear Denoise Cache": "Tyhjennä kohinanpoiston välimuisti", + "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", + "Clear all": "Tyhjennä kaikki", + "Clear cache": "Tyhjennä välimuisti", + "Clear form": "Tyhjennä lomake", + "Clear rating": "Poista luokitus", + "Clear rating and advance": "Poista luokitus ja siirry eteenpäin", + "Cleared History": "Historia tyhjennetty", + "Click Zoom Ratio": "Zoomaussuhde napsautuksella", + "Click a cell to move, resize or arrange it": "Napsauta solua siirtääksesi, muuttaaksesi sen kokoa tai järjestääksesi sen", + "Click a shortcut, then press the new keys (Esc cancels).": "Napsauta pikanäppäintä ja paina sitten uusia näppäimiä (Esc peruu).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Napsauta tunnistetta lisätäksesi sen. Esimerkit ovat tiedostolle IMG_0042.CR3, joka on otettu 14.1.2026 klo 05:58:48.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Napsauta mallipohjaa ottaaksesi sen käyttöön; osoita yhtä nähdäksesi sen esikatselun valitulla kuvalla. Napsauta omaa mallipohjaa hiiren kakkospainikkeella päivittääksesi, nimetäksesi uudelleen, viedäksesi tai poistaaksesi sen.", + "Click photos in the grid to toggle the keyword; Esc stops": "Napsauta ruudukon kuvia vaihtaaksesi avainsanan päälle tai pois; Esc lopettaa", + "Click the object to select it · ⌥-click leaves a part out": "Napsauta kohdetta valitaksesi sen · ⌥-napsautus jättää osan pois", + "Click the photo to pick a colour · ⇧-click adds more": "Napsauta kuvaa poimiaksesi värin · ⇧-napsautus lisää uusia", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Napsauta kuvaa poimiaksesi värin; ⇧-napsautus lisää uusia (enintään 5)", + "Click to clear these filters": "Napsauta tyhjentääksesi nämä suodattimet", + "Click to name this face": "Napsauta nimetäksesi tämä kasvo", + "Click to restore · double-click to rename": "Napsauta palauttaaksesi · kaksoisnapsauta nimetäksesi uudelleen", + "Click to type a value": "Napsauta kirjoittaaksesi arvon", + "Click to type a value, drag to change it": "Napsauta kirjoittaaksesi arvon tai vedä muuttaaksesi sitä", + "Clone": "Kloonaa", + "Close": "Sulje", + "Close Panel": "Sulje paneeli", + "Close Window": "Sulje ikkuna", + "Close all": "Sulje kaikki", + "Close file": "Sulje tiedosto", + "Close panel": "Sulje paneeli", + "Cloudy": "Pilvinen", + "Collapse": "Tiivistä", + "Collapse All Stacks": "Tiivistä kaikki pinot", + "Collapse Stack": "Tiivistä pino", + "Color": "Väri", + "Color Grading": "Väriluokittelu", + "Color Label": "Värimerkintä", + "Color Label Names": "Värimerkintöjen nimet", + "Color Label Sets": "Värimerkintäjoukot", + "Color Mixer": "Värisekoitin", + "Color Noise Reduction": "Värikohinan vähennys", + "Color Overlay": "Väripeittokuva", + "Color Overlay on B&W": "Väripeittokuva mustavalkokuvalla", + "Color Priority": "Värien prioriteetti", + "Color Range": "Väriväli", + "Color label cleared": "Värimerkintä poistettu", + "Color space": "Väriavaruus", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Väriavaruus: Rec. 2020 HLG (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Väriavaruus: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Väriavaruus: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Väriavaruus: Rec. 2020 PQ, 16-bittinen (HDR PNG)", + "Color space: sRGB (AVIF)": "Väriavaruus: sRGB (AVIF)", + "Color wheel": "Värikiekko", + "Colors": "Värit", + "Colour": "Väri", + "Colour only": "Vain väri", + "Colour wash": "Värivaippa", + "Combine files…": "Yhdistä tiedostot…", + "Combine with the existing person": "Yhdistä olemassa olevaan henkilöön", + "Command Journal": "Komentoloki", + "Comments panel": "Kommenttipaneeli", + "Commits": "Commitit", + "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", + "Concrete Cool": "Betonin viileä", + "Confirm before deleting": "Vahvista ennen poistamista", + "Confirm before moving photos to Recently Deleted": "Vahvista ennen kuvien siirtämistä Äskettäin poistettuihin", + "Confirm before moving photos to another folder on disk": "Vahvista ennen kuvien siirtämistä toiseen kansioon levyllä", + "Constrain Crop": "Rajoita rajaus", + "Contact Sheet PDF": "Kontaktivedos-PDF", + "Contact Sheet PDF…": "Kontaktivedos-PDF…", + "Container": "Säiliö", + "Content-Aware": "Sisältötietoinen", + "Continue": "Jatka", + "Continue Without Saving": "Jatka tallentamatta", + "Contrast": "Kontrasti", + "Contributors": "Tekijät", + "Convert to DNG": "Muunna DNG:ksi", + "Cool Fade": "Viileä haalistus", + "Cool Morning": "Viileä aamu", + "Copy": "Kopioi", + "Copy After's Settings to Before": "Kopioi Jälkeen-asetukset Ennen-puolelle", + "Copy Before to After": "Kopioi Ennen Jälkeen-puolelle", + "Copy Before's Settings to After": "Kopioi Ennen-asetukset Jälkeen-puolelle", + "Copy Edit Settings": "Kopioi muokkausasetukset", + "Copy History Step Settings to Before": "Kopioi historiavaiheen asetukset Ennen-puolelle", + "Copy Metadata": "Kopioi metatiedot", + "Copy Name": "Kopioi nimi", + "Copy Settings…": "Kopioi asetukset…", + "Copy as DNG": "Kopioi DNG:nä", + "Copy into the library (else use the files where they are)": "Kopioi kirjastoon (muuten tiedostoja käytetään niiden nykyisessä sijainnissa)", + "Copy pages": "Kopioi sivut", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopioi tiedostot (kirjaston Originals/-kansioon tai valitsemaasi kansioon)", + "Copy to": "Kopioi kohteeseen", + "Copy to Clipboard": "Kopioi leikepöydälle", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopioi: tiedostot kopioidaan alla olevaan kansioon ja kirjasto käyttää kopioita; alkuperäiset jäävät ennalleen.", + "Copyright": "Tekijänoikeus", + "Copyright Info URL": "Tekijänoikeustietojen URL", + "Copyright Status": "Tekijänoikeuden tila", + "Copyrighted": "Tekijänoikeuden alainen", + "Country": "Maa", + "Create": "Luo", + "Create Album": "Luo albumi", + "Create Album…": "Luo albumi…", + "Create Folder": "Luo kansio", + "Create Folder…": "Luo kansio…", + "Create Keyword": "Luo avainsana", + "Create Keyword Tag": "Luo avainsanatunniste", + "Create New Mask": "Luo uusi maski", + "Create PDF from clipboard": "Luo PDF leikepöydältä", + "Create PDF from file…": "Luo PDF tiedostosta…", + "Create PDF from images…": "Luo PDF kuvista…", + "Create Preset": "Luo esiasetus", + "Create Preset…": "Luo esiasetus…", + "Create Proof Copy": "Luo vedoskopio", + "Create Smart Album": "Luo älyalbumi", + "Create Smart Album from Filter…": "Luo älyalbumi suodattimesta…", + "Create Smart Album…": "Luo älyalbumi…", + "Create Stack": "Luo pino", + "Create Version": "Luo versio", + "Create Version from Step": "Luo versio vaiheesta", + "Create Virtual Copy": "Luo virtuaalikopio", + "Creator": "Tekijä", + "Creator metadata": "Tekijän metatiedot", + "Crisp Landscape": "Terävä maisema", + "Crisp Vista": "Terävä näkymä", + "Crop": "Rajaus", + "Crop & Rotate": "Rajaa ja kierrä", + "Crop & Rotate (C)": "Rajaa ja kierrä (C)", + "Crop Aspect": "Rajauksen kuvasuhde", + "Crop, Rotate, Geometry": "Rajaus, kierto, geometria", + "Cropped": "Rajattu", + "Cross Process": "Ristiinkäsittely", + "Culling": "Karsinta", + "Curve": "Käyrä", + "Curve channel": "Käyrän kanava", + "Custom": "Mukautettu", + "Custom AF Area": "Mukautettu tarkennusalue", + "Custom template…": "Oma mallipohja…", + "Custom…": "Mukautettu…", + "Cut": "Leikkaa", + "Cut pages": "Leikkaa sivut", + "Cycle Crop Overlay": "Vaihda rajauksen peittokuvaa", + "Cycle Crop Overlay Orientation": "Vaihda rajauksen peittokuvan suuntaa", + "Cycle Info Overlay": "Vaihda tietojen peittokuvaa", + "Cycle Mask Overlay Color": "Vaihda maskin peittokuvan väriä", + "Cycle Mask Overlay Mode": "Vaihda maskin peittokuvan tilaa", + "Cycle Tool Overlay": "Vaihda työkalun peittokuvaa", + "Cylindrical": "Lieriömäinen", + "Dark & Moody": "Tumma ja synkkä", + "Dark Mode": "Tumma tila", + "Dark Theme": "Tumma teema", + "Darken": "Tummenna", + "Darks": "Tummat", + "Date": "Päivämäärä", + "Date Groups": "Päivämääräryhmät", + "Day": "Päivä", + "Daylight": "Päivänvalo", + "Decrease Brush Feather": "Pienennä siveltimen pehmeyttä", + "Decrease Brush Size": "Pienennä siveltimen kokoa", + "Decrease Grid Thumbnail Size": "Pienennä ruudukon pikkukuvien kokoa", + "Decrease Rating": "Pienennä luokitusta", + "Deep Sky": "Syvä taivas", + "Defringe": "Värivirheen korjaus", + "Deghost Amount": "Haamukuvien poiston määrä", + "Dehaze": "Sumunpoisto", + "Delete": "Poista", + "Delete (⌫)": "Poista (⌫)", + "Delete Album": "Poista albumi", + "Delete All Masks": "Poista kaikki maskit", + "Delete Cell": "Poista solu", + "Delete Color Label Set": "Poista värimerkintäjoukko", + "Delete Component": "Poista komponentti", + "Delete Export Preset": "Poista vientiesiasetus", + "Delete Filter Preset": "Poista suodinesiasetus", + "Delete Imported Profile": "Poista tuotu profiili", + "Delete Keyword": "Poista avainsana", + "Delete Keyword Set": "Poista avainsanajoukko", + "Delete Keyword…": "Poista avainsana…", + "Delete Mask": "Poista maski", + "Delete Metadata Preset": "Poista metatietoesiasetus", + "Delete Permanently": "Poista pysyvästi", + "Delete Photo": "Poista kuva", + "Delete Photos": "Poista kuvat", + "Delete Point Color": "Poista pistevärin", + "Delete Point Color Sample": "Poista pistevärin näyte", + "Delete Point Curve Preset": "Poista pistekäyrän esiasetus", + "Delete Preset": "Poista esiasetus", + "Delete Red Eye": "Poista punasilmäisyys", + "Delete Red Eye Correction": "Poista punasilmäisyyden korjaus", + "Delete Spot": "Poista täplä", + "Delete Version": "Poista versio", + "Delete all": "Poista kaikki", + "Delete all spots": "Poista kaikki täplät", + "Delete them": "Poista ne", + "Delete…": "Poista…", + "Denoise Model Downloads": "Kohinanpoistomallien lataukset", + "Denoise Models": "Kohinanpoistomallit", + "Denoise Photos": "Poista kuvien kohinaa", + "Denoise Pump": "Kohinanpoiston pumppu", + "Denoise Settings": "Kohinanpoiston asetukset", + "Denoise Status": "Kohinanpoiston tila", + "Denoise all photos with an Amount": "Poista kohinaa kaikista kuvista Määrä-arvolla", + "Density": "Tiheys", + "Depth Range": "Syvyysalue", + "Descending": "Laskeva", + "Describe": "Kuvaile", + "Describe what to keep": "Kuvaile, mitä säilytetään", + "Describe what to leave out": "Kuvaile, mitä jätetään pois", + "Describe what to select": "Kuvaile, mitä valitaan", + "Describe…": "Kuvaile…", + "Description": "Kuvaus", + "Deselect": "Poista valinta", + "Deselect All": "Poista kaikkien valinta", + "Desert Heat": "Aavikon kuumuus", + "Desert Warmth": "Aavikon lämpö", + "Desktop": "Työpöytä", + "Destination library": "Kohdekirjasto", + "Detail": "Tarkastelu", + "Detail (D)": "Tarkastelu (D)", + "Details": "Tiedot", + "Detect Faces": "Tunnista kasvot", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Kasvojen tunnistus (Kuva-valikko) tarvitsee sen: pieni kertaluonteinen lataus.", + "Detection": "Havaitseminen", + "Develop": "Kehitys", + "Diagonal": "Vinoittain", + "Discard Preview Cache": "Hylkää esikatselun välimuisti", + "Discarding smart previews": "Hylätään älyesikatseluja", + "Display": "Näyttö", + "Display P3": "Display P3", + "Display Profile": "Näyttöprofiili", + "Display name": "Näyttönimi", + "Display profile not used": "Näyttöprofiilia ei käytetä", + "Display theme": "Näytön teema", + "Distortion": "Vääristymä", + "Document properties…": "Asiakirjan ominaisuudet…", + "Don't ask again": "Älä kysy uudelleen", + "Don't enlarge": "Älä suurenna", + "Done": "Valmis", + "Download": "Lataa", + "Download AI Mask Model": "Lataa tekoälymaskimalli", + "Download Denoise Model": "Lataa kohinanpoistomalli", + "Download Face Model": "Lataa kasvomalli", + "Download Place Names (GeoNames)": "Lataa paikannimet (GeoNames)", + "Download the SAM 3 Model?": "Ladataanko SAM 3 -malli?", + "Downloaded and checked. Installing…": "Ladattu ja tarkistettu. Asennetaan…", + "Downloading model": "Ladataan mallia", + "Downloading place names": "Ladataan paikannimiä", + "Downloads": "Lataukset", + "Drag Crop Handle": "Vedä rajauskahvaa", + "Drag along the horizon; double-click for Auto": "Vedä horisontin suuntaisesti; kaksoisnapsauta Automaattisesta", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Vedä kuvassa silmän päälle; sen sisällä oleva pupilli löytyy automaattisesti.", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Vedä asettaaksesi sävyn ja värikylläisyyden. Pidä Shift-näppäintä pohjassa säilyttääksesi sävyn, Cmd/Ctrl säilyttääksesi värikylläisyyden, Alt/Option hienosäätöön. Kaksoisnapsauta nollataksesi.", + "Draw Guides": "Piirrä apuviivoja", + "Drop here for the top level": "Pudota tähän ylimmälle tasolle", + "Duplicate": "Monista", + "Duplicate Component": "Monista komponentti", + "Duplicate Mask": "Monista maski", + "Duplicate and Invert Mask": "Monista ja käännä maski", + "Duration": "Kesto", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Jokainen / aloittaa kansiotason; tunnisteet täytetään kuvakohtaisesti (taso, jolta metatiedot puuttuvat, on ”tuntematon”).", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jokainen kuva mahdollisimman suurena mustalla taustalla, lyhyellä ristihäivytyksellä. Mitään ei piirretä sen päälle.", + "Edge": "Reuna", + "Edge to Edge": "Reunasta reunaan", + "Edit": "Muokkaa", + "Edit (E)": "Muokkaa (E)", + "Edit Capture Time": "Muokkaa kuvausaikaa", + "Edit Capture Time…": "Muokkaa kuvausaikaa…", + "Edit Color Label Names": "Muokkaa värimerkintöjen nimiä", + "Edit Color Label Names…": "Muokkaa värimerkintöjen nimiä…", + "Edit Copy for External Editor": "Muokkaa kopiota ulkoisessa muokkausohjelmassa", + "Edit Date": "Muokkaa päivämäärää", + "Edit In": "Muokkaa ohjelmassa", + "Edit Info": "Muokkaa tietoja", + "Edit Keyword": "Muokkaa avainsanaa", + "Edit Keyword Set": "Muokkaa avainsanajoukkoa", + "Edit Keyword Tag": "Muokkaa avainsanatunnistetta", + "Edit Keyword Tag…": "Muokkaa avainsanatunnistetta…", + "Edit Label Names": "Muokkaa merkintöjen nimiä", + "Edit Label Names…": "Muokkaa merkintöjen nimiä…", + "Edit Mask": "Muokkaa maskia", + "Edit Mask Component": "Muokkaa maskin komponenttia", + "Edit Sections": "Muokkaa osioita", + "Edit Set…": "Muokkaa joukkoa…", + "Edit Smart Album": "Muokkaa älyalbumia", + "Edit Smart Album…": "Muokkaa älyalbumia…", + "Edit Spot": "Muokkaa täplää", + "Edit in External Editor": "Muokkaa ulkoisessa muokkausohjelmassa", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Muokkaa HDR:nä: huippuvalot SDR-valkoisen yläpuolella HDR-vienteihin ja -näyttöihin", + "Edit in Other Editor": "Muokkaa toisessa muokkausohjelmassa", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Muokkaa toisessa muokkausohjelmassa (⌥⌘E) avaa kopion tässä sovelluksessa (macOS:ssä sovelluksen nimi, muualla ohjelman polku).", + "Edit in PhotoCraft": "Muokkaa PhotoCraftissa", + "Edit settings copied": "Muokkausasetukset kopioitu", + "Edited": "Muokattu", + "Edited or not": "Muokattu vai ei", + "Effect": "Tehoste", + "Effects": "Tehosteet", + "Empty Recently Deleted": "Tyhjennä Äskettäin poistetut", + "Enable Lens Corrections": "Ota objektiivikorjaukset käyttöön", + "Enable Profile Corrections": "Ota profiilikorjaukset käyttöön", + "End": "Loppu", + "End Interaction": "Lopeta vuorovaikutus", + "End Slideshow": "Lopeta diaesitys", + "Ending": "Lopetus", + "Ending screen": "Lopetusnäyttö", + "English": "English", + "Enter Full Screen": "Siirry koko näytön tilaan", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Anna päivämäärä muodossa VVVV-KK-PP TT:MM:SS", + "Enter to confirm, Esc to cancel": "Vahvista Enterillä, peruuta Escillä", + "Erase": "Pyyhi", + "Evaluate Face Recognition": "Arvioi kasvojentunnistusta", + "Every EXIF, GPS and XMP field in the file": "Jokainen tiedoston EXIF-, GPS- ja XMP-kenttä", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Jokainen kuva rajattuna täyttämään koko näytön, hitailla ristihäivytyksillä.", + "Existing previews": "Olemassa olevat esikatselut", + "Exit": "Poistu", + "Expand": "Laajenna", + "Expand All Stacks": "Laajenna kaikki pinot", + "Expand Stack": "Laajenna pino", + "Expand/Collapse Stack": "Laajenna/tiivistä pino", + "Expanded Flexible Spot": "Laajennettu joustava täplä", + "Export": "Vie", + "Export Album with Preset": "Vie albumi esiasetuksella", + "Export Album…": "Vie albumi…", + "Export As…": "Vie nimellä…", + "Export Contact Sheet PDF": "Vie kontaktivedos-PDF", + "Export Containing Keywords": "Vie avainsanat sisältäen", + "Export Effect Presets": "Vie tehosteesiasetukset", + "Export Effect Presets…": "Vie tehosteesiasetukset…", + "Export Group…": "Vie ryhmä…", + "Export JPEG…": "Vie JPEG…", + "Export Keywords": "Vie avainsanat", + "Export Keywords…": "Vie avainsanat…", + "Export Now": "Vie nyt", + "Export PDF…": "Vie PDF…", + "Export Point Curve Presets": "Vie pistekäyrien esiasetukset", + "Export Point Curve Presets…": "Vie pistekäyrien esiasetukset…", + "Export Presets": "Vie esiasetukset", + "Export Presets…": "Vie esiasetukset…", + "Export Slideshow": "Vie diaesitys", + "Export Slideshow Template": "Vie diaesityksen mallipohja", + "Export Slideshow: folder for the JPEG files": "Vie diaesitys: kansio JPEG-tiedostoille", + "Export Slideshow…": "Vie diaesitys…", + "Export Synonyms": "Vie synonyymit", + "Export User Presets…": "Vie käyttäjän esiasetukset…", + "Export Video…": "Vie video…", + "Export stopped unexpectedly": "Vienti pysähtyi odottamatta", + "Export to": "Vie kohteeseen", + "Export with Preset": "Vie esiasetuksella", + "Export with Previous": "Vie edellisillä asetuksilla", + "Exporting": "Viedään", + "Exporting contact sheet": "Viedään kontaktivedosta", + "Exporting slideshow": "Viedään diaesitystä", + "Export…": "Vie…", + "Exposure": "Valotus", + "Exposure (shutter · aperture · ISO)": "Valotus (suljinaika · aukko · ISO)", + "Extended Description": "Laajennettu kuvaus", + "External Editing": "Ulkoinen muokkaus", + "External Editor": "Ulkoinen muokkausohjelma", + "Face Boxes": "Kasvorajat", + "Face Model Downloads": "Kasvomallien lataukset", + "Face Models": "Kasvomallit", + "Face Recognition On/Off": "Kasvojentunnistus päälle/pois", + "Face detector": "Kasvojentunnistin", + "Face models (ONNX)": "Kasvomallit (ONNX)", + "Face names written to XMP by Lightroom and other apps show up here.": "Lightroomin ja muiden sovellusten XMP:hen kirjoittamat kasvojen nimet näkyvät täällä.", + "Face recognition": "Kasvojentunnistus", + "Face tracking": "Kasvojen seuranta", + "Faces": "Kasvot", + "Faces of a Person": "Henkilön kasvot", + "Fade": "Häivytys", + "Fade duration": "Häivytyksen kesto", + "Faded Matte": "Haalistettu matta", + "Faded Slide": "Haalistettu dia", + "Faded Urban": "Haalistettu urbaani", + "Failed": "Epäonnistui", + "Failed its last test": "Viimeisin testi epäonnistui", + "Favorite": "Suosikki", + "Favorite Preset": "Suosikkiesiasetus", + "Favorite Profile": "Suosikkiprofiili", + "Favorites": "Suosikit", + "Feather": "Häivytys", + "File": "Tiedosto", + "File & date": "Tiedosto ja päivä", + "File Extension": "Tiedostopääte", + "File Format": "Tiedostomuoto", + "File Name": "Tiedostonimi", + "File Path": "Tiedostopolku", + "File Size": "Tiedostokoko", + "File Type": "Tiedostotyyppi", + "File name": "Tiedostonimi", + "File types:": "Tiedostotyypit:", + "Filename": "Tiedostonimi", + "Filename captions": "Tiedostonimet kuvateksteinä", + "Files with the same content as another file in this import: only the first is imported.": "Tiedostot, joiden sisältö on sama kuin toisella tämän tuonnin tiedostolla: vain ensimmäinen tuodaan.", + "Fill": "Täyttö", + "Fill Edges": "Täytä reunat", + "Fill Location": "Täytä sijainti", + "Fill Location from GPS": "Täytä sijainti GPS:stä", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Täytä tyhjät Kaupunki, Osavaltio ja Maa GPS:stä (offline-paikannimet GeoNamesista)", + "Film": "Filmi", + "Filmstrip": "Filminauha", + "Filmstrip (/)": "Filminauha (/)", + "Filter": "Suodatin", + "Filter Bar": "Suodatinpalkki", + "Filter Keywords": "Suodata avainsanoja", + "Filter Presets": "Suodinesiasetukset", + "Filter bar": "Suodatinpalkki", + "Filter fields": "Suodatinkentät", + "Filters": "Suodattimet", + "Find Blemishes": "Etsi virheet", + "Find Dust Spots": "Etsi pölyhiukkaset", + "Find Missing Photos": "Etsi puuttuvat kuvat", + "Find Missing Photos…": "Etsi puuttuvat kuvat…", + "Find Similar Photos": "Etsi samankaltaisia kuvia", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Etsi tähän kansioon levyllä lisätyt tai siitä puuttuvat kuvat sekä muiden sovellusten muuttamat XMP-sivutiedostot", + "Find tools and commands…": "Etsi työkaluja ja komentoja…", + "Finding faces": "Etsitään kasvoja", + "Find…": "Etsi…", + "First": "Ensimmäinen", + "First commit": "Ensimmäinen commit", + "Fit": "Sovita", + "Fit (F)": "Sovita (F)", + "Fit on Screen": "Sovita näytölle", + "Fit to width": "Sovita leveyteen", + "Fit visible": "Sovita näkyvään", + "Five stars": "Viisi tähteä", + "Fix the marked rules to save this album.": "Korjaa merkityt säännöt tallentaaksesi tämän albumin.", + "Fixed keys": "Kiinteät näppäimet", + "Flag": "Lippu", + "Flag as Pick": "Merkitse valituksi", + "Flag as Reject": "Merkitse hylätyksi", + "Flagged as Pick": "Merkitty valituksi", + "Flagged as Reject": "Merkitty hylätyksi", + "Flash": "Salama", + "Flexible Spot": "Joustava täplä", + "Flip H": "Käännä vaaka.", + "Flip Horizontal": "Käännä vaakasuunnassa", + "Flip Overlay (⇧O)": "Käännä peittokuva (⇧O)", + "Flip V": "Käännä pysty.", + "Flip Vertical": "Käännä pystysuunnassa", + "Float panel": "Irrota paneeli", + "Flow": "Virtaus", + "Fluorescent": "Loisteputki", + "Focal Length": "Polttoväli", + "Focus": "Tarkennus", + "Focus (assisted culling)": "Tarkennus (avustettu valinta)", + "Focus point": "Tarkennuspiste", + "Folder": "Kansio", + "Folder name (renamed on disk; its photos follow)": "Kansion nimi (nimetään uudelleen levyllä; sen kuvat seuraavat mukana)", + "Folders": "Kansiot", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Kansiot ja {date} käyttävät kuvausaikaa; kuva, jolla sitä ei ole, käyttää tämän päivän päivämäärää.", + "Food": "Ruoka", + "Forget Unchanged Local Photos": "Unohda muuttumattomat paikalliset kuvat", + "Forget unchanged photos": "Unohda muuttumattomat kuvat", + "Form fields panel": "Lomakekenttäpaneeli", + "Format": "Muoto", + "Forward": "Eteenpäin", + "Found automatically": "Löytyi automaattisesti", + "Fr": "Pe", + "Frame rate": "Kuvataajuus", + "Frame time": "Kuva-aika", + "Frame update": "Kuvan päivitys", + "Free": "Vapaa", + "Fresh & Bright": "Raikas ja kirkas", + "Friday": "perjantai", + "Full": "Täysi", + "Full Screen Preview": "Koko näytön esikatselu", + "Full screen mode": "Koko näytön tila", + "Full size": "Täysi koko", + "GPS Track Log": "GPS-reittiloki", + "GPU Rendering": "GPU-renderöinti", + "Gallery Wall": "Galleriaseinä", + "General": "Yleiset", + "Generated demo photo": "Luotu esittelykuva", + "Geometry": "Geometria", + "Get Develop Settings": "Hae kehitysasetukset", + "Global Hue": "Yleinen sävy", + "Global Luminance": "Yleinen kirkkaus", + "Global Saturation": "Yleinen värikylläisyys", + "Glossy": "Kiiltävä", + "Go back to listing them by name": "Palaa listaamaan ne nimen mukaan", + "Go to History Step": "Siirry historiavaiheeseen", + "Golden": "Kultainen", + "Golden Glow": "Kultainen hehku", + "Golden Hour": "Kultainen hetki", + "Grab bag": "Sekalaista", + "Grain": "Rakeisuus", + "Graphic": "Grafiikka", + "Graphics card": "Näytönohjain", + "Green": "Vihreä", + "Green Amount": "Vihreän määrä", + "Green Hue": "Vihreän sävy", + "Green Hue High": "Vihreän sävy ylä", + "Green Hue Low": "Vihreän sävy ala", + "Green Luminance": "Vihreän kirkkaus", + "Green Primary": "Vihreän pääväri", + "Green Saturation": "Vihreän värikylläisyys", + "Green channel": "Vihreä kanava", + "Green label": "Vihreä merkintä", + "Grid": "Ruudukko", + "Grid Info": "Ruudukon tiedot", + "Grid: add the next photo to the selection": "Ruudukko: lisää seuraava kuva valintaan", + "Grid: add the previous photo to the selection": "Ruudukko: lisää edellinen kuva valintaan", + "Grid: first photo of the view": "Ruudukko: näkymän ensimmäinen kuva", + "Grid: last photo of the view": "Ruudukko: näkymän viimeinen kuva", + "Gritty Street": "Rosoinen katu", + "Group": "Ryhmä", + "Group (optional)": "Ryhmä (valinnainen)", + "Group by Date: Automatic": "Ryhmittely päivämäärän mukaan: automaattinen", + "Group by Day": "Ryhmittele päivän mukaan", + "Group by Month": "Ryhmittele kuukauden mukaan", + "Group by Year": "Ryhmittele vuoden mukaan", + "Group by date": "Ryhmittele päivämäärän mukaan", + "Group into Stack": "Ryhmittele pinoksi", + "Group name": "Ryhmän nimi", + "Grouped by parent": "Ryhmitelty ylätason mukaan", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Ryhmitelty: saman avainsanan sisällä olevat avainsanat jakavat sen nimeävän rivin, ja pitkät luettelot ja ehdotukset lyhennetään.", + "Guided": "Ohjattu", + "Guided Upright": "Ohjattu Upright", + "Guided Upright Guides": "Ohjatun Uprightin apuviivat", + "HDR": "HDR", + "HDR Headroom": "HDR-varakorkeus", + "HDR Merge": "HDR-yhdistäminen", + "HDR Merge Preview": "HDR-yhdistämisen esikatselu", + "HDR Panorama Merge": "HDR-panoraaman yhdistäminen", + "HDR Panorama Merge Preview": "HDR-panoraaman yhdistämisen esikatselu", + "HDR Panorama with Last Settings": "HDR-panoraama viimeisillä asetuksilla", + "HDR Panoramas": "HDR-panoraamat", + "HDR Panorama…": "HDR-panoraama…", + "HDR TIFF needs 32-bit float.": "HDR-TIFF vaatii 32-bittisen liukuluvun.", + "HDR for Each Bracket Set": "HDR jokaiselle haarukointisarjalle", + "HDR output": "HDR-tuloste", + "HDR panorama": "HDR-panoraama", + "HDR with Last Settings": "HDR viimeisillä asetuksilla", + "HDR…": "HDR…", + "Has Edits": "On muokkauksia", + "Has GPS": "On GPS", + "Heal": "Paranna", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Paranna pienet, pehmeät tummat täplät, joita anturin pöly jättää tasaisille alueille", + "Height": "Korkeus", + "Height (px)": "Korkeus (px)", + "Help": "Ohje", + "Hide LightCraft": "Piilota LightCraft", + "Hide Mask": "Piilota maski", + "Hide Others": "Piilota muut", + "Hide This Section": "Piilota tämä osio", + "Hides this shortcut only; the folder and its photos stay as they are": "Piilottaa vain tämän pikakuvakkeen; kansio ja sen kuvat säilyvät ennallaan", + "High": "Korkea", + "High Contrast B&W": "Korkeakontrastinen M/V", + "Highlight": "Huippuvalo", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Korostetut: liukusäätimet, joita tämä maski muuttaa. Pois jätetyt liukusäätimet pysyvät ennallaan, kun esiasetus otetaan käyttöön.", + "Highlights": "Huippuvalot", + "Highlights Hue": "Huippuvalojen sävy", + "Highlights Luminance": "Huippuvalojen kirkkaus", + "Highlights Saturation": "Huippuvalojen värikylläisyys", + "Highlights split": "Huippuvalojen jako", + "Histogram": "Histogrammi", + "History": "Historia", + "History & Activity (Y)": "Historia ja toiminta (Y)", + "Hold View Until Refreshed": "Pidä näkymä, kunnes se päivitetään", + "Home": "Kotikansio", + "Horizontal": "Vaaka", + "Horizontal inset (% of short edge)": "Vaakasuuntainen sisennys (% lyhyestä sivusta)", + "Horizontal text": "Vaakasuuntainen teksti", + "Hours": "Tunnit", + "How to install the model": "Mallin asentaminen", + "Hue": "Sävy", + "Hue Range": "Sävyalue", + "Human Eye Tracking": "Ihmissilmien seuranta", + "I have read these terms and accept them for my own use": "Olen lukenut nämä ehdot ja hyväksyn ne omaan käyttööni", + "ICC Profiles": "ICC-profiilit", + "ISO Speed": "ISO-herkkyys", + "Identity plate": "Tunnuslaatta", + "If file exists": "Jos tiedosto on olemassa", + "Image": "Kuva", + "Image on Black": "Kuva mustalla", + "Image on White": "Kuva valkoisella", + "Import": "Tuo", + "Import Date": "Tuontipäivä", + "Import Keywords": "Tuo avainsanat", + "Import Keywords…": "Tuo avainsanat…", + "Import Lightroom Catalog": "Tuo Lightroom-katalogi", + "Import Lightroom Catalog…": "Tuo Lightroom-katalogi…", + "Import Photos": "Tuo kuvia", + "Import Photos…": "Tuo kuvia…", + "Import Point Curve Presets": "Tuo pistekäyrien esiasetukset", + "Import Point Curve Presets…": "Tuo pistekäyrien esiasetukset…", + "Import Presets": "Tuo esiasetukset", + "Import Presets…": "Tuo esiasetukset…", + "Import Profiles": "Tuo profiilit", + "Import Profiles & Presets…": "Tuo profiilit ja esiasetukset…", + "Import Slideshow Templates": "Tuo diaesitysmallipohjat", + "Import as new": "Tuo uutena", + "Import from Device": "Tuo laitteelta", + "Import from Folder…": "Tuo kansiosta…", + "Import new photos": "Tuo uudet kuvat", + "Import photos to get started": "Aloita tuomalla kuvia", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Tuo kuvia valitsemalla Tiedosto → Tuo kuvia… (Cmd+Shift+I) tai pudota ne tähän", + "Imported": "Tuotu", + "Imported Photos": "Tuodut kuvat", + "Importing": "Tuodaan", + "Importing Lightroom Catalog": "Tuodaan Lightroom-katalogia", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Tuonti skannaa lähteestä kuvat; se ei lisää kansiota Paikalliseen (käytä Paikallinen → Selaa kansiota… työskennelläksesi kansiossa tuomatta).", + "Import…": "Tuo…", + "In Color": "Värillinen", + "In Photo Grid and Square Grid: Flag as Pick and advance": "Kuvaruudukossa ja neliöruudukossa: merkitse valituksi ja siirry eteenpäin", + "In Photo Grid and Square Grid: expand or collapse the stack": "Kuvaruudukossa ja neliöruudukossa: laajenna tai tiivistä pino", + "In Recently Deleted": "Äskettäin poistetuissa", + "In Use": "Käytössä", + "In a Stack": "Pinossa", + "In library": "Kirjastossa", + "In memory — nothing is saved": "Muistissa — mitään ei tallenneta", + "In use": "Käytössä", + "In-memory session: these settings last until LightCraft quits.": "Muistissa oleva istunto: nämä asetukset säilyvät, kunnes LightCraft suljetaan.", + "Include Amount": "Sisällytä määrä", + "Include brush size, feather and flow": "Sisällytä siveltimen koko, häivytys ja virtaus", + "Include on Export": "Sisällytä vientiin", + "Include selected photos": "Sisällytä valitut kuvat", + "Include subfolders": "Sisällytä alikansiot", + "Increase Brush Feather": "Suurenna siveltimen pehmeyttä", + "Increase Brush Size": "Suurenna siveltimen kokoa", + "Increase Grid Thumbnail Size": "Suurenna ruudukon pikkukuvien kokoa", + "Increase Rating": "Suurenna luokitusta", + "Index Faces": "Indeksoi kasvot", + "Index Faces in the Background": "Indeksoi kasvot taustalla", + "Info": "Tiedot", + "Info (I)": "Tiedot (I)", + "Info Overlay Off": "Tietojen peittokuva pois", + "Info Overlay: Exposure": "Tietojen peittokuva: valotus", + "Info Overlay: File": "Tietojen peittokuva: tiedosto", + "Info overlay": "Tietojen peittokuva", + "Insert at the cursor": "Lisää kohdistimen kohtaan", + "Inspect Denoise Model": "Tarkastele kohinanpoistomallia", + "Inspect Face Model File": "Tarkastele kasvomallitiedostoa", + "Inspect Lightroom Catalog": "Tarkastele Lightroom-katalogia", + "Inspect Photo": "Tarkastele kuvaa", + "Inspecting Lightroom Catalog": "Tarkastellaan Lightroom-katalogia", + "Install Denoise Model": "Asenna kohinanpoistomalli", + "Install Face Model": "Asenna kasvomalli", + "Install a denoise model to enable AI Denoise on this photo": "Asenna kohinanpoistomalli ottaaksesi tekoälykohinanpoiston käyttöön tällä kuvalla", + "Install from file…": "Asenna tiedostosta…", + "Install the SAM 3 Model": "Asenna SAM 3 -malli", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Asenna oma ONNX-mallisi, jonka vieressä on denoise-model.json. Tämä versio ei tarjoa mallilatauksia.", + "Instant ’70s": "Välitön 70-luku", + "Intent:": "Tarkoitus:", + "Interface": "Käyttöliittymä", + "Interface language": "Käyttöliittymän kieli", + "Intersect": "Leikkaus", + "Intersect with": "Leikkaa kanssa", + "Into one folder": "Yhteen kansioon", + "Intro": "Alku", + "Intro screen": "Aloitusnäyttö", + "Invert": "Käännä", + "Invert Component": "Käännä komponentti", + "Invert Mask": "Käännä maski", + "It tests this album, so testing it back would loop": "Se testaa tätä albumia, joten sen testaaminen takaisin muodostaisi silmukan", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Sen kohinanpoistettua kuvaa ei ole vielä tehty, eikä kuvia tehdä itsestään (Asetukset, Tekoälykohinanpoisto).", + "JPEG (Large)": "JPEG (suuri)", + "JPEG (Small)": "JPEG (pieni)", + "Join the ArtCraft Discord": "Liity ArtCraftin Discordiin", + "Join the ArtCraft Discord…": "Liity ArtCraftin Discordiin…", + "Join the ArtCraft community on Discord": "Liity ArtCraft-yhteisöön Discordissa", + "Keep in Local": "Säilytä Paikallisessa", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Pidä se asemalla, jolla on tilaa: älyesikatselut vievät noin 1 Mt kuvaa kohden. Pikkukuvien välimuisti pysyy kirjastossa.", + "Keyboard Shortcuts": "Pikanäppäimet", + "Keyword": "Avainsana", + "Keyword Count": "Avainsanojen määrä", + "Keyword Info": "Avainsanan tiedot", + "Keyword List": "Avainsanaluettelo", + "Keyword Lists": "Avainsanaluettelot", + "Keyword Name": "Avainsanan nimi", + "Keyword Painter": "Avainsanasivellin", + "Keyword Set": "Avainsanajoukko", + "Keyword Sets": "Avainsanajoukot", + "Keyword Suggestions": "Avainsanaehdotukset", + "Keyword Tag Options": "Avainsanatunnisteen asetukset", + "Keyword to paint": "Maalattava avainsana", + "Keywords": "Avainsanat", + "Keywords & People": "Avainsanat ja henkilöt", + "Keywords (K)": "Avainsanat (K)", + "Keywords (⌥1–⌥9)": "Avainsanat (⌥1–⌥9)", + "Keywords of the selection": "Valinnan avainsanat", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Lisäämäsi avainsanat näkyvät tässä; ⌥1–⌥9 ottaa ne käyttöön.", + "LUT-based": "LUT-pohjainen", + "Label": "Merkintä", + "Label Set": "Merkintäjoukko", + "Landscape": "Maisema", + "Landscape (wide)": "Maisema (leveä)", + "Language": "Kieli", + "Last": "Viimeinen", + "Last GPU fallback": "Viimeisin GPU-varamenettely", + "Last commit": "Viimeisin commit", + "Last loupe render": "Viimeisin suurennuslasin renderöinti", + "Last view": "Viimeisin näkymä", + "Lateral Chromatic Aberration": "Poikittainen kromaattinen aberraatio", + "Layer": "Taso", + "Layers": "Tasot", + "Layout": "Asettelu", + "Layout Image Matte": "Asettelukuvan paspis", + "Layout Image Opacity": "Asettelukuvan peittävyys", + "Leave them": "Jätä ne", + "Left": "Vasen", + "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.": "Vasen: vakioalue (SDR) SDR-valkoiseen asti. Oikea: HDR-alue, yksi kaista SDR-valkoisen yläpuolella olevaa aukkoa kohden (oranssi viiva: HDR-varakorkeus). Kun HDR-alueen havainnollistus on päällä, kaistat käyttävät sen värejä.", + "Lens": "Objektiivi", + "Lens Blur": "Objektiivin epätarkkuus", + "Lens Corrections": "Objektiivikorjaukset", + "Letter": "Letter", + "Level": "Taso", + "Library": "Kirjasto", + "Library Folders": "Kirjastokansiot", + "Library Info": "Kirjaston tiedot", + "Library Originals": "Kirjaston alkuperäiset", + "Library Preferences": "Kirjaston asetukset", + "Library State": "Kirjaston tila", + "Library name": "Kirjaston nimi", + "Library saved": "Kirjasto tallennettu", + "Licence:": "Lisenssi:", + "Light": "Valo", + "Light Mode": "Vaalea tila", + "Light Theme": "Vaalea teema", + "LightCraft Default": "LightCraftin oletus", + "LightCraft Effect Presets": "LightCraftin tehosteesiasetukset", + "LightCraft Help": "LightCraftin ohje", + "LightCraft Preset": "LightCraft-esiasetus", + "LightCraft Presets": "LightCraft-esiasetukset", + "LightCraft Website": "LightCraftin verkkosivusto", + "LightCraft could not start": "LightCraft ei voinut käynnistyä", + "LightCraft couldn't find where to keep your library.": "LightCraft ei löytänyt paikkaa kirjastosi säilytykselle.", + "LightCraft couldn't open the library at": "LightCraft ei voinut avata kirjastoa kohteessa", + "LightCraft does not know this model, so it assumed:": "LightCraft ei tunne tätä mallia, joten se oletti:", + "LightCraft on GitHub": "LightCraft GitHubissa", + "LightCraft stopped": "LightCraft pysähtyi", + "LightCraft — Second Window": "LightCraft — toinen ikkuna", + "Lightroom Catalog Import": "Lightroom-katalogin tuonti", + "Lightroom Classic Catalog": "Lightroom Classic -katalogi", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-katalogit tuodaan avattuun kirjastoon; erillistä katalogikansiota ei luoda.", + "Lightroom import cancelled": "Lightroom-tuonti peruttu", + "Lightroom inspection cancelled": "Lightroom-tarkastelu peruttu", + "Lights": "Vaaleat", + "Lights Out": "Valot sammuksiin", + "Limit": "Raja", + "Limit file size (KB, 0 = off)": "Rajoita tiedostokokoa (kt, 0 = pois)", + "Line delta": "Rivien muutos", + "Linear": "Lineaarinen", + "Linear Gradient": "Lineaarinen liukuväri", + "Lines +/−": "Rivit +/−", + "Lines added": "Lisätyt rivit", + "Lines deleted": "Poistetut rivit", + "Link all margins": "Linkitä kaikki marginaalit", + "List Albums": "Listaa albumit", + "List Develop Controls": "Listaa kehityssäätimet", + "List History": "Listaa historia", + "List Presets": "Listaa esiasetukset", + "List Profiles": "Listaa profiilit", + "List this folder in Local from now on": "Listaa tämä kansio Paikallisessa tästä lähtien", + "Loading preview…": "Ladataan esikatselua…", + "Local": "Paikallinen", + "Local folders": "Paikalliset kansiot", + "Local library — no cloud account needed": "Paikallinen kirjasto — pilvitiliä ei tarvita", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Paikalliset kuvat eivät ole vielä kirjastossa: lisää ne ensin Omiin kuviin ja siirrä sitten", + "Locate Missing Files…": "Paikanna puuttuvat tiedostot…", + "Locate Missing File…": "Paikanna puuttuva tiedosto…", + "Locate Photo": "Paikanna kuva", + "Location": "Sijainti", + "Lock": "Lukitse", + "Lock Crop Aspect": "Lukitse rajauksen kuvasuhde", + "Lock On AF": "Lukitse tarkennus", + "Locked": "Lukittu", + "Long Edge": "Pitkä sivu", + "Looking for changes": "Etsitään muutoksia", + "Looking for photos…": "Etsitään kuvia…", + "Looking up places": "Haetaan paikkoja", + "Lossless": "Häviötön", + "Low": "Matala", + "Luminance": "Kirkkaus", + "Luminance Range": "Kirkkausalue", + "Lush Greens": "Rehevät vihreät", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100 ja R8. CR3-tiedostot, joita purkaja ei vielä osaa lukea, avautuvat edelleen upotetusta JPEG-kuvastaan kuten ennenkin.", + "Magenta": "Magenta", + "Magenta Hue": "Magentan sävy", + "Magenta Luminance": "Magentan kirkkaus", + "Magenta Saturation": "Magentan värikylläisyys", + "Make Candidate the Select": "Tee ehdokkaasta valinta", + "Make it now": "Tee se nyt", + "Make the denoised picture of the photos I am looking at": "Tee katsomieni kuvien kohinanpoistettu kuva", + "Manage Effect Presets…": "Hallitse tehosteesiasetuksia…", + "Manage Presets…": "Hallitse esiasetuksia…", + "Managed by Printer (Adobe RGB)": "Tulostimen hallinnoima (Adobe RGB)", + "Managed by Printer (sRGB)": "Tulostimen hallinnoima (sRGB)", + "Manual": "Manuaalinen", + "Margins": "Marginaalit", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Marginaalit ovat osuus dian lyhyemmästä sivusta, joten diat näyttävät samalta millä tahansa näytöllä.", + "Marquee zoom": "Aluezoom", + "Mask": "Maski", + "Mask Adjustment": "Maskin säätö", + "Masking": "Maskaus", + "Masking (M)": "Maskaus (M)", + "Masks": "Maskit", + "Masks keep their sliders. Export presets first to keep a copy.": "Maskit säilyttävät liukusäätimensä. Vie esiasetukset ensin säilyttääksesi kopion.", + "Match": "Täsmää", + "Match Total Exposures": "Täsmää kokonaisvalotukset", + "Matrix/TRC": "Matriisi/TRC", + "Matte": "Matta", + "Matte Soft": "Pehmeä matta", + "Med": "Keski", + "Medium Contrast": "Keskikontrastinen", + "Megapixels": "Megapikselit", + "Memory Budget": "Muistibudjetti", + "Memory Usage": "Muistin käyttö", + "Memory for caches": "Välimuistien muisti", + "Menu": "Valikko", + "Merge": "Yhdistä", + "Merge Keywords": "Yhdistä avainsanat", + "Merge into…": "Yhdistä kohteeseen…", + "Merged": "Yhdistetty", + "Merged PRs": "Yhdistetyt PR:t", + "Merging": "Yhdistetään", + "Merging…": "Yhdistetään…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Metan ehdot, ei LightCraftin. Lataaminen tarkoittaa niiden hyväksymistä.", + "Metadata": "Metatiedot", + "Metadata Preset": "Metatietoesiasetus", + "Metadata Presets": "Metatietoesiasetukset", + "Metadata preset": "Metatietoesiasetus", + "Metadata read": "Metatiedot luettu", + "Midnight": "Keskiyö", + "Midpoint": "Keskipiste", + "Midtones": "Keskisävyt", + "Midtones Hue": "Keskisävyjen sävy", + "Midtones Luminance": "Keskisävyjen kirkkaus", + "Midtones Saturation": "Keskisävyjen värikylläisyys", + "Midtones split": "Keskisävyjen jako", + "Minimize": "Pienennä", + "Missing Photos": "Puuttuvat kuvat", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Puuttuvat metatiedot ({camera}, {title}…) jättävät tyhjän aukon; nimi, joka jäisi tyhjäksi, säilyttää alkuperäisen nimen.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Puuttuvat kuvat siirtyvät Äskettäin poistettuihin ja ne voi palauttaa; levyllä oleviin tiedostoihin ei kosketa.", + "Misty Morning": "Sumuinen aamu", + "Mo": "Ma", + "Mode": "Tila", + "Model": "Malli", + "Models": "Mallit", + "Modified Date": "Muokkauspäivä", + "Monday": "maanantai", + "Monitor profile": "Näytön profiili", + "Mono Red Filter": "Mono punasuodin", + "Mono Rich": "Mono rikas", + "Mono Soft": "Mono pehmeä", + "Monochrome": "Yksivärinen", + "Month": "Kuukausi", + "Moody": "Synkkä", + "Moody Portrait": "Synkkä muotokuva", + "More": "Lisää", + "More preset options": "Lisää esiasetusvaihtoehtoja", + "Move": "Siirrä", + "Move Album": "Siirrä albumi", + "Move Down": "Siirrä alas", + "Move Down in Stack": "Siirrä pinossa alas", + "Move Folder": "Siirrä kansio", + "Move Folder To…": "Siirrä kansio kohteeseen…", + "Move Keyword": "Siirrä avainsana", + "Move Mask": "Siirrä maski", + "Move Photos": "Siirrä kuvat", + "Move Preset to Group": "Siirrä esiasetus ryhmään", + "Move Preset to New Group": "Siirrä esiasetus uuteen ryhmään", + "Move Selected Photos Here": "Siirrä valitut kuvat tänne", + "Move Sidebar Section": "Siirrä sivupalkin osio", + "Move Up": "Siirrä ylös", + "Move Up in Stack": "Siirrä pinossa ylös", + "Move in Stack": "Siirrä pinossa", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Siirrä tiedostot (kirjaston Originals/-kansioon tai valitsemaasi kansioon) ja poista ne lähteestä", + "Move them": "Siirrä ne", + "Move to": "Siirrä kohteeseen", + "Move to Folder": "Siirrä kansioon", + "Move to Folder…": "Siirrä kansioon…", + "Move to Group": "Siirrä ryhmään", + "Move to Group…": "Siirrä ryhmään…", + "Move to group": "Siirrä ryhmään", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Siirrä: tiedostot siirretään alla olevaan kansioon; jokainen alkuperäinen (ja sen XMP-sivutiedosto) poistetaan lähteestä vasta, kun sen kopio on varmistettu. Kaksoiskappaleet ja epäonnistuneet tiedostot pysyvät paikoillaan.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Siirtää kaikki tältä levyltä tuodut kuvat Äskettäin poistettuihin; tiedostoihin ei kosketa", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Siirtää tiedostot ja niiden XMP-sivutiedostot tähän kansioon; ei koskaan korvaa mitään", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Siirtää tästä kansiosta tuodut kuvat Äskettäin poistettuihin; tiedostoihin ei kosketa", + "Multi": "Useita", + "Muted": "Hillitty", + "Muted Film": "Hillitty filmi", + "My Photos": "Omat kuvat", + "My Photos Panel": "Omat kuvat -paneeli", + "My Preset": "Oma esiasetus", + "Name": "Nimi", + "Name (A–Z)": "Nimi (A–Ö)", + "Name Face": "Nimeä kasvot", + "Name Faces": "Nimeä kasvot", + "Name of the folder the original is in": "Sen kansion nimi, jossa alkuperäinen on", + "Name the preset": "Nimeä esiasetus", + "Named": "Nimetty", + "Named People": "Nimetyt henkilöt", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nimet näkyvät merkintävalikossa, suodatinpalkissa ja Tiedot-paneelissa, ja ne kirjoitetaan XMP:hen. Tyhjä = värin nimi.", + "Navigator": "Navigaattori", + "Needs Work": "Vaatii työtä", + "Neon Dusk": "Neonhämärä", + "Neon Night": "Neonyö", + "Nested group": "Sisäkkäinen ryhmä", + "Neutral": "Neutraali", + "Never": "Ei koskaan", + "New": "Uusi", + "New Album": "Uusi albumi", + "New Album…": "Uusi albumi…", + "New Folder on Disk": "Uusi kansio levylle", + "New Folder on Disk…": "Uusi kansio levylle…", + "New Folder…": "Uusi kansio…", + "New Group…": "Uusi ryhmä…", + "New Library": "Uusi kirjasto", + "New Library: Choose Where to Put It": "Uusi kirjasto: valitse sijainti", + "New Library…": "Uusi kirjasto…", + "New Smart Album": "Uusi älyalbumi", + "New Smart Album from Filter…": "Uusi älyalbumi suodattimesta…", + "New Smart Album…": "Uusi älyalbumi…", + "New album": "Uusi albumi", + "New album…": "Uusi albumi…", + "New blank PDF": "Uusi tyhjä PDF", + "New group — Return": "Uusi ryhmä — Return", + "New name": "Uusi nimi", + "New spot (Return adds it here)": "Uusi täplä (Return lisää sen tähän)", + "New template name": "Uuden mallipohjan nimi", + "New time": "Uusi aika", + "New…": "Uusi…", + "Next Appearance Mode": "Seuraava ulkoasutila", + "Next History Step": "Seuraava historiavaihe", + "Next Photo": "Seuraava kuva", + "Next Slide": "Seuraava dia", + "Next page": "Seuraava sivu", + "Next up for AI Denoise…": "Seuraavana tekoälykohinanpoistossa…", + "Night Blue": "Yönsininen", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) käyttää intoPIXin TicoRAW-pakkausta. LightCraftilla ei tällä hetkellä ole lisenssiä tähän koodekkiin, joten käytettävissä on vain kameran upotettu JPEG-esikatselu, ei raakaa anturidataa. Muokataksesi raakadataa kuvaa Lossless compressed NEF -muodossa tai muunna olemassa olevat HE-tiedostot DNG:ksi lisensoidulla muuntimella.", + "No": "Ei", + "No Camera or Card Found": "Kameraa tai korttia ei löytynyt", + "No Date Groups": "Ei päivämääräryhmiä", + "No Label": "Ei merkintää", + "No Matte": "Ei paspista", + "No Recent Libraries": "Ei viimeisimpiä kirjastoja", + "No Stars": "Ei tähtiä", + "No Tool": "Ei työkalua", + "No albums match": "Yksikään albumi ei täsmää", + "No albums yet": "Ei vielä albumeja", + "No automatic versions.": "Ei automaattisia versioita.", + "No camera": "Ei kameraa", + "No change": "Ei muutosta", + "No clipping detected in this preview.": "Tässä esikatselussa ei havaittu leikkautumista.", + "No contributor data was built into this copy.": "Tähän kopioon ei sisällytetty tekijätietoja.", + "No date": "Ei päivämäärää", + "No dust spots found": "Pölyhiukkasia ei löytynyt", + "No edits yet.": "Ei vielä muokkauksia.", + "No effect matches": "Yksikään tehoste ei täsmää", + "No effect presets yet": "Ei vielä tehosteesiasetuksia", + "No exposure information": "Ei valotustietoja", + "No faces by that name": "Ei kasvoja tällä nimellä", + "No filter presets yet": "Ei vielä suodinesiasetuksia", + "No folders to browse": "Ei selattavia kansioita", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Grafiikkaprosessoria ei löytynyt: LightCraft renderöi suorittimella, mikä on paljon hitaampaa. Lisätietoja kohdassa Ohje → Järjestelmätiedot.", + "No imported folders yet": "Ei vielä tuotuja kansioita", + "No keywords are exported": "Avainsanoja ei viedä", + "No keywords match": "Yksikään avainsana ei täsmää", + "No keywords yet": "Ei vielä avainsanoja", + "No lens data embedded in this file (DNG lens opcodes only).": "Tähän tiedostoon ei ole upotettu objektiivitietoja (vain DNG:n objektiivin opkoodit).", + "No lens data embedded in this file.": "Tähän tiedostoon ei ole upotettu objektiivitietoja.", + "No masks yet. Choose a mask type above.": "Ei vielä maskeja. Valitse maskin tyyppi yltä.", + "No matching commands": "Ei täsmääviä komentoja", + "No matching photos": "Ei täsmääviä kuvia", + "No metadata found": "Metatietoja ei löytynyt", + "No model credits were built into this copy.": "Tähän kopioon ei sisällytetty mallien tekijätietoja.", + "No named people in these photos": "Näissä kuvissa ei ole nimettyjä henkilöitä", + "No named people yet": "Ei vielä nimettyjä henkilöitä", + "No named people yet. Name the faces below to start.": "Ei vielä nimettyjä henkilöitä. Aloita nimeämällä alla olevat kasvot.", + "No new presets": "Ei uusia esiasetuksia", + "No people yet": "Ei vielä henkilöitä", + "No person by that name": "Ei henkilöä tällä nimellä", + "No photo has it; the keywords below it go too.": "Yhdelläkään kuvalla sitä ei ole; myös sen alla olevat avainsanat poistuvat.", + "No photo selected": "Kuvaa ei ole valittu", + "No photos": "Ei kuvia", + "No photos found": "Kuvia ei löytynyt", + "No photos in this folder": "Tässä kansiossa ei ole kuvia", + "No photos match the active filters": "Yksikään kuva ei täsmää käytössä oleviin suodattimiin", + "No photos match the filters (View → Clear Filters)": "Yksikään kuva ei täsmää suodattimiin (Näytä → Tyhjennä suodattimet)", + "No photos yet": "Ei vielä kuvia", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ei vielä raw-kuvia: kamerat ilmestyvät tähän, kun niiden kuvat ovat kirjastossa.", + "No reference photo": "Ei vertailukuvaa", + "No unnamed faces. Faces found in your photos appear here until you name them.": "Ei nimeämättömiä kasvoja. Kuvistasi löytyneet kasvot näkyvät tässä, kunnes nimeät ne.", + "No usable GPU found: rendering on the CPU": "Käyttökelpoista GPU:ta ei löytynyt: renderöidään suorittimella", + "No versions yet. Create one to keep this look.": "Ei vielä versioita. Luo yksi säilyttääksesi tämän ilmeen.", + "Noise": "Kohina", + "Noise Reduction": "Kohinanpoisto", + "Non-raw photos": "Ei-raw-kuvat", + "None": "Ei mitään", + "None (the display is treated as sRGB)": "Ei mitään (näyttöä käsitellään sRGB:nä)", + "None yet: save the current settings as a template below.": "Ei vielä mitään: tallenna nykyiset asetukset mallipohjana alla.", + "Not Now": "Ei nyt", + "Not loaded yet": "Ei vielä ladattu", + "Not saved": "Ei tallennettu", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Mitään ei noudeta ennen kuin hyväksyt. Malli säilytetään vain tällä tietokoneella; LightCraft ei koskaan lähetä tai jaa sitä.", + "Nothing to compare": "Ei mitään vertailtavaa", + "Nothing to survey": "Ei mitään kartoitettavaa", + "Nudge Develop Value": "Säädä kehitysarvoa", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Numeroidut JPEG-tiedostot (nimi-001.jpg…) uuteen kansioon, jonka nimi annetaan seuraavassa vaiheessa.", + "OK": "OK", + "ONNX model": "ONNX-malli", + "Object": "Kohde", + "Object Mask": "Kohdemaski", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Kohde- ja Kuvaile-maskit käyttävät SAM 3:a, Metan segmentointimallia. Se ei kuulu LightCraftiin, ja kaikki muu toimii ilman sitä.", + "October 2026": "lokakuu 2026", + "Off": "Pois", + "Offset": "Siirtymä", + "Offset X": "Siirtymä X", + "Offset Y": "Siirtymä Y", + "On NixOS, start it from `nix develop`.": "NixOS:ssa käynnistä se komennolla `nix develop`.", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Yksi sivu diaa kohden (pitkä sivu pikseleinä = pisteet), dian kesto ja häivytys katselijan esitystilaa varten.", + "One photo fills each page inside the margins.": "Yksi kuva täyttää jokaisen sivun marginaalien sisällä.", + "Only": "Vain", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Tehdään vain kuvat, joiden tekoälykohinanpoiston määrä on yli 0. Viennit tekevät omansa aina, jos se puuttuu.", + "Opacity": "Peittävyys", + "Opacity (%)": "Peittävyys (%)", + "Open Library": "Avaa kirjasto", + "Open Library…": "Avaa kirjasto…", + "Open Log Folder": "Avaa lokikansio", + "Open Original in Other Editor (RAW, no copy)": "Avaa alkuperäinen toisessa muokkausohjelmassa (RAW, ei kopiota)", + "Open Recent Library": "Avaa viimeisin kirjasto", + "Open Settings": "Avaa asetukset", + "Open in": "Avaa ohjelmassa", + "Open in Detail": "Avaa Tarkastelussa", + "Open page": "Avaa sivu", + "Open the model's page": "Avaa mallin sivu", + "Open the place in OpenStreetMap": "Avaa paikka OpenStreetMapissa", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Avaa mallin oman sivun selaimessa, jotta voit lukea siitä tai hakea tiedoston itse.", + "Open…": "Avaa…", + "Optics": "Optiikka", + "Optimize Library": "Optimoi kirjasto", + "Optional name": "Valinnainen nimi", + "Options": "Asetukset", + "Orange": "Oranssi", + "Orange Hue": "Oranssin sävy", + "Orange Luminance": "Oranssin kirkkaus", + "Orange Saturation": "Oranssin värikylläisyys", + "Orientation": "Suunta", + "Original": "Alkuperäinen", + "Original + Settings": "Alkuperäinen + asetukset", + "Original extension without the dot": "Alkuperäinen tiedostopääte ilman pistettä", + "Original file name without its extension": "Alkuperäinen tiedostonimi ilman päätettä", + "Original files": "Alkuperäiset tiedostot", + "Original offline · editing the smart preview": "Alkuperäinen ei käytettävissä · muokataan älyesikatselua", + "Original offline · no smart preview": "Alkuperäinen ei käytettävissä · ei älyesikatselua", + "Originals": "Alkuperäiset", + "Other editor": "Toinen muokkausohjelma", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Muut kuvat (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Muu…", + "Overlay": "Peittokuva", + "Overlay color": "Peittokuvan väri", + "Overlays": "Peittokuvat", + "Override colour": "Ohita väri", + "Overwrite": "Korvaa", + "Own duration (off: the slide duration)": "Oma kesto (pois: dian kesto)", + "Page": "Sivu", + "Pages": "Sivut", + "Paint": "Maalaa", + "Paint only areas like the one under the brush": "Maalaa vain alueet, jotka muistuttavat sivellimen alla olevaa", + "Paint over a distraction on the photo to remove it.": "Maalaa häiriötekijän päälle kuvassa poistaaksesi sen.", + "Paint overlay": "Maalauksen peittokuva", + "Panels": "Paneelit", + "Panorama": "Panoraama", + "Panorama Merge": "Panoraaman yhdistäminen", + "Panorama Merge Preview": "Panoraaman yhdistämisen esikatselu", + "Panorama with Last Settings": "Panoraama viimeisillä asetuksilla", + "Panoramas": "Panoraamat", + "Panorama…": "Panoraama…", + "Paper": "Paperi", + "Parametric curve": "Parametrinen käyrä", + "Paste": "Liitä", + "Paste Edit Settings": "Liitä muokkausasetukset", + "Paste Metadata": "Liitä metatiedot", + "Paste Selected Settings": "Liitä valitut asetukset", + "Paste Selected Settings…": "Liitä valitut asetukset…", + "Paste Settings": "Liitä asetukset", + "Paste Settings from Previous": "Liitä asetukset edellisestä", + "Paste from Previous": "Liitä edellisestä", + "Paste pages": "Liitä sivut", + "Pastel Haze": "Pastellihuntu", + "Pause / Resume Slideshow": "Keskeytä / jatka diaesitystä", + "Pen Pressure": "Kynän paine", + "Pending photo action cancelled": "Odottava kuvatoiminto peruttu", + "Pending photo action cancelled because its library or photo changed": "Odottava kuvatoiminto peruttu, koska sen kirjasto tai kuva muuttui", + "People": "Henkilöt", + "People Count": "Henkilöiden määrä", + "Percent": "Prosentti", + "Percentage": "Prosenttiosuus", + "Perceptual": "Havaintoon perustuva", + "Performance": "Suorituskyky", + "Person": "Henkilö", + "Perspective": "Perspektiivi", + "Pet Eye": "Lemmikin silmä", + "Pet Eye Catchlight": "Lemmikin silmänvalo", + "Photo": "Kuva", + "Photo Above": "Kuva yllä", + "Photo Below": "Kuva alla", + "Photo Grid": "Kuvaruudukko", + "Photo Grid (G)": "Kuvaruudukko (G)", + "Photo Merge": "Kuvien yhdistäminen", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Kuva → Muokkaa ohjelmassa → Muokkaa PhotoCraftissa (⇧⌘E tai napsauta kuvaa hiiren kakkospainikkeella) renderöi kopion muokkauksineen, pinoaa sen alkuperäisen kanssa ja avaa sen PhotoCraftissa. Tallenna siellä, niin kopio päivittyy täällä. PSD-muodossa PhotoCraft tallentaa suoraan siihen. Jätä sijainti tyhjäksi, ellet käytä kannettavaa kopiota.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Kuva → Muokkaa ulkoisessa muokkausohjelmassa (⇧⌘E) renderöi 16-bittisen TIFF-kopion, pinoaa sen alkuperäisen kanssa ja avaa sen täällä (macOS:ssä sovelluksen nimi, muualla ohjelman polku).", + "PhotoCraft": "PhotoCraft", + "Photos": "Kuvat", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Kuvat näytetään näyttösi ICC-profiilin läpi, joten värit ovat oikein laajan värialueen ja kalibroiduilla näytöillä, ja Tarkastelu-näkymä käyttää näytön koko väriavaruutta. Histogrammi, esikatselun välimuistit ja viennit eivät muutu.", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "HDR:nä muokatut kuvat: JPEG HDR-vahvistuskartalla (näyttää oikealta kaikkialla, kirkkaammat huippuvalot HDR-näytöillä), AVIF 10-bittisenä, PNG ja JPEG XL 16-bittisenä Rec. 2020 PQ:na; TIFF vaatii 32-bittisen liukuluvun", + "Photos per bracket (0 = auto)": "Kuvia haarukointisarjaa kohden (0 = auto)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Paikallisessa nähdyt, mutta ei koskaan lisätyt tai muutetut kuvat poistuvat katalogista, kun kansiota ei ole selattu näin pitkään aikaan (tarkistetaan kirjastoa avattaessa). Tiedostot pysyvät levyllä; kansion selaaminen näyttää ne taas.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Kuvat pysyvät kohdassa Kaikki kuvat. Kansion poistaminen poistaa myös sen albumit. Voit kumota tämän.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Tähän kansioon saapuvat kuvat (tethering, skanneri, synkronointisovellus) lisätään heti, kun ne ovat valmiita.", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Tuomasi kuvat näkyvät tässä. Mitään ei kopioida tai siirretä, ellet valitse niin.", + "Pick": "Valitse", + "Pick Flag": "Valintalippu", + "Pick a colour on the photo with the eyedropper.": "Poimi kuvasta väri pipetillä.", + "Pick a date": "Valitse päivämäärä", + "Pick the sharpest photo of each burst": "Valitse kustakin sarjasta terävin kuva", + "Picked": "Valittu kuva", + "Picks": "Valitut", + "Pictures": "Kuvat", + "Pixels": "Pikselit", + "Place Names Status": "Paikannimien tila", + "Place names download cancelled": "Paikannimien lataus peruttu", + "Place names downloaded": "Paikannimet ladattu", + "Plain Black": "Pelkkä musta", + "Playback": "Toisto", + "Point Color": "Pistevärit", + "Point Curve": "Pistekäyrä", + "Point Curve Presets": "Pistekäyrien esiasetukset", + "Portrait": "Muotokuva", + "Portrait (tall)": "Muotokuva (pysty)", + "Position": "Sijainti", + "Preferences": "Asetukset", + "Preferences…": "Asetukset…", + "Prepare AI Masks": "Valmistele tekoälymaskit", + "Preset": "Esiasetus", + "Preset name": "Esiasetuksen nimi", + "Presets": "Esiasetukset", + "Presets & Profiles": "Esiasetukset ja profiilit", + "Presets (Shift+P)": "Esiasetukset (Shift+P)", + "Presets ▾": "Esiasetukset ⌄", + "Press keys…": "Paina näppäimiä…", + "Preview": "Esikatselu", + "Preview Build Progress": "Esikatselun luonnin eteneminen", + "Preview only": "Vain esikatselu", + "Preview only: editing the camera's embedded JPEG": "Vain esikatselu: muokataan kameran upotettua JPEG-kuvaa", + "Preview size": "Esikatselun koko", + "Preview unavailable": "Esikatselu ei käytettävissä", + "Previews": "Esikatselut", + "Previous History Step": "Edellinen historiavaihe", + "Previous Photo": "Edellinen kuva", + "Previous Slide": "Edellinen dia", + "Previous page": "Edellinen sivu", + "Previous settings": "Edelliset asetukset", + "Primaries (x, y)": "Päävärit (x, y)", + "Print": "Tulostus", + "Print to PDF": "Tulosta PDF:ksi", + "Print…": "Tulosta…", + "Processor": "Suoritin", + "Profile": "Profiili", + "Profile Browser": "Profiiliselain", + "Profile Distortion": "Profiilin vääristymä", + "Profile Menu": "Profiilivalikko", + "Profile Vignetting": "Profiilin vinjetointi", + "Profile:": "Profiili:", + "Profiles": "Profiilit", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Työpöydän värinhallinta-asetuksissa määritetyt profiilit ovat yleensä kansiossa ~/.local/share/icc.", + "Progressive": "Progressiivinen", + "Projection": "Projektio", + "Proof copy created": "Vedoskopio luotu", + "Properties": "Ominaisuudet", + "Public Domain": "Yleinen alue", + "Pupil Size": "Pupillin koko", + "Purge Unused Keywords": "Tyhjennä käyttämättömät avainsanat", + "Purple": "Violetti", + "Purple Amount": "Violetin määrä", + "Purple Hue": "Violetin sävy", + "Purple Hue High": "Violetin sävy ylä", + "Purple Hue Low": "Violetin sävy ala", + "Purple Luminance": "Violetin kirkkaus", + "Purple Saturation": "Violetin värikylläisyys", + "Put New Keywords Inside": "Lisää uudet avainsanat sisään", + "Put New Keywords Inside This Keyword": "Lisää uudet avainsanat tämän avainsanan sisään", + "Put the locations you removed from Local back (no files change)": "Palauta Paikallisesta poistamasi sijainnit (tiedostot eivät muutu)", + "Quality": "Laatu", + "Query Photos": "Kysele kuvia", + "Quick Collection": "Pikakokoelma", + "Quick Develop": "Pikakehitys", + "Quiet Green": "Hiljainen vihreä", + "Quit": "Lopeta", + "Quit Anyway": "Lopeta silti", + "Quit LightCraft": "Lopeta LightCraft", + "Quit while tasks are running?": "Lopetetaanko, vaikka tehtäviä on käynnissä?", + "Quit with unsaved changes?": "Lopetetaanko tallentamattomilla muutoksilla?", + "Radial": "Säteittäinen", + "Radial Gradient": "Säteittäinen liukuväri", + "Radius": "Säde", + "Random": "Satunnainen", + "Range": "Alue", + "Rate ★": "Luokitus ★", + "Rate ★ and advance": "Luokitus ★ ja siirry eteenpäin", + "Rate ★★": "Luokitus ★★", + "Rate ★★ and advance": "Luokitus ★★ ja siirry eteenpäin", + "Rate ★★★": "Luokitus ★★★", + "Rate ★★★ and advance": "Luokitus ★★★ ja siirry eteenpäin", + "Rate ★★★★": "Luokitus ★★★★", + "Rate ★★★★ and advance": "Luokitus ★★★★ ja siirry eteenpäin", + "Rate ★★★★★": "Luokitus ★★★★★", + "Rate ★★★★★ and advance": "Luokitus ★★★★★ ja siirry eteenpäin", + "Rating": "Luokitus", + "Rating cleared": "Luokitus poistettu", + "Rating stars": "Luokitustähdet", + "Ratings & flags": "Luokitukset ja liput", + "Raw": "Raw", + "Raw default": "Raw-oletus", + "Raw defaults": "Raw-oletukset", + "Raw files": "Raw-tiedostot", + "Raw photos": "Raw-kuvat", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Raw-kuvat DNG:nä, muokkaukset upotettuina. Koko-, väri- ja tulosteasetukset eivät päde.", + "Read Focus Points": "Lue tarkennuspisteet", + "Read Metadata from File": "Lue metatiedot tiedostosta", + "Read metadata updates from XMP sidecars": "Lue metatietopäivitykset XMP-sivutiedostoista", + "Read mode": "Lukutila", + "Read the SAM License": "Lue SAM-lisenssi", + "Reading": "Luetaan", + "Reading Lightroom catalog": "Luetaan Lightroom-katalogia", + "Reading Lightroom catalog…": "Luetaan Lightroom-katalogia…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Sivutiedoston lukeminen korvaa kuvan luokituksen, merkinnät, metatiedot ja muokkaukset sen sisällöllä.", + "Reading focus points": "Luetaan tarkennuspisteitä", + "Reading folder": "Luetaan kansiota", + "Reading folder…": "Luetaan kansiota…", + "Reading photos…": "Luetaan kuvia…", + "Real name": "Oikea nimi", + "Recent": "Viimeaikaiset", + "Recent Keywords": "Viimeaikaiset avainsanat", + "Recent Keywords is built in: choose another name": "Viimeaikaiset avainsanat on sisäänrakennettu: valitse toinen nimi", + "Recent Libraries": "Viimeisimmät kirjastot", + "Recently Added": "Äskettäin lisätyt", + "Recently Deleted": "Äskettäin poistetut", + "Recognise faces (experimental)": "Tunnista kasvot (kokeellinen)", + "Recognition": "Tunnistus", + "Recognition models": "Tunnistusmallit", + "Red": "Punainen", + "Red Eye": "Punasilmäisyys", + "Red Hue": "Punaisen sävy", + "Red Luminance": "Punaisen kirkkaus", + "Red Primary": "Punaisen pääväri", + "Red Saturation": "Punaisen värikylläisyys", + "Red channel": "Punainen kanava", + "Red label": "Punainen merkintä", + "Red/Cyan Fringe": "Puna/syaanireunus", + "Redo": "Tee uudelleen", + "Reference": "Vertailukuva", + "Reference View": "Vertailunäkymä", + "Reference the files where they are": "Viittaa tiedostoihin niiden nykyisessä sijainnissa", + "Refine": "Tarkenna", + "Refine AI Mask Detail": "Tarkenna tekoälymaskin yksityiskohtia", + "Refine Edges": "Tarkenna reunoja", + "Refine Mask Edges": "Tarkenna maskin reunoja", + "Refine Saturation": "Tarkenna värikylläisyyttä", + "Refining the mask's detail…": "Tarkennetaan maskin yksityiskohtia…", + "Refresh Source": "Päivitä lähde", + "Refresh Source (/)": "Päivitä lähde (/)", + "Refresh View": "Päivitä näkymä", + "Reject": "Hylkää", + "Reject below focus": "Hylkää tarkennuksen alapuolella", + "Rejected": "Hylätty", + "Rejects": "Hylätyt", + "Relative": "Suhteellinen", + "Relink Photo": "Yhdistä kuva uudelleen", + "Relink photos whose file was renamed or moved": "Yhdistä uudelleen kuvat, joiden tiedosto nimettiin uudelleen tai siirrettiin", + "Relinked": "Yhdistetty uudelleen", + "Reload Changed Files": "Lataa muuttuneet tiedostot uudelleen", + "Reload from Disk": "Lataa levyltä uudelleen", + "Remove": "Poista", + "Remove (H)": "Poista (H)", + "Remove CA": "Poista kromaattinen aberraatio", + "Remove Chromatic Aberration": "Poista kromaattinen aberraatio", + "Remove Denoise Model": "Poista kohinanpoistomalli", + "Remove Disk from Library": "Poista levy kirjastosta", + "Remove Disk from Library…": "Poista levy kirjastosta…", + "Remove Face Box": "Poista kasvorajaus", + "Remove Face Model": "Poista kasvomalli", + "Remove Folder Color Label": "Poista kansion värimerkintä", + "Remove Folder from Library": "Poista kansio kirjastosta", + "Remove a filter above, or choose Clear all": "Poista suodatin yltä tai valitse Tyhjennä kaikki", + "Remove from Album": "Poista albumista", + "Remove from Favorites": "Poista suosikeista", + "Remove from Library…": "Poista kirjastosta…", + "Remove from Local": "Poista Paikallisesta", + "Remove from Photo": "Poista kuvasta", + "Remove from Selected Photos": "Poista valituista kuvista", + "Remove from Stack": "Poista pinosta", + "Remove from Target Album": "Poista kohdealbumista", + "Remove from survey": "Poista vertailusta", + "Remove location info": "Poista sijaintitiedot", + "Remove missing photos from the library": "Poista puuttuvat kuvat kirjastosta", + "Remove region": "Poista alue", + "Remove shortcut": "Poista pikakuvake", + "Remove this face box (undo with Edit ▸ Undo)": "Poista tämä kasvorajaus (kumoa kohdasta Muokkaa → Kumoa)", + "Remove this filter": "Poista tämä suodatin", + "Remove this group": "Poista tämä ryhmä", + "Remove this rule": "Poista tämä sääntö", + "Removed": "Poistettu", + "Rename": "Nimeä uudelleen", + "Rename Album": "Nimeä albumi uudelleen", + "Rename Component": "Nimeä komponentti uudelleen", + "Rename Folder": "Nimeä kansio uudelleen", + "Rename Folder…": "Nimeä kansio uudelleen…", + "Rename Keyword": "Nimeä avainsana uudelleen", + "Rename Keyword…": "Nimeä avainsana uudelleen…", + "Rename Mask": "Nimeä maski uudelleen", + "Rename Person…": "Nimeä henkilö uudelleen…", + "Rename Photos": "Nimeä kuvat uudelleen", + "Rename Photos…": "Nimeä kuvat uudelleen…", + "Rename Preset": "Nimeä esiasetus uudelleen", + "Rename Preview": "Nimeämisen esikatselu", + "Rename Template Tags": "Uudelleennimeämisen tunnisteet", + "Rename Version": "Nimeä versio uudelleen", + "Rename…": "Nimeä uudelleen…", + "Render behind image": "Renderöi kuvan taakse", + "Rendering": "Renderöinti", + "Rendering…": "Renderöidään…", + "Reorder Album": "Järjestä albumi uudelleen", + "Reorder Masks": "Järjestä maskit uudelleen", + "Reset": "Nollaa", + "Reset All": "Nollaa kaikki", + "Reset All Curves": "Nollaa kaikki käyrät", + "Reset All Keyboard Shortcuts": "Nollaa kaikki pikanäppäimet", + "Reset Before to Import State": "Nollaa Ennen tuontitilaan", + "Reset Control": "Nollaa säädin", + "Reset Crop": "Nollaa rajaus", + "Reset Edits": "Nollaa muokkaukset", + "Reset Panel Layout": "Nollaa paneelien asettelu", + "Reset Parametric Curve": "Nollaa parametrinen käyrä", + "Reset Section": "Nollaa osio", + "Reset Sidebar Sections": "Nollaa sivupalkin osiot", + "Reset Sliders": "Nollaa liukusäätimet", + "Reset Tone Curve": "Nollaa sävykäyrä", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Nollaa kaikki käyrät: pistekäyrät (kaikki kanavat) ja parametrinen (kaksoisnapsauta kanavaa nollataksesi vain sen)", + "Reset all edits": "Nollaa kaikki muokkaukset", + "Reset all edits (Cmd+Shift+R)": "Nollaa kaikki muokkaukset (Cmd+Shift+R)", + "Reset layout": "Nollaa asettelu", + "Reshuffle": "Sekoita uudelleen", + "Resize Face Box": "Muuta kasvorajauksen kokoa", + "Resize panel window": "Muuta paneeli-ikkunan kokoa", + "Resize panels": "Muuta paneelien kokoa", + "Resize region": "Muuta alueen kokoa", + "Resize to": "Muuta kokoa kohteeseen", + "Resolution (ppi)": "Tarkkuus (ppi)", + "Restore": "Palauta", + "Restore (no shortcut)": "Palauta (ei pikanäppäintä)", + "Restore Library from Backup…": "Palauta kirjasto varmuuskopiosta…", + "Restore Version": "Palauta versio", + "Restore keeps their edits; Import as new deletes the old record first.": "Palauta säilyttää niiden muokkaukset; Tuo uutena poistaa vanhan tietueen ensin.", + "Restore them": "Palauta ne", + "Retouch": "Retusointi", + "Return creates this keyword and gives it to the selected photos": "Return luo tämän avainsanan ja antaa sen valituille kuville", + "Reverse the order": "Käännä järjestys", + "Revert": "Palauta alkuperäiseen", + "Review": "Tarkistus", + "Review Import": "Tarkista tuonti", + "Right": "Oikea", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Napsauta kuvaa hiiren kakkospainikkeella → Aseta vertailukuvaksi, sitten Näytä → Vertailunäkymä (Shift+R)", + "Right-click a preset to delete it": "Poista esiasetus napsauttamalla sitä hiiren kakkospainikkeella", + "Rights Usage Terms": "Oikeuksien käyttöehdot", + "Rotate": "Kierrä", + "Rotate Crop": "Kierrä rajausta", + "Rotate Crop Aspect": "Kierrä rajauksen kuvasuhdetta", + "Rotate Left": "Kierrä vasemmalle", + "Rotate Right": "Kierrä oikealle", + "Roughness": "Karheus", + "Roundness": "Pyöreys", + "Rules": "Säännöt", + "Run face models on the CPU only": "Aja kasvomallit vain suorittimella", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM 3:n lataus pysähtyi: se jatkuu seuraavalla kerralla siitä, mihin jäi.", + "SDR": "SDR", + "SDR Brightness": "SDR-kirkkaus", + "SDR Clarity": "SDR-selkeys", + "SDR Contrast": "SDR-kontrasti", + "SDR Highlights": "SDR-huippuvalot", + "SDR Shadows": "SDR-varjot", + "SDR Whites": "SDR-valkoiset", + "Sa": "La", + "Same as raw default": "Sama kuin raw-oletus", + "Same folder as original photo": "Sama kansio kuin alkuperäisellä kuvalla", + "Sample Mask Color": "Poimi maskin väri", + "Sample a colour on the photo": "Poimi väri kuvasta", + "Saturation": "Värikylläisyys", + "Saturation Range": "Värikylläisyysalue", + "Saturday": "lauantai", + "Save": "Tallenna", + "Save As…": "Tallenna nimellä…", + "Save Color Label Set": "Tallenna värimerkintäjoukko", + "Save Current Filter as Preset…": "Tallenna nykyinen suodatin esiasetuksena…", + "Save Current Keywords as Set…": "Tallenna nykyiset avainsanat joukkona…", + "Save Current Settings as New Effect Preset…": "Tallenna nykyiset asetukset uudeksi tehosteesiasetukseksi…", + "Save Current Settings as New Preset…": "Tallenna nykyiset asetukset uudeksi esiasetukseksi…", + "Save Export Preset": "Tallenna vientiesiasetus", + "Save Filter Preset": "Tallenna suodinesiasetus", + "Save Keyword Set": "Tallenna avainsanajoukko", + "Save Metadata Preset": "Tallenna metatietoesiasetus", + "Save Metadata Preset…": "Tallenna metatietoesiasetus…", + "Save Metadata to File": "Tallenna metatiedot tiedostoon", + "Save Point Curve Preset": "Tallenna pistekäyrän esiasetus", + "Save Point Curve…": "Tallenna pistekäyrä…", + "Save as Preset": "Tallenna esiasetuksena", + "Save as Set…": "Tallenna joukkona…", + "Save as Smart Album…": "Tallenna älyalbumina…", + "Save as Template": "Tallenna mallipohjana", + "Save as a new set": "Tallenna uutena joukkona", + "Save as set": "Tallenna joukkona", + "Save preset": "Tallenna esiasetus", + "Saving Lightroom import index…": "Tallennetaan Lightroom-tuonnin hakemistoa…", + "Scale": "Skaalaus", + "Scan Folder for Changes": "Skannaa kansio muutosten varalta", + "Scan failed": "Skannaus epäonnistui", + "Scanning": "Skannataan", + "Scanning folder": "Skannataan kansiota", + "Scanning…": "Skannataan…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Pisteet säilyvät kuvissa: suodata tai tee älyalbumeja Tarkennus- ja Paras samankaltaisista otoksista -arvoilla.", + "Screen": "Näyttö", + "Scroll menu down": "Vieritä valikkoa alas", + "Scroll menu up": "Vieritä valikkoa ylös", + "Search": "Haku", + "Search Photos": "Hae kuvia", + "Search albums": "Hae albumeja", + "Search commands or keys": "Hae komentoja tai näppäimiä", + "Search effects": "Hae tehosteita", + "Search…": "Hae…", + "Seasons": "Vuodenajat", + "Second Window": "Toinen ikkuna", + "Select": "Valitse", + "Select All": "Valitse kaikki", + "Select Luminance Range": "Valitse kirkkausalue", + "Select Mask": "Valitse maski", + "Select Only Active Photo": "Valitse vain aktiivinen kuva", + "Select Photos": "Valitse kuvat", + "Select Spot": "Valitse täplä", + "Select a mask to apply or update presets.": "Valitse maski ottaaksesi esiasetuksia käyttöön tai päivittääksesi niitä.", + "Select a photo to edit": "Valitse muokattava kuva", + "Select a photo to preview its slide": "Valitse kuva esikatsellaksesi sen diaa", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Valitse kuva ja ota tämä käyttöön täyttääksesi sillä dian taustan jokaisen kuvan takana.", + "Select by": "Valitse perusteella", + "Select by Flag, Rating or Label": "Valitse lipun, luokituksen tai merkinnän mukaan", + "Select none": "Älä valitse mitään", + "Select photos to give them keywords.": "Valitse kuvat antaaksesi niille avainsanoja.", + "Select photos, then choose View → Survey (N)": "Valitse kuvat ja valitse sitten Näytä → Vertailu (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Valitse kaksi kuvaa ja valitse sitten Näytä → Vertaa (Shift+C)", + "Selected": "Valittu", + "Selecting…": "Valitaan…", + "Selenium Tone": "Seleenisävy", + "Send Feedback…": "Lähetä palautetta…", + "Separate several keywords with commas. Return gives them to the selected photos.": "Erota useat avainsanat pilkuilla. Return antaa ne valituille kuville.", + "Separated by commas": "Pilkuilla erotettu", + "Sepia Tone": "Seepiasävy", + "Sequence number zero-padded to N digits (1–9)": "Järjestysnumero nollilla täytettynä N numeroon (1–9)", + "Sequence number, counting from the start number": "Järjestysnumero, joka lasketaan aloitusnumerosta", + "Services": "Palvelut", + "Set": "Aseta", + "Set Album Cover": "Aseta albumin kansi", + "Set Before": "Aseta Ennen", + "Set Color Label": "Aseta värimerkintä", + "Set Crop": "Aseta rajaus", + "Set Develop Values": "Aseta kehitysarvot", + "Set Flag": "Aseta lippu", + "Set Folder Color Label": "Aseta kansion värimerkintä", + "Set Image Zoom and Pan": "Aseta kuvan zoomaus ja panorointi", + "Set Keyboard Shortcut": "Aseta pikanäppäin", + "Set Mask Adjustments": "Aseta maskin säädöt", + "Set Point Curve": "Aseta pistekäyrä", + "Set Profile": "Aseta profiili", + "Set Rating": "Aseta luokitus", + "Set Top of Stack": "Aseta pinon päällimmäinen", + "Set as Album Cover": "Aseta albumin kanneksi", + "Set as Before": "Aseta Ennen-kuvaksi", + "Set as Reference Photo": "Aseta vertailukuvaksi", + "Set as Target Album": "Aseta kohdealbumiksi", + "Set as Target Album (B adds to it)": "Aseta kohdealbumiksi (B lisää siihen)", + "Set as Top of Stack": "Aseta pinon päällimmäiseksi", + "Set date & time": "Aseta päivämäärä ja aika", + "Set name": "Aseta nimi", + "Set the mix from the photo's colours": "Aseta sekoitus kuvan väreistä", + "Set up AI Denoise…": "Ota tekoälykohinanpoisto käyttöön…", + "Settings": "Asetukset", + "Settings file problem": "Asetustiedoston ongelma", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Asetukset, joista uudet raw-kuvat lähtevät liikkeelle. Niiden muuttaminen ei koske kirjastossa jo olevia kuvia.", + "Settings pasted": "Asetukset liitetty", + "Settings to include": "Mukaan otettavat asetukset", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Asetukset → Vahvista ennen kuvien siirtämistä toiseen kansioon levyllä ottaa sen takaisin käyttöön", + "Settings…": "Asetukset…", + "Shade": "Varjostus", + "Shadow": "Varjo", + "Shadows": "Varjot", + "Shadows Hue": "Varjojen sävy", + "Shadows Luminance": "Varjojen kirkkaus", + "Shadows Saturation": "Varjojen värikylläisyys", + "Shadows Tint": "Varjojen värisävy", + "Shadows split": "Varjojen jako", + "Sharpen": "Terävöitä", + "Sharpening": "Terävöinti", + "Sharpness": "Terävyys", + "Shift": "Siirtymä", + "Shift by": "Siirrä", + "Shooting Data": "Kuvaustiedot", + "Short Edge": "Lyhyt sivu", + "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", + "Show Source": "Näytä lähde", + "Show Thumbnails": "Näytä pikkukuvat", + "Show all overlays": "Näytä kaikki peittokuvat", + "Show commands without a shortcut": "Näytä komennot, joilla ei ole pikanäppäintä", + "Show destination gamut warning": "Näytä kohteen väriavaruuden varoitus", + "Show display gamut warning": "Näytä näytön väriavaruuden varoitus", + "Show every keyword of the selected photos": "Näytä valittujen kuvien kaikki avainsanat", + "Show fewer": "Näytä vähemmän", + "Show file names": "Näytä tiedostonimet", + "Show guides": "Näytä apuviivat", + "Show in Compare": "Näytä Vertailussa", + "Show in Explorer": "Näytä Resurssienhallinnassa", + "Show in File Manager": "Näytä tiedostonhallinnassa", + "Show in Finder": "Näytä Finderissa", + "Show in Library": "Näytä Kirjastossa", + "Show less": "Näytä vähemmän", + "Show mask": "Näytä maski", + "Show on Map": "Näytä kartalla", + "Show overlay (O)": "Näytä peittokuva (O)", + "Show photos": "Näytä kuvat", + "Show ratings, flags and edit badges": "Näytä luokitukset, liput ja muokkausmerkit", + "Show the Navigator while zoomed in": "Näytä Navigaattori zoomattuna", + "Show the import review when a camera or card is connected": "Näytä tuontitarkistus, kun kamera tai kortti liitetään", + "Show the template tags; click one to insert it": "Näytä mallipohjan tunnisteet; napsauta yhtä lisätäksesi sen", + "Show this wheel's effect": "Näytä tämän pyörän vaikutus", + "Show/Hide Mask": "Näytä/piilota maski", + "Show/hide My Photos panel": "Näytä/piilota Omat kuvat -paneeli", + "Shows": "Näyttää", + "Shutter Speed": "Suljinaika", + "Side panels": "Sivupaneelit", + "Sidebar Sections": "Sivupalkin osiot", + "Sidecar names": "Sivutiedostojen nimet", + "Silver": "Hopea", + "Simulate Paper & Ink": "Simuloi paperi ja muste", + "Size": "Koko", + "Size (% of short edge)": "Koko (% lyhyestä sivusta)", + "Size limit": "Kokorajoitus", + "Skip": "Ohita", + "Skipped: files already in the library elsewhere": "Ohitettu: tiedostot, jotka ovat jo kirjastossa muualla", + "Skipped: files that can't be read": "Ohitettu: tiedostot, joita ei voi lukea", + "Sky": "Taivas", + "Slide": "Dia", + "Slide duration": "Dian kesto", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Diat käyttävät diaesityksen asettelua, peittokuvia sekä alku- ja lopetusnäyttöjä näkymän järjestyksessä.", + "Slideshow": "Diaesitys", + "Slideshow Options…": "Diaesityksen asetukset…", + "Slideshow Templates": "Diaesitysmallipohjat", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "Näytä-valikon Diaesitys toistaa näkymän kuvat valitusta kuvasta alkaen. Nämä asetukset tallennetaan kirjaston mukana.", + "Slideshow paused": "Diaesitys keskeytetty", + "Slideshow resumed": "Diaesitys jatkuu", + "Slideshow · Space pauses · Esc ends": "Diaesitys · Välilyönti keskeyttää · Esc lopettaa", + "Smart Album": "Älyalbumi", + "Smart Album Rule Fields": "Älyalbumin sääntökentät", + "Smart Preview Status": "Älyesikatselun tila", + "Smart Previews Location": "Älyesikatseluiden sijainti", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Älyalbumit päivittyvät automaattisesti: muuta niiden sääntöjä tai käytä Kuva → Poista kuva", + "Smart preview available": "Älyesikatselu saatavilla", + "Smart previews": "Älyesikatselut", + "Smart previews folder changed": "Älyesikatseluiden kansio muuttui", + "Smoothness": "Tasaisuus", + "Soft B&W": "Pehmeä M/V", + "Soft Pastel": "Pehmeä pastelli", + "Soft Proofing": "Vedoksen esikatselu", + "Soft Skin": "Pehmeä iho", + "Solo Mode": "Yksittäistila", + "Some rules need fixing (Edit Smart Album…):": "Joitakin sääntöjä pitää korjata (Muokkaa älyalbumia…):", + "Sort": "Lajittele", + "Sort Albums A–Z": "Lajittele albumit A–Ö", + "Sort Albums by Name": "Lajittele albumit nimen mukaan", + "Sort Contents A–Z": "Lajittele sisältö A–Ö", + "Sort by": "Lajitteluperuste", + "Source": "Lähde", + "Source code on GitHub": "Lähdekoodi GitHubissa", + "Specific folder": "Tietty kansio", + "Speed": "Nopeus", + "Spherical": "Pallomainen", + "Spiral": "Spiraali", + "Split Stack": "Jaa pino", + "Spot": "Täplä", + "Spring Fresh": "Kevään raikkaus", + "Square": "Neliö", + "Square Grid": "Neliöruudukko", + "Square Grid (G toggles)": "Neliöruudukko (G vaihtaa)", + "Square Grid: show file names and formats": "Neliöruudukko: näytä tiedostonimet ja -muodot", + "Stack": "Pino", + "Stack photos taken within this time of each other:": "Pinoa toistensa lähellä tässä ajassa otetut kuvat:", + "Stacks": "Pinot", + "Standard": "Vakio", + "Star rating (0–5)": "Tähtiluokitus (0–5)", + "Start at": "Aloita kohdasta", + "Start number": "Aloitusnumero", + "Starting": "Käynnistyy", + "Starting…": "Käynnistyy…", + "Startup": "Käynnistys", + "State / Province": "Osavaltio / lääni", + "Stop": "Pysäytä", + "Stop Building Previews": "Pysäytä esikatseluiden luonti", + "Stop Using as Target Album": "Lopeta käyttö kohdealbumina", + "Stopped": "Pysäytetty", + "Stopped by an error (see the log)": "Pysäytetty virheeseen (katso loki)", + "Stopping…": "Pysäytetään…", + "Straighten": "Suorista", + "Straighten Tool": "Suoristustyökalu", + "Stroke border": "Reunaviiva", + "Strong Contrast": "Voimakas kontrasti", + "Style": "Tyyli", + "Su": "Su", + "Subfolder": "Alikansio", + "Subfolder name": "Alikansion nimi", + "Subject": "Kohde", + "Subtract": "Vähennä", + "Subtract from this mask": "Vähennä tästä maskista", + "Suggest Names for Faces": "Ehdota nimiä kasvoille", + "Suggestions": "Ehdotukset", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Ehdottaa, ketkä kuvassa ovat, nimeämiesi kasvojen perusteella. Kaikki pysyy tietokoneellasi.", + "Summer Haze": "Kesän usva", + "Sunday": "sunnuntai", + "Survey": "Vertailu", + "Survey (N)": "Vertailu (N)", + "Swap": "Vaihda", + "Swap Aspect (X)": "Vaihda kuvasuhde (X)", + "Swap Before and After": "Vaihda Ennen ja Jälkeen keskenään", + "Swap Before and After Settings": "Vaihda Ennen- ja Jälkeen-asetukset keskenään", + "Swap Compare Photos": "Vaihda vertailtavat kuvat keskenään", + "Switch light / dark theme": "Vaihda vaalean ja tumman teeman välillä", + "Sync Settings": "Synkronoi asetukset", + "Sync with System": "Seuraa järjestelmää", + "Synchronize": "Synkronoi", + "Synchronize Folder…": "Synkronoi kansio…", + "Synchronizing folder": "Synkronoidaan kansiota", + "Synchronizing…": "Synkronoidaan…", + "Synonyms": "Synonyymit", + "System": "Järjestelmä", + "System Info": "Järjestelmätiedot", + "System Info…": "Järjestelmätiedot…", + "System default": "Järjestelmän oletus", + "Table": "Taulukko", + "Tags": "Tunnisteet", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tunnisteet: katso mallipohjan vieressä olevat Tunnisteet. Tiedostot nimetään uudelleen levyllä (XMP-sivutiedostoineen); olemassa olevat nimet saavat lopun -1, -2…", + "Take a snapshot": "Ota tilannevedos", + "Targeted Adjustment": "Kohdistettu säätö", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Kohdistettu säätö päällä: vedä kuvassa ylös tai alas · Esc lopettaa", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Kohdistettu säätö: vedä kuvassa ylös/alas säätääksesi siellä olevia värejä", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Kohdistettu säätö: vedä kuvassa ylös/alas säätääksesi käyrää siinä kohdassa", + "Teal & Amber": "Petrooli ja meripihka", + "Teal & Orange": "Petrooli ja oranssi", + "Temp": "Lämpötila", + "Temperature": "Lämpötila", + "Template": "Mallipohja", + "Template name": "Mallipohjan nimi", + "Templates": "Mallipohjat", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Väliaikainen istunto — kirjastoasi ei ole avattu, eikä täällä tekemääsi tallenneta.", + "Test Denoise Model": "Testaa kohinanpoistomalli", + "Test Face Model": "Testaa kasvomalli", + "Tested and works on this computer": "Testattu ja toimii tällä tietokoneella", + "Text": "Teksti", + "Text direction": "Tekstin suunta", + "Text overlays": "Tekstipeittokuvat", + "Texture": "Tekstuuri", + "Th": "To", + "That includes the photos in the folders inside it.": "Tämä sisältää myös sen sisällä olevien kansioiden kuvat.", + "That key can't be used as a shortcut": "Tätä näppäintä ei voi käyttää pikanäppäimenä", + "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 -malli on asennettu: Kohde- ja Kuvaile-maskit ovat valmiita.", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Kohinanpoistomalli on asennettu ja käytössä: Tekoälykohinanpoiston Määrä-liukusäädin Tarkastelun alla toimii nyt", + "The download didn't work:": "Lataus ei onnistunut:", + "The ending screen, after the last photo.": "Lopetusnäyttö viimeisen kuvan jälkeen.", + "The export watermark, drawn on the photo and sized relative to it.": "Viennin vesileima, piirretty kuvan päälle ja mitoitettu sen mukaan.", + "The first time, macOS asks whether LightCraft may control Photos.": "Ensimmäisellä kerralla macOS kysyy, saako LightCraft ohjata Kuvia-sovellusta.", + "The graphics card is set up when the next photo is made.": "Näytönohjain otetaan käyttöön, kun seuraava kuva muodostetaan.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Tuonti lukee katalogin muuttamatta sitä ja lisää sen kuvat, muokkaukset, avainsanat ja kokoelmat nyt avoinna olevaan kirjastoon.", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Alkunäyttö esitetään kerran ennen ensimmäistä kuvaa; lopetusnäyttö viimeisen kuvan jälkeen, kun Toisto on pois päältä. Tunnuslaatta on se, joka on asetettu kohdassa Peittokuvat. Otsikot häivytetään kuten diat, eikä satunnainen järjestys koskaan siirrä niitä.", + "The intro screen, before the first photo.": "Alkunäyttö ennen ensimmäistä kuvaa.", + "The layout image can't be shown": "Asettelukuvaa ei voi näyttää", + "The library is up to date with this folder.": "Kirjasto on ajan tasalla tämän kansion kanssa.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Malli säilytetään vain tällä tietokoneella. LightCraft ei koskaan lähetä tai jaa sitä.", + "The number at the end of the original name (IMG_0042 → 0042)": "Alkuperäisen nimen lopussa oleva numero (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Alkuperäinen tiedostopääte lisätään aina; {ext} vain sijoittaa sen myös nimen sisään.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Alkuperäiset tiedostot muuttumattomina, kullakin XMP-sivutiedosto, joka sisältää sen muokkaukset.", + "The photo has these settings": "Kuvalla on nämä asetukset", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Näkymän kuvat dioina yllä olevilla asetuksilla. Edistyminen näkyy toimintapinossa.", + "The processor does the work.": "Suoritin tekee työn.", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Valittu kuva diana. Toisto täyttää näytön; kuvasuhteen esikatselu muuttaa vain tätä esikatselua.", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Niiden tiedostot ja XMP-sivutiedostot siirtyvät levyllä. Muokkaa → Kumoa siirtää ne takaisin.", + "Their photos and the keywords below them come together under one keyword.": "Niiden kuvat ja niiden alla olevat avainsanat yhdistyvät yhden avainsanan alle.", + "Their photos in the grid": "Niiden kuvat ruudukossa", + "Theme": "Teema", + "There is no model file to install": "Asennettavaa mallitiedostoa ei ole", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Nämä kuvat ovat vain muistissa. Avaa levyllä oleva kirjasto ja tuo katalogi uudelleen tallentaaksesi ne.", + "They can be restored from Recently Deleted until it is emptied.": "Ne voi palauttaa Äskettäin poistetuista, kunnes se tyhjennetään.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Ne siirtyvät Äskettäin poistettuihin ja ne voi palauttaa; levyllä oleviin tiedostoihin ei kosketa.", + "Thirds": "Kolmasosat", + "This Computer": "Tämä tietokone", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Tämä versio ei vielä voi ladata mallia. Asentaaksesi sen käsin laita Metan facebook/sam3-mallin tiedostot model.safetensors, vocab.json ja merges.txt yllä olevaan kansioon: Kohde ja Kuvaile toimivat heti, kun ne ovat siellä.", + "This build cannot run denoise models.": "Tämä versio ei voi ajaa kohinanpoistomalleja.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Tämä versio ei voi ajaa tunnistusmalleja: ne voi lisätä ja valita, mutta ei käyttää.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Tällä versiolla ei ole paikkaa kohinanpoistomalleille, joten tekoälykohinanpoisto ei ole käytettävissä. (Työpöytäsovelluksessa on.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Tällä versiolla ei ole paikkaa kasvomalleille, joten malliluettelo ei ole käytettävissä. (Työpöytäsovelluksessa on.)", + "This file cannot be used yet": "Tätä tiedostoa ei voi vielä käyttää", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Tämä kansio ei ole käytettävissä (asema irrotettu?). Älyesikatseluita ei luoda eikä käytetä, ennen kuin se on takaisin tai valitset toisen kansion.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Kansiota ei ole siellä: se siirrettiin tai nimettiin uudelleen, tai sen levy ei ole kytkettynä.", + "This group is empty: add a rule or remove it.": "Tämä ryhmä on tyhjä: lisää sääntö tai poista se.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Tämä on Lightroom-katalogi, ei LightCraft-kirjasto. Tuodaanko se sen sijaan LightCraft-kirjastoon?", + "This is the album you're editing": "Tämä on albumi, jota muokkaat", + "This page setup is not valid": "Tämä sivuasetus ei kelpaa", + "This photo can't be opened": "Tätä kuvaa ei voi avata", + "This raw is now developed from its raw data": "Tämä raw kehitetään nyt raakadatastaan", + "This raw variant still can't be decoded": "Tätä raw-muunnelmaa ei vieläkään voi purkaa", + "This row covers more than one disk: drop on a folder inside it": "Tämä rivi kattaa useamman kuin yhden levyn: pudota sen sisällä olevaan kansioon", + "This screen is off: turn it on under Titles to play it.": "Tämä näyttö on pois päältä: ota se käyttöön kohdassa Otsikot toistaaksesi sen.", + "Threshold": "Kynnys", + "Thumbnail cache": "Pikkukuvien välimuisti", + "Thumbnail size": "Pikkukuvien koko", + "Thursday": "torstai", + "Tick the box to accept the model's terms first": "Hyväksy ensin mallin ehdot valitsemalla ruutu", + "Tidying the thumbnail cache": "Siivotaan pikkukuvien välimuistia", + "Time zone": "Aikavyöhyke", + "Tint": "Värisävy", + "Title": "Otsikko", + "Title bar": "Otsikkopalkki", + "Title metadata": "Otsikon metatiedot", + "Title text": "Otsikkoteksti", + "Titles": "Otsikot", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Antaaksesi tälle muokkausohjelmalle alkuperäisen tiedoston itsensä (raw ilman kopiota, esimerkiksi DxO PhotoLab) napsauta kuvaa hiiren kakkospainikkeella → Muokkaa ohjelmassa → Avaa alkuperäinen toisessa muokkausohjelmassa (RAW, ei kopiota).", + "Toggle Keyword from Set": "Vaihda avainsana joukosta", + "Toggle Mask": "Vaihda maski", + "Toggle Section": "Vaihda osio", + "Toggle Zoom": "Vaihda zoomaus", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tunnisteet: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Muu teksti näkyy sellaisenaan.", + "Tone Curve": "Sävykäyrä", + "Tool Overlay": "Työkalun peittokuva", + "Tools": "Työkalut", + "Top": "Yläreuna", + "Top Level": "Ylin taso", + "Tracking": "Seuranta", + "Transfer": "Siirto", + "Treatment": "Käsittely", + "Triangle": "Kolmio", + "Try Again": "Yritä uudelleen", + "Try Saving Again": "Yritä tallentaa uudelleen", + "Try again": "Yritä uudelleen", + "Try the graphics card again": "Kokeile näytönohjainta uudelleen", + "Tu": "Ti", + "Tuesday": "tiistai", + "Tungsten": "Hehkulamppu", + "Turn Off": "Poista käytöstä", + "Turn off": "Poista käytöstä", + "Turn on Include subfolders, or pick another folder under Local": "Ota Sisällytä alikansiot käyttöön tai valitse toinen kansio kohdasta Paikallinen", + "Type": "Tyyppi", + "Uncheck All": "Poista kaikkien valinta", + "Uncheck the files of this type": "Poista tämän tyyppisten tiedostojen valinta", + "Undo": "Kumoa", + "Unedited": "Muokkaamaton", + "Unedited raws": "Muokkaamattomat raw-kuvat", + "Unflag": "Poista lippu", + "Unflagged": "Ei lippua", + "Ungroup Stack": "Pura pino", + "Ungrouped": "Ryhmittelemättömät", + "Unknown": "Tuntematon", + "Unknown Date": "Tuntematon päivämäärä", + "Unknown date": "Tuntematon päivämäärä", + "Unknown licence": "Tuntematon lisenssi", + "Unknown tags stay as typed — check the preview for a {typo}.": "Tuntemattomat tunnisteet pysyvät sellaisinaan — tarkista esikatselusta {typo}.", + "Unnamed Faces": "Nimeämättömät kasvot", + "Unnamed face": "Nimeämätön kasvo", + "Unrated": "Luokittelematon", + "Unreadable": "Lukukelvoton", + "Update": "Päivitä", + "Update Mask Shape": "Päivitä maskin muoto", + "Update Preset with Current Settings": "Päivitä esiasetus nykyisillä asetuksilla", + "Update Rules from Current Filter": "Päivitä säännöt nykyisestä suodattimesta", + "Update Version": "Päivitä versio", + "Update Version with Current Settings": "Päivitä versio nykyisillä asetuksilla", + "Update to Current Process": "Päivitä nykyiseen prosessiin", + "Update with Current Settings": "Päivitä nykyisillä asetuksilla", + "Updated the edits saved in the external editor": "Ulkoisessa muokkausohjelmassa tallennetut muokkaukset päivitetty", + "Upright": "Upright", + "Urban": "Urbaani", + "Use": "Käytä", + "Use Keyword Set": "Käytä avainsanajoukkoa", + "Use Library Folder": "Käytä kirjastokansiota", + "Use Screen Profile": "Käytä näytön profiilia", + "Use camera-specific defaults": "Käytä kamerakohtaisia oletuksia", + "Use export watermark": "Käytä vientivesileimaa", + "Use sRGB": "Käytä sRGB:tä", + "Use selected photo": "Käytä valittua kuvaa", + "Use the GPU for rendering": "Käytä GPU:ta renderöintiin", + "User Presets": "Käyttäjän esiasetukset", + "Username": "Käyttäjänimi", + "Value": "Arvo", + "Variance": "Vaihtelu", + "Version": "Versio", + "Version name": "Version nimi", + "Versions": "Versiot", + "Versions (Shift+V)": "Versiot (Shift+V)", + "Vertical": "Pysty", + "Vertical inset (% of short edge)": "Pystysuuntainen sisennys (% lyhyestä sivusta)", + "Vertical text": "Pystysuuntainen teksti", + "Vibrance": "Eloisuus", + "Video": "Video", + "Video Duration": "Videon kesto", + "Videos": "Videot", + "View": "Näytä", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Katseluohjelmat, jotka eivät lue PNG:n cICP-lohkoa, näyttävät PQ-arvot sRGB:nä: latteina ja haaleina. Jos haluat kuvan, joka näyttää oikealta kaikkialla, käytä JPEG-muotoa (vahvistuskartta).", + "Vignette": "Vinjetti", + "Vignetting": "Vinjetointi", + "Vintage": "Vintage", + "Virtual Copy": "Virtuaalikopio", + "Visible": "Näkyvä", + "Visualize HDR": "Havainnollista HDR", + "Visualize Spots": "Havainnollista täplät", + "Visualize Spots (A)": "Havainnollista täplät (A)", + "Visualize range": "Havainnollista alue", + "Vivid": "Eloisa", + "Vivid Pop": "Eloisa pop", + "Wait for the import to finish": "Odota tuonnin valmistumista", + "Waiting to start…": "Odotetaan käynnistymistä…", + "Warm Film": "Lämmin filmi", + "Warm Glow": "Lämmin hehku", + "Warm Print": "Lämmin vedos", + "Warm Table": "Lämmin pöytä", + "Watched folder": "Tarkkailtu kansio", + "Watermark": "Vesileima", + "We": "Ke", + "Wednesday": "keskiviikko", + "What's New": "Mitä uutta", + "When pins show on the photo (Shift+H cycles)": "Milloin nastat näkyvät kuvassa (Shift+H vaihtaa)", + "When rated or hovered": "Luokiteltaessa tai osoitettaessa", + "White Balance": "Valkotasapaino", + "White Balance Selector": "Valkotasapainon valitsin", + "White Balance Selector (W)": "Valkotasapainon valitsin (W)", + "White on Black": "Valkoinen mustalla", + "Whites": "Valkoiset", + "Whole names": "Kokonaiset nimet", + "Wide": "Leveä", + "Width": "Leveys", + "Width (% of photo)": "Leveys (% kuvasta)", + "Width (px)": "Leveys (px)", + "Width × Height": "Leveys × korkeus", + "Will Export": "Viedään", + "Will be replaced": "Korvataan", + "Will be restored": "Palautetaan", + "Window": "Ikkuna", + "Winter Blue": "Talvensininen", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Toimii raw-tiedostoilla kameroista, joissa on tavallinen (Bayer) -anturi. Muut tiedostot säilyttävät tavallisen kohinanpoistonsa.", + "Wrote the XMP sidecars that were waiting": "Odottaneet XMP-sivutiedostot kirjoitettu", + "XMP Sidecar Preferences": "XMP-sivutiedostojen asetukset", + "XMP sidecars": "XMP-sivutiedostot", + "Year": "Vuosi", + "Yellow": "Keltainen", + "Yellow Hue": "Keltaisen sävy", + "Yellow Luminance": "Keltaisen kirkkaus", + "Yellow Saturation": "Keltaisen värikylläisyys", + "Yellow label": "Keltainen merkintä", + "Yes": "Kyllä", + "You can close this: the download continues, and resumes if interrupted.": "Voit sulkea tämän: lataus jatkuu ja palaa käyntiin, jos se keskeytyy.", + "Your Name": "Nimesi", + "Your library couldn't be opened": "Kirjastoasi ei voitu avata", + "Your library is empty": "Kirjastosi on tyhjä", + "Your name or studio": "Nimesi tai studiosi", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Omat mallipohjasi tarvitsevat asetuskansion, jota tässä versiossa ei ole; sisäänrakennetut toimivat.", + "Your templates": "Omat mallipohjasi", + "Zone": "Alue", + "Zoom": "Zoomaus", + "Zoom 100%": "Zoomaus 100 %", + "Zoom In": "Lähennä", + "Zoom Out": "Loitonna", + "Zoom to Fit": "Zoomaa sovittamaan", + "Zoom to Focus Point": "Zoomaa tarkennuspisteeseen", + "Zoom to fill frame": "Zoomaa täyttämään kehys", + "Zoom to page level": "Zoomaa sivun tasolle", + "a literal %": "kirjaimellinen %", + "a removed photo": "poistettu kuva", + "and": "ja", + "any": "mikä tahansa", + "are empty": "ovat tyhjiä", + "aren't empty": "eivät ole tyhjiä", + "cached picture": "välimuistissa oleva kuva", + "cached pictures": "välimuistissa olevaa kuvaa", + "can't be tested that way": "ei voi testata sillä tavalla", + "can't match any rating from 0 to 5": "ei voi vastata mitään luokitusta 0–5", + "can't test a folder; choose an album in it": "kansiota ei voi testata; valitse sen sisältä albumi", + "cells overlap": "solut menevät päällekkäin", + "comma, separated": "pilkuilla, erotettu", + "commercial use allowed": "kaupallinen käyttö sallittu", + "commercial use subject to GPL conditions": "kaupallinen käyttö GPL-ehtojen mukaan", + "contains": "sisältää", + "day 01–31": "päivä 01–31", + "days": "päivää", + "doesn't contain": "ei sisällä", + "e.g. sky · the red car · car, road": "esim. taivas · punainen auto · auto, tie", + "ends with": "päättyy", + "false": "epätosi", + "has the first date after the second": "ensimmäinen päivämäärä on toisen jälkeen", + "hour 00–23": "tunti 00–23", + "in use": "käytössä", + "includes the folders inside it": "sisältää sen sisällä olevat kansiot", + "installed": "asennettu", + "is": "on", + "is <": "on <", + "is >": "on >", + "is after": "on jälkeen", + "is before": "on ennen", + "is between": "on välillä", + "is empty": "on tyhjä", + "is in the last": "on viimeisen", + "is unknown": "on tuntematon", + "is yes or no": "on kyllä tai ei", + "is ≤": "on ≤", + "is ≥": "on ≥", + "isn't": "ei ole", + "isn't a field rules know": "ei ole kenttä, jonka säännöt tuntevat", + "isn't empty": "ei ole tyhjä", + "isn't in the last": "ei ole viimeisen", + "isn't one of the choices": "ei ole yksi vaihtoehdoista", + "keep names — or e.g. {date}_{seq:3}": "säilytä nimet — tai esim. {date}_{seq:3}", + "latitude, longitude": "leveysaste, pituusaste", + "makes AI Denoise available": "tekee tekoälykohinanpoiston käytettäväksi", + "memory only": "vain muisti", + "minute": "minuutti", + "month 01–12": "kuukausi 01–12", + "months": "kuukautta", + "names an album that no longer exists": "nimeää albumin, jota ei enää ole", + "needs a date like 2026, 2026-08 or 2026-08-14": "tarvitsee päivämäärän, kuten 2026, 2026-08 tai 2026-08-14", + "needs a number": "tarvitsee numeron", + "needs a number more than 0": "tarvitsee nollaa suuremman numeron", + "needs a time like 1/250 or 2": "tarvitsee ajan, kuten 1/250 tai 2", + "needs an album": "tarvitsee albumin", + "needs hours, days, weeks, months or years": "tarvitsee tunteja, päiviä, viikkoja, kuukausia tai vuosia", + "needs something to look for (or use “are empty”)": "tarvitsee jotain haettavaa (tai käytä ”ovat tyhjiä”)", + "needs something to look for (or use “aren't empty”)": "tarvitsee jotain haettavaa (tai käytä ”eivät ole tyhjiä”)", + "needs something to look for (or use “is empty”)": "tarvitsee jotain haettavaa (tai käytä ”on tyhjä”)", + "needs something to look for (or use “isn't empty”)": "tarvitsee jotain haettavaa (tai käytä ”ei ole tyhjä”)", + "needs text": "tarvitsee tekstiä", + "needs two values": "tarvitsee kaksi arvoa", + "new keyword": "uusi avainsana", + "non-commercial use only": "vain ei-kaupalliseen käyttöön", + "none": "ei mitään", + "none (CPU rendering)": "ei mitään (suoritinrenderöinti)", + "none of": "ei mikään seuraavista", + "not added to Apple Photos (cancelled)": "ei lisätty Apple Kuviin (peruttu)", + "not available here": "ei käytettävissä täällä", + "of the following rules:": "seuraavista säännöistä:", + "or": "tai", + "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", + "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", + "saved in": "tallennettu kohteeseen", + "second": "sekunti", + "starts using it for AI Denoise": "alkaa käyttää sitä tekoälykohinanpoistoon", + "starts with": "alkaa", + "terms unclear": "ehdot epäselvät", + "the graphics card": "näytönohjain", + "the graphics card is not used": "näytönohjainta ei käytetä", + "the photos in view": "näkymän kuvat", + "the selected photos": "valitut kuvat", + "this photo": "tämä kuva", + "this screen's profile": "tämän näytön profiili", + "true": "tosi", + "weeks": "viikkoa", + "would make this album include itself through other smart albums": "saisi tämän albumin sisältämään itsensä muiden älyalbumien kautta", + "year, 2 digits": "vuosi, 2 numeroa", + "year, 4 digits": "vuosi, 4 numeroa", + "years": "vuotta", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} on kuvausaika; kuvalla, jolla sitä ei ole, käytetään tuontiaikaa.", + "© 2026 Your Name": "© 2026 Nimesi", + "© Your Name": "© Nimesi", + "− harder border · + softer border": "− terävämpi reuna · + pehmeämpi reuna", + "≤ at most": "≤ enintään", + "≥ at least": "≥ vähintään", + "★ Favorites": "★ Suosikit" +} diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 1ecd4c4b2..ae2b77872 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", "· {in_source} identical copies": "· {in_source} copies identiques", "{count} more warnings.": "{count} avertissements supplémentaires.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} images du catalogue n’ont pas été importées, car leurs entrées dans le catalogue sont incomplètes.", "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.", "Applied {name}": "{name} appliqué", @@ -295,5 +296,6 @@ "Already in “{}”": "Déjà dans « {} »", "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." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index fa57e373e..2f22a70c1 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -403,6 +403,7 @@ "Detail (D)": "Détail (D)", "Details": "Détails", "Diagonal": "Diagonale", + "Dimensions": "Dimensions", "Discard Preview Cache": "Supprimer le cache des aperçus", "Display P3": "Display P3", "Display name": "Nom affiché", @@ -1364,6 +1365,7 @@ "Show in Explorer": "Afficher dans l'Explorateur", "Show in File Manager": "Afficher dans le gestionnaire de fichiers", "Show in Finder": "Afficher dans le Finder", + "Show in Folders": "Afficher dans les dossiers", "Show on Map": "Afficher sur la carte", "Show overlay (O)": "Afficher l'incrustation (O)", "Show ratings, flags and edit badges": "Afficher les notes, les drapeaux et les pastilles de retouche", @@ -1571,7 +1573,7 @@ "Width": "Largeur", "Width (% of photo)": "Largeur (% de la photo)", "Width (px)": "Largeur (px)", - "Width × Height": "Largeur × hauteur", + "Width & Height": "Largeur & hauteur", "Will be replaced": "Sera remplacée", "Will be restored": "Sera restaurée", "Window": "Fenêtre", diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json new file mode 100644 index 000000000..b3887f917 --- /dev/null +++ b/crates/ui-egui/locales/id-formats.json @@ -0,0 +1,301 @@ +{ + " · {skipped} skipped (file exists)": " · {skipped} dilewati (file sudah ada)", + " · {} duplicate{} skipped": " · {} duplikat dilewati{:.0}", + " · {} failed: {}": " · {} gagal: {}", + " · {} moved": " · {} dipindahkan", + " · {} not readable": " · {} tidak dapat dibaca", + " · {} restored from Recently Deleted": " · {} dipulihkan dari Baru dihapus", + ", {failed} file{} not readable": ", {failed} file tidak dapat dibaca{:.0}", + "Add {local_n} to My Photos": "Tambahkan {local_n} ke Foto saya", + "Add {} Photo{}": "Tambahkan {} foto{:.0}", + "Add {} Photo{}, restore {}": "Tambahkan {} foto{:.0}, pulihkan {}", + "Add “{}” to Favorites": "Tambahkan “{}” ke Favorit", + "Added {n} photo{} to My Photos": "{n} foto{:.0} ditambahkan ke Foto saya", + "Added {n} photo{} to “{}”": "{n} foto ditambahkan ke “{1}”{0:.0}", + "All {n} selected photos move by the same amount.": "Semua {n} foto yang dipilih digeser dengan selisih waktu yang sama.", + "Already in “{}”": "Sudah ada di “{}”", + "Auto Sync: edits apply to {n} photos": "Sinkronisasi otomatis: edit diterapkan ke {n} foto", + "Automatic ({} MB)": "Otomatis ({} MB)", + "Click {}:1": "Klik {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Ruang warna: Rec. 2020 {curve} (AVIF HDR)", + "Create {n} Virtual Copies": "Buat {n} salinan virtual", + "Decoder: {reason}": "Dekoder: {reason}", + "Delete \"{label}\"": "Hapus \"{label}\"", + "Delete \"{label}\" (and the mask)": "Hapus \"{label}\" (beserta maskernya)", + "Delete {n} Photos": "Hapus {n} foto", + "Delete “{current}”": "Hapus “{current}”", + "Duplicate \"{label}\"": "Duplikat \"{label}\"", + "Example: {example}": "Contoh: {example}", + "Exported {n} photo{}": "{n} foto{:.0} diekspor", + "Exported {ok} of {total} photo{}": "{ok} dari {total} foto diekspor{:.0}", + "Exported {} point curve preset{}": "{} preset kurva titik{:.0} diekspor", + "Exported {} preset{}": "{} preset{:.0} diekspor", + "Exporting {} of {total}": "Mengekspor {} dari {total}", + "Filter bar — {active} active filter{}": "Bilah filter — {active} filter aktif{:.0}", + "Healed {n} dust spot{}": "{n} bintik debu diperbaiki{:.0}", + "Imported {n} point curve preset{}": "{n} preset kurva titik diimpor{:.0}", + "Imported {n} preset{}": "{n} preset{:.0} diimpor", + "Imported {n} preset{}, {f} file{} not readable": "{n} preset diimpor{0:.0}, {f} file tidak dapat dibaca{1:.0}", + "Imported {} photo{}": "{} foto{:.0} diimpor", + "LightCraft could not open its window: {e}": "LightCraft tidak dapat membuka jendela: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft tidak dapat membuka jendela: {e}\n\nDetailnya ada di file log: {path}", + "Matches: {}": "Cocok: {}", + "Merging… {stage} {:.0}%": "Menggabungkan… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Pindahkan {n} foto{0:.0} ke “{1}”", + "Move {n} photo{} to “{}”?": "Pindahkan {n} foto{0:.0} ke “{1}”?", + "Move {what} to Recently Deleted?": "Pindahkan {what} ke Baru dihapus?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto dipindahkan ke “{1}”{0:.0}. Pilih Edit → Urungkan untuk mengembalikannya.", + "Named ({named_n})": "Bernama ({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "Tempel ke {n} foto{:.0} — hanya menempelkan pengaturan yang disalin", + "Preset: {name}": "Preset: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Hanya pratinjau — yang diedit adalah JPEG bawaan kamera ({})", + "Preview only — {}": "Hanya pratinjau — {}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Salinan soft proof ({})", + "Proof Preview · {}": "Pratinjau soft proof · {}", + "Quick Develop · {n} photos": "Olah cepat · {n} foto", + "Rated {}": "Diberi rating {}", + "Reading Lightroom catalog… {done} of {total}": "Membaca katalog Lightroom… {done} dari {total}", + "Reading photos… {done} of {total}": "Membaca foto… {done} dari {total}", + "Redo {}": "Ulangi {}", + "Remove “{}” from Favorites": "Keluarkan “{}” dari Favorit", + "Removed from {}": "Dikeluarkan dari {}", + "Rename {n} Photos…": "Ganti nama {n} foto…", + "Rename “{name}”": "Ganti nama “{name}”", + "Rendering on the CPU: {why}": "Merender dengan CPU: {why}", + "Reset {} Channel": "Atur ulang kanal {}", + "Restore {}": "Pulihkan {}", + "Restore {} Photo{}": "Pulihkan {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "{} foto{:.0} dipulihkan dari Baru dihapus", + "Saved export preset “{}”": "Preset ekspor “{}” disimpan", + "Show {hidden} hidden location{}": "Tampilkan {hidden} lokasi tersembunyi{:.0}", + "Showing {n} of {}": "Menampilkan {n} dari {}", + "Smart album: {}": "Album pintar: {}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "File sidecar XMP untuk {n} foto tidak dapat ditulis (apakah foldernya sedang offline?). LightCraft akan terus mencoba.", + "Undo {}": "Urungkan {}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versi {} — pustaka foto dan pengolah raw yang dibuat secara clean-room dengan Rust murni.", + "Version: {}": "Versi: {}", + "none (CPU rendering): {why}": "tidak ada (rendering CPU): {why}", + "the {n} selected photos": "{n} foto yang dipilih", + "the {} photos in view": "{} foto yang ditampilkan", + "these {count} photos": "{count} foto ini", + "{count} smart preview{} · {:.1} MB": "{count} pratinjau pintar{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} folder (beserta subfoldernya)", + "{label} Grading": "Grading {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} pada setiap foto yang dipilih", + "{label}, {n} photos": "{label}, {n} foto", + "{matching} of {t} photos": "{matching} dari {t} foto", + "{matching} photos": "{matching} foto", + "{mode} spot {} of {}": "{mode}: bintik {} dari {}", + "{msg} ({failed} failed)": "{msg} ({failed} gagal)", + "{name} Label": "Label {name}", + "{name} Off": "{name} nonaktif", + "{name} On": "{name} aktif", + "{name} — double-click to reset it": "{name} — klik dua kali untuk mengatur ulang", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} perubahan{0:.0} tersimpan di memori, tetapi belum ditulis ke disk: {e}\nLightCraft akan otomatis mencoba lagi; jika Anda keluar sekarang, perubahan tersebut akan hilang.{1:.0}", + "{n} found · {sel} selected": "{n} ditemukan · {sel} dipilih", + "{n} photos": "{n} foto", + "{n} photos in {} are hidden by the filters above": "{n} foto di {} disembunyikan oleh filter di atas", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} foto cocok{:.0} · diperbarui otomatis saat foto berubah", + "{n} photo{} now · updates automatically as photos change": "Saat ini {n} foto{:.0} · diperbarui otomatis saat foto berubah", + "{n} selected photos · {pages} pages": "{n} foto dipilih · {pages} halaman", + "{sel_n} selected · {counted}": "{sel_n} dipilih · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{what} merge added": "Hasil penggabungan {what} ditambahkan", + "{what} merge failed: {e}": "Penggabungan {what} gagal: {e}", + "{} and higher": "{} ke atas", + "{} files": "{} file", + "{} files and {f} folder{}": "{} file dan {f} folder{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} dari 4 garis panduan — seret di sepanjang garis yang seharusnya vertikal atau horizontal.", + "{} photos": "{} foto", + "{} photo{} not readable": "{} foto tidak dapat dibaca{:.0}", + "{} spot(s) on this photo": "{} bintik di foto ini", + "· {dups} already in the library": "· {dups} sudah ada di pustaka", + "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} di “Baru dihapus”", + "… and {more} more": "… dan {more} lainnya", + "Import cancelled · {} photo{} added": "Impor dibatalkan · {} foto{:.0} ditambahkan", + "Reading photos… {} of {}": "Membaca foto… {} dari {}", + "Adding photos… {} of {}": "Menambahkan foto… {} dari {}", + " ({} in Recently Deleted)": " ({} di Baru dihapus)", + "Already in the library: {} photo{} selected in All Photos": "Sudah ada di pustaka: {} foto{:.0} dipilih di Semua foto", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Sudah ada di pustaka, di Baru dihapus: {} foto{:.0} dipilih · klik kanan → Pulihkan, atau Hapus permanen untuk mengimpor ulang", + "Auto ({})": "Otomatis ({})", + "Build 1:1 Previews ({scope})": "Buat pratinjau 1:1 ({scope})", + "Build Smart Previews ({scope})": "Buat pratinjau pintar ({scope})", + "Build Standard-Sized Previews ({scope})": "Buat pratinjau ukuran standar ({scope})", + "Discard Smart Previews ({scope})": "Buang pratinjau pintar ({scope})", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Katalog Lightroom diperiksa: {photos} foto, {collections} koleksi, {missing} hilang", + "Lightroom import complete: {imported} photos added": "Impor Lightroom selesai: {imported} foto ditambahkan", + "Lightroom import complete: {imported} photos added, {failed} failed": "Impor Lightroom selesai: {imported} foto ditambahkan, {failed} gagal", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Impor Lightroom selesai: {imported} foto ditambahkan; peringatan indeks: {warning}", + "Lightroom import failed: {e}{warning}": "Impor Lightroom gagal: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Pemeriksaan Lightroom gagal: {e}{warning}", + "Label Set: {name}": "Set label: {name}", + "Quick Develop: {name}": "Olah cepat: {name}", + "Reset {name}": "Atur ulang {name}", + "Updated “{name}”": "“{name}” diperbarui", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day:02}/{month:02}/{year}", + "{year}": "{year}", + "Remove “{name}” and its {count} photo{} from the library?": "Keluarkan “{name}” beserta {count} foto{:.0} di dalamnya dari pustaka?", + " ({unsure} with several look-alike files: use Locate)": " ({unsure} memiliki beberapa file serupa: gunakan “Temukan file hilang”)", + " · {:.0}% clipped": " · {:.0}% terpotong", + " · {failed} couldn't be rendered": " · {failed} gagal dirender", + " · {} original{} left at the source": " · {} file asli{:.0} tetap di sumber", + " — not carried over: {}{}": " — tidak ikut terbawa: {}{}", + "+{} more": "+{} lainnya", + "; {kept} already had a location": "; {kept} sudah memiliki lokasi", + "; {outside} outside its time range": "; {outside} di luar rentang waktunya", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} baris (Δ {}), +{} / −{} aset biner, {} – {}", + "Added {what} to {name}": "{what} ditambahkan ke {name}", + "Auto Import: added {} photo{}": "Impor otomatis: {} foto{:.0} ditambahkan", + "Building previews for {} photos…": "Membuat pratinjau untuk {} foto…", + "Couldn't open the editor: {e}": "Tidak dapat membuka editor: {e}", + "Creates {} stacks from {} of {scope}": "Membuat {} tumpukan dari {} di antara {scope}", + "Done ({what}): {done} photo{plural}": "Selesai ({what}): {done} foto{plural:.0}", + "Focus {:.0}": "Fokus {:.0}", + "Folder “{}” (and its subfolders)": "Folder “{}” (beserta subfoldernya)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Ditemukan {n} foto yang hilang{:.0}; {left} masih hilang{unsure}", + "Import failed: {e}": "Impor gagal: {e}", + "Import warnings ({n}):\n{warnings}": "Peringatan impor ({n}):\n{warnings}", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP \"{profile}\" tidak diterapkan untuk {path}; warna mungkin berbeda dari Lightroom.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometri Lightroom untuk {path} tidak dipertahankan; {mode} akan dihitung ulang.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi tersimpan diabaikan karena Tegakkan nonaktif.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi Tegakkan terpandu yang tersimpan tidak didukung.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi Tegakkan yang tersimpan tidak didukung.", + "XMP adjustments were not imported for {path}: {fields}.": "Penyesuaian XMP untuk {path} tidak diimpor: {fields}.", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil diimpor{:.0} (Penjelajah profil → grup masing-masing)", + "Insert at the cursor (also written {})": "Sisipkan di posisi kursor (dapat juga ditulis {})", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Font: {} (OFL). Ikon: orisinal.", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Menerapkan “{k}”: klik foto untuk menambahkan atau menghapusnya · Esc untuk berhenti", + "Previews ready for {done} photo{plural}": "Pratinjau untuk {done} foto{plural:.0} sudah siap", + "Removed {what} from {name}": "{what} dihapus dari {name}", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Mengganti nama “{from}” di {n} foto{:.0} (termasuk kata kunci di bawahnya). Gunakan | untuk tingkatan, mis. Liburan|Italia. Jika nama sudah ada, keduanya akan digabungkan.", + "Replace {} with:": "Ganti {} dengan:", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Menilai fokus dan eksposur {scope}, lalu mencari bidikan serupa yang diambil berselang beberapa detik (bidikan beruntun).", + "Tagged {n} photo{} from the tracklog": "{n} foto diberi lokasi dari log jejak GPS{:.0}", + "The SAM 3 download failed: {e}": "Gagal mengunduh SAM 3: {e}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tag tak dikenal{s:.0} {0} tetap seperti yang diketik{1:.0} (lihat Tag)", + "Virtual copy “{name}” of {of}": "Salinan virtual “{name}” dari {of}", + "Working on {what} for {} photos…": "Memproses {what} untuk {} foto…", + "{label} {index} of {n}": "{label} {index} dari {n}", + "{name} opened for editing; it is stacked with the original": "{name} dibuka untuk diedit dan ditumpuk dengan file aslinya", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} perubahan{0:.0} tidak dapat ditulis ke disk: {e}\nJika Anda keluar sekarang, perubahan tersebut akan hilang{1:.0}.", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} perubahan{:.0} tersimpan di memori, tetapi belum ditulis ke disk: {e} — LightCraft akan mencoba lagi", + "{pid} (missing)": "{pid} (hilang)", + "{{date:…}} directives: {}": "Direktif {{date:…}}: {}", + "{} contributors · {} commits": "{} kontributor · {} commit", + "{} failed": "{} gagal", + "{} item{}": "{} item{:.0}", + "{} photos · exposures {} EV": "{} foto · eksposur {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", + "{} sample{}": "{} sampel{:.0}", + "{} sec": "{} dtk", + "{} — show their photos": "{} — tampilkan foto orang ini", + "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 belum dapat mendekode varian raw ini ({}). Anda sedang mengedit pratinjau JPEG bawaan kamera, yang sudah menyertakan gaya gambar kamera (mis. Monokrom) dan keseimbangan putihnya.", + "Filtered: {label}": "Difilter: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto serupa{:.0} · Tampilan → Hapus filter untuk melihat semua", + "Moved; {} photo(s) relinked": "Dipindahkan; {} foto ditautkan ulang", + "{} label (click more labels to show any of them)": "Label {} (klik label lain untuk ikut menampilkan foto berlabel tersebut)", + ", {} star{}": ", {} bintang{:.0}", + ", {} label": ", label {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Diunduh dari {host}. Setelah selesai diunduh dan lolos pemeriksaan, LightCraft akan menginstalnya, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Berjalan di prosesor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Disimpan di folder “denoise” pustaka; saat batas tercapai, file terlama yang tidak dipakai foto mana pun dihapus lebih dulu.", + "Denoising {name}: {done} of {total} tiles{}": "Mengurangi noise {name}: {done} dari {total} petak{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} / {} dari {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Membuat gambar hasil pengurangan noise… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Dilatih dengan: {provenance}", + " · {queued} waiting": " · {queued} menunggu", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Menampilkan ketentuan model, lalu mengunduh {} dari {host}, menginstalnya, dan mulai menggunakannya. Tidak ada data lain yang dikirim.", + "In line for AI Denoise ({ahead} ahead)…": "Dalam antrean pengurangan noise AI ({ahead} di depan)…", + "Running on {}.": "Berjalan di {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Masukkan ke “{parent}”", + "Delete the keyword “{name}”?": "Hapus kata kunci “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Kata kunci ini akan dilepas dari {count} foto{:.0}, beserta kata kunci di bawahnya. Urungkan untuk mengembalikannya.", + "Create Keyword Tag Inside “{name}”…": "Buat tag kata kunci di dalam “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” sudah berisi “{name}”. Gabungkan keduanya?", + "There is a “{name}” at the top level already. Merge the two?": "Sudah ada “{name}” di tingkat teratas. Gabungkan keduanya?", + "Added “{keyword}” to {n} photo{}": "“{keyword}” ditambahkan ke {n} foto{:.0}", + "On {have} of {of} selected photos": "Pada {have} dari {of} foto yang dipilih", + "Remove “{name}”": "Hapus “{name}”", + "There is a keyword set “{name}” already": "Set kata kunci “{name}” sudah ada", + "Added {n} keyword{}": "{n} kata kunci ditambahkan{:.0}", + "; {n} gained synonyms": "; {n} mendapat sinonim baru", + "Exported {n} keyword{}": "{n} kata kunci diekspor{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One tidak akan mengimpor daftar yang kata kuncinya mengandung ; , < atau >: {names}", + " — left out, as a keyword list can't hold them: {names}": " — tidak disertakan karena tidak dapat dimasukkan ke daftar kata kunci: {names}", + "{done} of {total}": "{done} dari {total}", + "{done} of {total} MB": "{done} dari {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} lainnya", + "Create the keyword “{name}”": "Buat kata kunci “{name}”", + "new, inside {parent}": "baru, di dalam {parent}", + "Keywords of {n} selected photos": "Kata kunci {n} foto yang dipilih", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” dihapus dari {n} foto. Edit → Urungkan akan mengembalikannya.", + "1 task is still running: {names}. Quitting stops it.": "1 tugas masih berjalan: {names}. Jika keluar sekarang, tugas ini akan dihentikan.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tugas masih berjalan: {names}. Jika keluar sekarang, semua tugas ini akan dihentikan.", + "{what} merge cancelled": "Penggabungan {what} dibatalkan", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} foto Lokal{:.0} dilewati: album hanya dapat berisi foto pustaka (tambahkan ke Foto saya terlebih dahulu)", + "New Folder in “{name}”": "Folder baru di “{name}”", + "Moved {} photo(s)": "{} foto dipindahkan", + "not added to Apple Photos: {e}": "tidak ditambahkan ke Apple Photos: {e}", + "{n} added to Apple Photos album “{album}”": "{n} ditambahkan ke album Apple Photos “{album}”", + "{n} added to Apple Photos": "{n} ditambahkan ke Apple Photos", + "{n} photos opened for editing; each is stacked with its original": "{n} foto dibuka untuk diedit; masing-masing ditumpuk dengan file aslinya", + " in {g} group{}": " dalam {g} grup{:.0}", + " · kept, not applied: {}": " · dipertahankan, tidak diterapkan: {}", + " — kept but not applied: {}": " — disimpan, tetapi tidak diterapkan: {}", + "Apply to “{mask}” on Selected Photos": "Terapkan ke “{mask}” pada foto yang dipilih", + "Delete the effect preset “{name}”?": "Hapus preset efek “{name}”?", + "Delete these {n} effect presets?": "Hapus {n} preset efek ini?", + "Delete {n} effect preset{}? This can't be undone.": "Hapus {n} preset efek{:.0}? Tindakan ini tidak dapat diurungkan.", + "Effect: {name}": "Efek: {name}", + "Exported {} effect preset{}": "{} preset efek diekspor{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efek masker{:.0} diimpor (Masker → Efek)", + "Update “{name}” with Current Settings": "Perbarui “{name}” dengan pengaturan saat ini", + "{n} preset{}": "{n} preset{:.0}", + "{name} opened in the other editor": "{name} dibuka di editor lain", + "{n} originals opened in the other editor": "{n} file asli dibuka di editor lain", + "· {in_source} identical copies": "· {in_source} salinan identik", + "{count} more warnings.": "{count} peringatan lainnya.", + "{photos} catalog records processed; {collections} collections.": "{photos} entri katalog diproses; {collections} koleksi.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} file asli hilang. Data katalognya masih tersedia; gunakan “Cari foto hilang” untuk menautkannya ulang.", + "Applied {name}": "{name} diterapkan", + "Delete “{name}”?": "Hapus “{name}”?", + "Updated {name}": "{name} diperbarui", + "Exported to {path}": "Diekspor ke {path}", + "Saved template {name}": "Templat {name} disimpan", + "Imported {name}": "{name} diimpor", + "Skipped {problem}": "Dilewati: {problem}", + "…and {more} more": "…dan {more} lainnya", + "Template preview: {name}": "Pratinjau templat: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} foto dalam tampilan · {slides} slide · {time}", + "{n} pages": "{n} halaman", + "{n} JPEG files": "{n} file JPEG", + "{s} s of AV1 video": "Video AV1 {s} detik", + "Slideshow exported: {what}": "Tayangan slide diekspor: {what}", + " · {n} skipped (could not be rendered)": " · {n} dilewati (tidak dapat dirender)", + "{n} raws can now be developed from their raw data": "{n} file raw kini dapat diolah dari data raw-nya", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft berhenti tiba-tiba: {e}\n\nDetailnya ada di file log: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft berhenti tiba-tiba: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Mengganti nama {n} wajah{:.0} di seluruh pustaka. File tidak diubah.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” sudah terdaftar sebagai orang. Jika digabungkan, keduanya menjadi satu orang; Urungkan akan memisahkannya lagi.", + "{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}" +} diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json new file mode 100644 index 000000000..aefe04417 --- /dev/null +++ b/crates/ui-egui/locales/id.json @@ -0,0 +1,2329 @@ +{ + " (library default)": " (bawaan pustaka)", + " (inverted)": " (dibalik)", + " · best of its burst": " · terbaik dari bidikan beruntunnya", + " · in a burst": " · bagian dari bidikan beruntun", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): DNG dicocokkan dengan data raw, ditulis ke file sementara, disinkronkan, lalu dibaca ulang sebelum diberi", + "% of all commits": "% dari semua commit", + "& Containing": "& kata kunci induk", + "(not in the library)": "(tidak ada di pustaka)", + "+ Group": "+ Grup", + "+ Rule": "+ Aturan", + ", picked": ", pilihan", + ", rejected": ", ditolak", + "0: nothing is rejected.": "0: tidak ada yang ditolak.", + "1 day": "1 hari", + "1 raw can now be developed from its raw data": "1 file raw kini dapat diolah dari data raw-nya", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · edit saat ini · pas tanpa pemangkasan", + "2026-04 or 2026-04-12": "2026-04 atau 2026-04-12", + "= exactly": "= sama persis", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Bingkai 16:9 dengan bilah hitam di atas dan bawah, judul di sudut, transisi memudar lewat layar hitam, dan kartu penutup.", + "A blank template keeps the original names.": "Templat kosong mempertahankan nama asli.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Latar abu-abu gelap berisi eksposur, ISO, dan panjang fokus, lalu kamera dan lensa, di bawah setiap foto.", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Model wajah hanya menggunakan GPU jika pengujian menunjukkan hasilnya sama tetapi lebih cepat.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Filter menyembunyikan foto di tampilan ini: ubah atau hapus filternya (Tampilan → Hapus filter)", + "A folder is already being synchronized": "Sudah ada folder yang sedang disinkronkan", + "A folder is being synchronized": "Folder sedang disinkronkan", + "A keyword set needs a name": "Set kata kunci harus diberi nama", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Dinding abu-abu muda dengan paspartu putih dan bayangan lembut; keterangan dan rating di bawah foto.", + "A one-time download of about": "Unduhan satu kali, sekitar", + "A pen's pressure sets the size and flow of the brush": "Tekanan pena mengatur ukuran dan aliran kuas", + "AI Denoise": "Pengurangan noise AI", + "AI Denoise Model": "Model pengurangan noise AI", + "AI Denoise is enabled on the requesting photo": "Pengurangan noise AI diaktifkan pada foto yang bersangkutan", + "AI Denoise is off: press Use on a model.": "Pengurangan noise AI nonaktif: tekan Gunakan pada salah satu model.", + "AI Denoise needs a model. Install one in Settings.": "Pengurangan noise AI memerlukan model. Instal model di Pengaturan.", + "AI Mask Model Status": "Status model masker AI", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1; setiap slide ditampilkan sesuai durasinya, lengkap dengan transisi pudar. Tanpa suara: musik belum ikut diekspor.", + "About": "Tentang", + "About LightCraft": "Tentang LightCraft", + "Accept & Download": "Setujui & unduh", + "Active": "Aktif", + "Activity": "Aktivitas", + "Actual size": "Ukuran sebenarnya", + "Add": "Tambahkan", + "Add Catchlight": "Tambahkan catchlight", + "Add Face Model…": "Tambahkan model wajah…", + "Add Folder to Local": "Tambahkan folder ke Lokal", + "Add Mask": "Tambahkan masker", + "Add Object Click": "Tambahkan titik objek", + "Add Point Color Sample": "Tambahkan sampel warna titik", + "Add Print Cell": "Tambahkan sel cetak", + "Add Red Eye Correction": "Tambahkan koreksi mata merah", + "Add Remove Spot": "Tambahkan bintik untuk dihapus", + "Add Selected Photos": "Tambahkan foto yang dipilih", + "Add a Face Recognition Model": "Tambahkan model pengenalan wajah", + "Add a model file…": "Tambahkan file model…", + "Add a rule (⌥-click: a group)": "Tambahkan aturan (⌥-klik: grup)", + "Add in place": "Tambahkan di lokasi asli", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Tambahkan di lokasi asli: pustaka merujuk file di lokasinya saat ini; tidak ada yang disalin atau dipindahkan.", + "Add keyword": "Tambahkan kata kunci", + "Add number": "Tambahkan nomor", + "Add text overlay": "Tambahkan overlay teks", + "Add to": "Tambahkan ke", + "Add to Album": "Tambahkan ke album", + "Add to All Selected Photos": "Tambahkan ke semua foto yang dipilih", + "Add to Apple Photos": "Tambahkan ke Apple Photos", + "Add to Favorites": "Tambahkan ke Favorit", + "Add to Local": "Tambahkan ke Lokal", + "Add to My Photos": "Tambahkan ke Foto saya", + "Add to My Photos First": "Tambahkan ke Foto saya terlebih dahulu", + "Add to Selected Photos": "Tambahkan ke foto yang dipilih", + "Add to Target Album": "Tambahkan ke album tujuan", + "Add to favorites": "Tambahkan ke Favorit", + "Add to this mask": "Tambahkan ke masker ini", + "Add/Subtract/Intersect Mask": "Tambahkan/Kurangi/Iriskan masker", + "Added to photos you import that don't already have it.": "Ditambahkan ke foto yang diimpor jika belum ada.", + "Adding": "Menambahkan", + "Adding Lightroom photos…": "Menambahkan foto Lightroom…", + "Adding photos…": "Menambahkan foto…", + "Adding to Apple Photos": "Menambahkan ke Apple Photos", + "Adding to Apple Photos…": "Menambahkan ke Apple Photos…", + "Advanced search…": "Pencarian lanjutan…", + "After": "Sesudah", + "After 30 days": "Setelah 30 hari", + "After 7 days": "Setelah 7 hari", + "After 90 days": "Setelah 90 hari", + "After a year": "Setelah satu tahun", + "After installation, AI Denoise will turn on for the requesting photo.": "Setelah terinstal, pengurangan noise AI akan aktif untuk foto tersebut.", + "Album": "Album", + "Albums": "Album", + "Albums hold library photos: Add to My Photos first": "Album hanya berisi foto pustaka: tambahkan ke Foto saya terlebih dahulu", + "All": "Semua", + "All Metadata": "Semua metadata", + "All Metadata…": "Semua metadata…", + "All Photos": "Semua foto", + "All rights reserved": "Hak cipta dilindungi undang-undang", + "All the photos imported from this disk are included.": "Semua foto yang diimpor dari disk ini disertakan.", + "All types": "Semua jenis", + "Already installed. Installing again is harmless.": "Sudah terinstal. Menginstal ulang tetap aman.", + "Already installed. Remove it before installing a replacement.": "Sudah terinstal. Hapus terlebih dahulu sebelum menginstal penggantinya.", + "Alt Text": "Teks alternatif", + "Always": "Selalu", + "Amount": "Kekuatan", + "Analyzing the photo for AI masks…": "Menganalisis foto untuk masker AI…", + "Anchored to": "Berpatokan pada", + "Angle": "Sudut", + "Animal Eye Tracking": "Pelacakan mata hewan", + "Another library was opened since the photos were dropped: nothing was moved": "Pustaka lain telah dibuka setelah foto diletakkan: tidak ada yang dipindahkan", + "Any Searchable Text": "Semua teks yang dapat dicari", + "Any camera": "Kamera apa pun", + "Any keyword": "Kata kunci apa pun", + "Any lens": "Lensa apa saja", + "Aperture": "Bukaan", + "Appearance Mode": "Mode tampilan", + "Appearance Settings…": "Pengaturan tampilan…", + "Apple Photos Imports": "Impor ke Apple Photos", + "Application": "Aplikasi", + "Apply": "Terapkan", + "Apply Color Label Set": "Terapkan set label warna", + "Apply Filter Preset": "Terapkan preset filter", + "Apply Mask Effect": "Terapkan efek masker", + "Apply Metadata Preset": "Terapkan preset metadata", + "Apply Point Curve Preset": "Terapkan preset kurva titik", + "Apply Preset": "Terapkan preset", + "Apply Print Template": "Terapkan templat cetak", + "Apply Settings JSON": "Terapkan JSON pengaturan", + "Apply Slideshow Template": "Terapkan templat tayangan slide", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Terapkan tone mapping lokal yang tersimpan di file raw (Apple ProRAW), seperti yang ditampilkan kamera", + "Approved": "Disetujui", + "Aqua": "Aqua", + "Aqua Hue": "Hue aqua", + "Aqua Luminance": "Luminans aqua", + "Aqua Saturation": "Saturasi aqua", + "Arrange Panel": "Atur panel", + "Arrange Print Cell": "Atur susunan sel cetak", + "ArtCraft Website": "Situs web ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — aplikasi kreatif lainnya", + "As Shot": "Sesuai pemotretan", + "As shot": "Sesuai pemotretan", + "Ascending": "Naik", + "Aspect": "Aspek", + "Aspect Ratio": "Rasio aspek", + "Aspect preview": "Pratinjau aspek", + "Assisted Culling": "Seleksi foto berbantuan", + "Assisted Culling…": "Seleksi foto berbantuan…", + "Auto": "Otomatis", + "Auto Advance": "Lanjut otomatis", + "Auto Advance Off": "Lanjut otomatis nonaktif", + "Auto Advance On": "Lanjut otomatis aktif", + "Auto Advance after rating or flagging": "Lanjut otomatis setelah memberi rating atau tanda", + "Auto Advance: move to the next photo after rating or flagging": "Lanjut otomatis: pindah ke foto berikutnya setelah memberi rating atau tanda", + "Auto Align": "Sejajarkan otomatis", + "Auto B&W Mix": "Campuran hitam putih otomatis", + "Auto Crop": "Pangkas otomatis", + "Auto Import": "Impor otomatis", + "Auto Import Now": "Impor otomatis sekarang", + "Auto Import Settings": "Pengaturan impor otomatis", + "Auto Layout": "Tata letak otomatis", + "Auto Mask": "Masker otomatis", + "Auto Select": "Pilih otomatis", + "Auto Settings": "Pengaturan otomatis", + "Auto Straighten": "Luruskan otomatis", + "Auto Sync": "Sinkronisasi otomatis", + "Auto settings applied": "Pengaturan otomatis diterapkan", + "Auto-Stack by Capture Time": "Tumpuk otomatis menurut waktu pengambilan", + "Auto-Stack by Capture Time…": "Tumpuk otomatis menurut waktu pengambilan…", + "Auto-Tag Photos from Tracklog": "Tandai lokasi foto otomatis dari log jejak GPS", + "Auto-Tag from Tracklog": "Tandai otomatis dari log jejak GPS", + "Auto-Tag from Tracklog…": "Tandai otomatis dari log jejak GPS…", + "Automatic": "Otomatis", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Otomatis merender tampilan Detail sesuai ukuran tampilnya, hingga resolusi asli foto. Pilih ukuran untuk membatasinya: makin kecil, makin cepat.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Mode otomatis memakai kartu grafis jika lebih cepat daripada prosesor. Kecepatan keduanya diukur di komputer ini saat foto pertama kali diproses.", + "Automatically Write Changes into XMP": "Tulis perubahan ke XMP secara otomatis", + "Automatically show full screen": "Tampilkan layar penuh secara otomatis", + "Automatically write changes into XMP sidecars": "Tulis perubahan secara otomatis ke file sidecar XMP", + "Autumn Gold": "Emas Musim Gugur", + "B&W": "Hitam putih", + "B&W Mix": "Campuran hitam putih", + "B&W Mixer": "Pencampur hitam putih", + "Back": "Kembali", + "Back Up Library…": "Cadangkan pustaka…", + "Back to Edit": "Kembali ke Edit", + "Back to Edit (E)": "Kembali ke Edit (E)", + "Back to Grid": "Kembali ke grid", + "Back to this person": "Kembali ke orang ini", + "Backdrop": "Latar", + "Background": "Latar belakang", + "Background Tasks": "Tugas latar belakang", + "Background colour": "Warna latar belakang", + "Background image": "Gambar latar", + "Balance": "Keseimbangan", + "Basic": "Dasar", + "Before": "Sebelum", + "Before the last template": "Sebelum templat terakhir diterapkan", + "Before/After (Y)": "Sebelum/Sesudah (Y)", + "Before/After Settings": "Pengaturan Sebelum/Sesudah", + "Before/After Split": "Sebelum/Sesudah: terbagi", + "Before/After Split Top/Bottom": "Sebelum/Sesudah: terbagi atas/bawah", + "Before/After Top/Bottom": "Sebelum/Sesudah: atas/bawah", + "Begin Interaction": "Mulai interaksi", + "Best of Similar Shots": "Terbaik dari bidikan serupa", + "Binary assets added": "Aset biner ditambahkan", + "Binary assets removed": "Aset biner dihapus", + "Bit depth": "Kedalaman bit", + "Black & White": "Hitam putih", + "Blacks": "Hitam", + "Bleach Bypass": "Bleach Bypass", + "Bleached": "Luntur", + "Blending": "Pembauran", + "Blue": "Biru", + "Blue Hour Boost": "Blue Hour Intens", + "Blue Hue": "Hue biru", + "Blue Luminance": "Luminans biru", + "Blue Primary": "Primer biru", + "Blue Saturation": "Saturasi biru", + "Blue channel": "Kanal biru", + "Blue label": "Label biru", + "Blue/Yellow Fringe": "Fringe biru/kuning", + "Blurry photos below the score are flagged as rejects.": "Foto buram dengan skor di bawah batas ini ditandai sebagai ditolak.", + "Bottom": "Bawah", + "Boundary Warp": "Pelengkungan tepi", + "Bright & Airy": "Bright & Airy", + "Bring All to Front": "Bawa semua ke depan", + "Bring the library up to date with this folder and the folders inside it:": "Perbarui pustaka agar sesuai dengan folder ini beserta subfoldernya:", + "Browse Folder": "Telusuri folder", + "Browse Folder…": "Telusuri folder…", + "Browse Profiles": "Telusuri profil", + "Browse…": "Telusuri…", + "Brush": "Kuas", + "Brush Settings": "Pengaturan kuas", + "Brush Stroke": "Sapuan kuas", + "Build Previews": "Buat pratinjau", + "Build Smart Previews": "Buat pratinjau pintar", + "Building previews": "Membuat pratinjau", + "Building smart previews": "Membuat pratinjau pintar", + "Built-in": "Bawaan", + "By Date": "Menurut tanggal", + "By day (YYYY/YYYY-MM-DD)": "Per hari (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "Per bulan (YYYY/YYYY-MM)", + "CPU": "CPU", + "CPU threads": "Thread CPU", + "Cache": "Cache", + "Calibration": "Kalibrasi", + "Camera": "Kamera", + "Camera Info": "Info kamera", + "Camera local tone mapping": "Pemetaan tone lokal kamera", + "Camera make and model": "Merek dan model kamera", + "Cameras and Cards": "Kamera dan kartu memori", + "Can't be moved here": "Tidak dapat dipindahkan ke sini", + "Cancel": "Batal", + "Cancel AI Mask Model Download": "Batalkan unduhan model masker AI", + "Cancel Background Task": "Batalkan tugas latar belakang", + "Cancel Denoise": "Batalkan pengurangan noise", + "Cancel Denoise Model Download": "Batalkan unduhan model pengurangan noise", + "Cancel Download": "Batalkan unduhan", + "Cancel Face Model Download": "Batalkan unduhan model wajah", + "Cancel Interaction": "Batalkan interaksi", + "Cancel Pending Photo Action": "Batalkan tindakan foto yang tertunda", + "Cancel Preview Build": "Batalkan pembuatan pratinjau", + "Cancel pending action": "Batalkan tindakan yang tertunda", + "Candidate": "Kandidat", + "Caption": "Keterangan", + "Capture Date": "Tanggal pengambilan", + "Capture date and time in your own format (directives below)": "Tanggal dan waktu pengambilan dalam format kustom (lihat kode format di bawah)", + "Capture date as YYYYMMDD": "Tanggal pengambilan sebagai YYYYMMDD", + "Captured": "Diambil", + "Captured from": "Diambil sejak", + "Captured until": "Diambil hingga", + "Cast shadow": "Bayangan jatuh", + "Catalog Statistics": "Statistik katalog", + "Catchlight": "Catchlight", + "Center": "Tengah", + "Change": "Ubah", + "Change Component Mode": "Ubah mode komponen", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Karakter yang tidak boleh ada di nama file (/ \\ : * ? \" < > |) diganti dengan -; nama yang sudah ada diberi akhiran -1, -2….", + "Charcoal": "Abu-abu arang", + "Check All": "Centang semua", + "Check Output Path": "Periksa jalur output", + "Check the files of this type": "Centang semua file jenis ini", + "Checking and installing the model…": "Memeriksa dan menginstal model…", + "Checking names…": "Memeriksa nama…", + "Checking raw files": "Memeriksa file raw", + "Checking the GPU for faces": "Memeriksa GPU untuk deteksi wajah", + "Checking the GPU…": "Memeriksa GPU…", + "Checking…": "Memeriksa…", + "Choose Another Library…": "Pilih pustaka lain…", + "Choose Denoise Model": "Pilih model pengurangan noise", + "Choose Edit Settings to Copy": "Pilih pengaturan edit yang akan disalin", + "Choose Edit Settings to Copy…": "Pilih pengaturan edit yang akan disalin…", + "Choose Face Recognition Model": "Pilih model pengenalan wajah", + "Choose Folder…": "Pilih folder…", + "Choose Layout Image…": "Pilih gambar tata letak…", + "Choose Monitor Profile": "Pilih profil monitor", + "Choose Profile…": "Pilih profil…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Setelah konfirmasi, pilih lokasi penyimpanan PDF. Cetak PDF dari aplikasi penampil PDF Anda.", + "Choose a model below (Use) to start.": "Pilih salah satu model di bawah (Gunakan) untuk memulai.", + "Choose a photo in the grid or filmstrip": "Pilih foto di grid atau strip film", + "Choose an album…": "Pilih album…", + "Choose…": "Pilih…", + "Cinema Wide": "Sinema lebar", + "Cinematic": "Sinematik", + "City": "Kota", + "Clarity": "Kejernihan", + "Clean Studio": "Studio Bersih", + "Clear": "Hapus", + "Clear Cache": "Kosongkan cache", + "Clear Denoise Cache": "Kosongkan cache pengurangan noise", + "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", + "Clear Reference": "Hapus referensi", + "Clear all": "Hapus semua", + "Clear cache": "Kosongkan cache", + "Clear form": "Kosongkan formulir", + "Clear rating": "Hapus rating", + "Clear rating and advance": "Hapus rating lalu lanjut", + "Cleared History": "Riwayat dihapus", + "Click Zoom Ratio": "Rasio zoom saat diklik", + "Click a shortcut, then press the new keys (Esc cancels).": "Klik pintasan, lalu tekan tombol barunya (Esc untuk membatalkan).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klik tag untuk menyisipkannya. Contoh ini memakai IMG_0042.CR3 yang diambil 14 Jan 2026, 05:58:48.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Klik templat untuk menerapkannya; arahkan penunjuk ke templat untuk melihat pratinjaunya pada foto yang dipilih. Klik kanan templat milik Anda untuk memperbarui, mengganti nama, mengekspor, atau menghapusnya.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klik foto di grid untuk memasang atau melepas kata kunci; tekan Esc untuk berhenti", + "Click the object to select it · ⌥-click leaves a part out": "Klik objek untuk memilihnya · ⌥-klik untuk mengecualikan sebagian", + "Click the photo to pick a colour · ⇧-click adds more": "Klik foto untuk memilih warna · ⇧-klik untuk menambahkan warna lain", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Klik foto untuk memilih warna; ⇧-klik untuk menambahkan warna lain (maksimal 5)", + "Click to clear these filters": "Klik untuk menghapus filter ini", + "Click to name this face": "Klik untuk memberi nama wajah ini", + "Click to restore · double-click to rename": "Klik untuk memulihkan · klik dua kali untuk mengganti nama", + "Click to type a value": "Klik untuk mengetik nilai", + "Click to type a value, drag to change it": "Klik untuk mengetik nilai, seret untuk mengubahnya", + "Clone": "Klon", + "Close": "Tutup", + "Close Panel": "Tutup panel", + "Close Window": "Tutup jendela", + "Close all": "Tutup semua", + "Close file": "Tutup file", + "Close panel": "Tutup panel", + "Cloudy": "Berawan", + "Collapse": "Ciutkan", + "Collapse All Stacks": "Ciutkan semua tumpukan", + "Collapse Stack": "Ciutkan tumpukan", + "Color": "Warna", + "Color Grading": "Color grading", + "Color Label": "Label warna", + "Color Label Names": "Nama label warna", + "Color Label Sets": "Set label warna", + "Color Mixer": "Pencampur warna", + "Color Noise Reduction": "Pengurangan noise warna", + "Color Overlay": "Overlay warna", + "Color Overlay on B&W": "Overlay warna pada hitam putih", + "Color Priority": "Prioritas warna", + "Color Range": "Rentang warna", + "Color label cleared": "Label warna dihapus", + "Color space": "Ruang warna", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Ruang warna: Rec. 2020 HLG (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Ruang warna: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Ruang warna: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Ruang warna: Rec. 2020 PQ, 16-bit (HDR PNG)", + "Color space: sRGB (AVIF)": "Ruang warna: sRGB (AVIF)", + "Colors": "Warna", + "Colour": "Warna", + "Colour only": "Warna saja", + "Colour wash": "Sapuan warna", + "Combine files…": "Gabungkan file…", + "Command Journal": "Jurnal perintah", + "Comments panel": "Panel komentar", + "Commits": "Commit", + "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", + "Concrete Cool": "Beton Sejuk", + "Confirm before deleting": "Minta konfirmasi sebelum menghapus", + "Confirm before moving photos to Recently Deleted": "Minta konfirmasi sebelum memindahkan foto ke Baru dihapus", + "Confirm before moving photos to another folder on disk": "Minta konfirmasi sebelum memindahkan foto ke folder lain di disk", + "Constrain Crop": "Batasi pangkasan", + "Contact Sheet PDF": "PDF lembar kontak", + "Contact Sheet PDF…": "PDF lembar kontak…", + "Container": "Kontainer", + "Content-Aware": "Sesuai konten", + "Continue": "Lanjutkan", + "Continue Without Saving": "Lanjutkan tanpa menyimpan", + "Contrast": "Kontras", + "Contributors": "Kontributor", + "Convert to DNG": "Konversi ke DNG", + "Cool Fade": "Pudar Sejuk", + "Cool Morning": "Pagi Sejuk", + "Copy": "Salin", + "Copy After's Settings to Before": "Salin pengaturan Sesudah ke Sebelum", + "Copy Before to After": "Salin Sebelum ke Sesudah", + "Copy Before's Settings to After": "Salin pengaturan Sebelum ke Sesudah", + "Copy Edit Settings": "Salin pengaturan edit", + "Copy History Step Settings to Before": "Salin pengaturan langkah riwayat ke Sebelum", + "Copy Metadata": "Salin metadata", + "Copy Name": "Nama salinan", + "Copy as DNG": "Salin sebagai DNG", + "Copy into the library (else use the files where they are)": "Salin ke pustaka (jika tidak, file dipakai dari lokasinya sekarang)", + "Copy pages": "Salin halaman", + "Copy the files (into the library's Originals/, or a folder you choose)": "Salin file (ke Originals/ di pustaka, atau ke folder pilihan Anda)", + "Copy to": "Salin ke", + "Copy to Clipboard": "Salin ke papan klip", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Salin: file disalin ke folder di bawah dan pustaka menggunakan salinannya; file asli tidak diubah.", + "Copyright": "Hak cipta", + "Copyright Info URL": "URL info hak cipta", + "Copyright Status": "Status hak cipta", + "Copyrighted": "Dilindungi hak cipta", + "Country": "Negara", + "Create": "Buat", + "Create Album": "Buat album", + "Create Album…": "Buat album…", + "Create Folder": "Buat folder", + "Create Folder…": "Buat folder…", + "Create Keyword": "Buat kata kunci", + "Create Keyword Tag": "Buat tag kata kunci", + "Create New Mask": "Buat masker baru", + "Create PDF from clipboard": "Buat PDF dari papan klip", + "Create PDF from file…": "Buat PDF dari file…", + "Create PDF from images…": "Buat PDF dari gambar…", + "Create Preset": "Buat preset", + "Create Preset…": "Buat preset…", + "Create Proof Copy": "Buat salinan soft proof", + "Create Smart Album": "Buat album pintar", + "Create Smart Album from Filter…": "Buat album pintar dari filter…", + "Create Smart Album…": "Buat album pintar…", + "Create Stack": "Buat tumpukan", + "Create Version": "Buat versi", + "Create Version from Step": "Buat versi dari langkah ini", + "Create Virtual Copy": "Buat salinan virtual", + "Creator": "Pembuat", + "Creator metadata": "Metadata pembuat", + "Crisp Landscape": "Lanskap Tajam", + "Crisp Vista": "Pemandangan Jernih", + "Crop": "Pangkas", + "Crop & Rotate": "Pangkas & putar", + "Crop & Rotate (C)": "Pangkas & putar (C)", + "Crop Aspect": "Aspek pangkas", + "Crop, Rotate, Geometry": "Pangkas, putar, geometri", + "Cropped": "Dipangkas", + "Cross Process": "Cross Process", + "Culling": "Seleksi foto", + "Curve": "Kurva", + "Custom": "Kustom", + "Custom AF Area": "Area AF kustom", + "Custom template…": "Templat kustom…", + "Custom…": "Kustom…", + "Cut": "Potong", + "Cut pages": "Potong halaman", + "Cycle Crop Overlay": "Ganti overlay pangkas", + "Cycle Crop Overlay Orientation": "Ganti orientasi overlay pangkas", + "Cycle Flag": "Ganti tanda berikutnya", + "Cycle Info Overlay": "Ganti overlay info", + "Cycle Mask Overlay Color": "Ganti warna overlay masker", + "Cycle Mask Overlay Mode": "Ganti mode overlay masker", + "Cycle Tool Overlay": "Ganti overlay alat berikutnya", + "Cylindrical": "Silindris", + "Dark & Moody": "Gelap & Muram", + "Dark Mode": "Mode gelap", + "Dark Theme": "Tema gelap", + "Darken": "Gelapkan", + "Darks": "Gelap", + "Date": "Tanggal", + "Date Groups": "Grup tanggal", + "Day": "Hari", + "Daylight": "Siang hari", + "Decrease Brush Feather": "Kurangi feather kuas", + "Decrease Brush Size": "Perkecil ukuran kuas", + "Decrease Flag Status": "Turunkan status tanda", + "Decrease Grid Thumbnail Size": "Perkecil thumbnail grid", + "Decrease Rating": "Turunkan rating", + "Deep Sky": "Langit Pekat", + "Defringe": "Hilangkan fringe", + "Deghost Amount": "Kekuatan anti-ghosting", + "Dehaze": "Hilangkan kabut", + "Delete": "Hapus", + "Delete (⌫)": "Hapus (⌫)", + "Delete Album": "Hapus album", + "Delete All Masks": "Hapus semua masker", + "Delete Color Label Set": "Hapus set label warna", + "Delete Component": "Hapus komponen", + "Delete Export Preset": "Hapus preset ekspor", + "Delete Filter Preset": "Hapus preset filter", + "Delete Imported Profile": "Hapus profil yang diimpor", + "Delete Keyword": "Hapus kata kunci", + "Delete Keyword Set": "Hapus set kata kunci", + "Delete Keyword…": "Hapus kata kunci…", + "Delete Mask": "Hapus masker", + "Delete Mask Effect": "Hapus efek masker", + "Delete Metadata Preset": "Hapus preset metadata", + "Delete Permanently": "Hapus permanen", + "Delete Photo": "Hapus foto", + "Delete Photos": "Hapus foto", + "Delete Point Color": "Hapus warna titik", + "Delete Point Color Sample": "Hapus sampel warna titik", + "Delete Point Curve Preset": "Hapus preset kurva titik", + "Delete Preset": "Hapus preset", + "Delete Print Cell": "Hapus sel cetak", + "Delete Print Template": "Hapus templat cetak", + "Delete Red Eye": "Hapus koreksi mata merah", + "Delete Red Eye Correction": "Hapus koreksi mata merah", + "Delete Slideshow Template": "Hapus templat tayangan slide", + "Delete Spot": "Hapus bintik", + "Delete Version": "Hapus versi", + "Delete all": "Hapus semua", + "Delete all spots": "Hapus semua bintik", + "Delete mask": "Hapus masker", + "Delete them": "Hapus", + "Denoise Model Downloads": "Unduhan model pengurangan noise", + "Denoise Models": "Model pengurangan noise", + "Denoise Photos": "Kurangi noise foto", + "Denoise Pump": "Antrean pengurangan noise", + "Denoise Settings": "Pengaturan pengurangan noise", + "Denoise Status": "Status pengurangan noise", + "Denoise all photos with an Amount": "Kurangi noise pada semua foto yang memiliki nilai Kekuatan", + "Density": "Kepadatan", + "Depth Range": "Rentang kedalaman", + "Descending": "Menurun", + "Describe": "Deskripsikan", + "Describe what to keep": "Jelaskan bagian yang ingin dipertahankan", + "Describe what to leave out": "Jelaskan apa yang ingin dikecualikan", + "Describe what to select": "Jelaskan objek yang ingin dipilih", + "Describe…": "Deskripsikan…", + "Description": "Deskripsi", + "Deselect": "Batalkan pilihan", + "Deselect All": "Batalkan semua pilihan", + "Desert Heat": "Panas Gurun", + "Desert Warmth": "Hangat Gurun", + "Desktop": "Desktop", + "Destination library": "Pustaka tujuan", + "Detail": "Detail", + "Detail (D)": "Detail (D)", + "Details": "Detail", + "Detect Faces": "Deteksi wajah", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Diperlukan untuk Deteksi wajah (menu Foto): unduhan kecil, cukup sekali.", + "Detection": "Deteksi", + "Develop": "Olah", + "Diagonal": "Diagonal", + "Discard Preview Cache": "Buang cache pratinjau", + "Discarding smart previews": "Membuang pratinjau pintar", + "Display": "Layar", + "Display P3": "Display P3", + "Display Profile": "Profil layar", + "Display name": "Nama tampilan", + "Display profile not used": "Profil layar tidak digunakan", + "Display theme": "Tema tampilan", + "Distortion": "Distorsi", + "Document properties…": "Properti dokumen…", + "Don't ask again": "Jangan tanya lagi", + "Don't enlarge": "Jangan perbesar", + "Done": "Selesai", + "Download": "Unduh", + "Download AI Mask Model": "Unduh model masker AI", + "Download Denoise Model": "Unduh model pengurangan noise", + "Download Face Model": "Unduh model wajah", + "Download Place Names (GeoNames)": "Unduh nama tempat (GeoNames)", + "Download the SAM 3 Model?": "Unduh model SAM 3?", + "Downloaded and checked. Installing…": "Sudah diunduh dan diverifikasi. Menginstal…", + "Downloading model": "Mengunduh model", + "Downloading place names": "Mengunduh nama tempat", + "Downloads": "Unduhan", + "Drag Crop Handle": "Seret pegangan pangkas", + "Drag along the horizon; double-click for Auto": "Seret di sepanjang cakrawala; klik dua kali untuk Otomatis", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Seret di atas mata pada foto; pupilnya akan terdeteksi otomatis.", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Seret untuk mengatur hue dan saturasi. Tahan Shift untuk mempertahankan hue, Cmd/Ctrl untuk mempertahankan saturasi, Alt/Option untuk penyesuaian halus. Klik dua kali untuk mengatur ulang.", + "Draw Guides": "Gambar garis panduan", + "Drop here for the top level": "Lepaskan di sini untuk memindahkannya ke tingkat teratas", + "Duplicate": "Duplikat", + "Duplicate Component": "Duplikat komponen", + "Duplicate Mask": "Duplikat masker", + "Duplicate Mask Effect": "Duplikat efek masker", + "Duplicate and Invert Mask": "Duplikat dan balikkan masker", + "Duration": "Durasi", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Setiap / memulai satu tingkat folder; tag diisi untuk setiap foto (tingkat yang metadatanya kosong menjadi \"unknown\").", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Setiap foto ditampilkan sebesar mungkin dengan latar hitam dan transisi crossfade singkat. Tanpa elemen apa pun di atasnya.", + "Edge": "Tepi", + "Edge to Edge": "Tepi ke tepi", + "Edit": "Edit", + "Edit (E)": "Edit (E)", + "Edit Capture Time": "Edit waktu pengambilan", + "Edit Capture Time…": "Edit waktu pengambilan…", + "Edit Color Label Names": "Edit nama label warna", + "Edit Color Label Names…": "Edit nama label warna…", + "Edit Copy for External Editor": "Edit salinan dengan editor eksternal", + "Edit Date": "Tanggal edit", + "Edit In": "Edit di", + "Edit Info": "Edit info", + "Edit Keyword": "Edit kata kunci", + "Edit Keyword Set": "Edit set kata kunci", + "Edit Keyword Tag": "Edit tag kata kunci", + "Edit Keyword Tag…": "Edit tag kata kunci…", + "Edit Label Names": "Edit nama label", + "Edit Label Names…": "Edit nama label…", + "Edit Mask": "Edit masker", + "Edit Mask Component": "Edit komponen masker", + "Edit Sections": "Bagian edit", + "Edit Set…": "Edit set…", + "Edit Smart Album": "Edit album pintar", + "Edit Smart Album…": "Edit album pintar…", + "Edit Spot": "Edit bintik", + "Edit in External Editor": "Edit di editor eksternal", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Edit dalam HDR: sorotan melampaui putih SDR, untuk ekspor dan layar HDR", + "Edit in Other Editor": "Edit di editor lain", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Edit di editor lain (⌥⌘E) membuka salinannya di aplikasi ini (nama aplikasi di macOS, jalur program di sistem lain).", + "Edit in PhotoCraft": "Edit di PhotoCraft", + "Edit settings copied": "Pengaturan edit disalin", + "Edited": "Diedit", + "Edited or not": "Diedit atau belum", + "Effect": "Efek", + "Effects": "Efek", + "Empty Recently Deleted": "Kosongkan Baru dihapus", + "Enable Lens Corrections": "Aktifkan koreksi lensa", + "Enable Profile Corrections": "Aktifkan koreksi profil", + "End": "Akhir", + "End Interaction": "Akhiri interaksi", + "End Slideshow": "Akhiri tayangan slide", + "Ending": "Penutup", + "Ending screen": "Layar penutup", + "English": "English", + "Enter Full Screen": "Masuk layar penuh", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Masukkan tanggal dengan format YYYY-MM-DD HH:MM:SS", + "Enter to confirm, Esc to cancel": "Enter untuk mengonfirmasi, Esc untuk membatalkan", + "Erase": "Penghapus", + "Evaluate Face Recognition": "Evaluasi pengenalan wajah", + "Every EXIF, GPS and XMP field in the file": "Semua kolom EXIF, GPS, dan XMP dalam file", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Setiap foto dipangkas agar memenuhi seluruh layar, dengan transisi silang yang lambat.", + "Existing previews": "Pratinjau yang sudah ada", + "Exit": "Keluar", + "Expand": "Luaskan", + "Expand All Stacks": "Luaskan semua tumpukan", + "Expand Stack": "Luaskan tumpukan", + "Expand/Collapse Stack": "Luaskan/Ciutkan tumpukan", + "Expanded Flexible Spot": "Spot fleksibel diperluas", + "Export": "Ekspor", + "Export Album with Preset": "Ekspor album dengan preset", + "Export Album…": "Ekspor album…", + "Export As…": "Ekspor sebagai…", + "Export Contact Sheet PDF": "Ekspor PDF lembar kontak", + "Export Containing Keywords": "Ekspor kata kunci induk", + "Export Effect Presets…": "Ekspor preset efek…", + "Export Group…": "Ekspor grup…", + "Export JPEG…": "Ekspor JPEG…", + "Export Keywords": "Ekspor kata kunci", + "Export Keywords…": "Ekspor kata kunci…", + "Export Mask Effects": "Ekspor efek masker", + "Export Now": "Ekspor sekarang", + "Export PDF…": "Ekspor PDF…", + "Export Point Curve Presets": "Ekspor preset kurva titik", + "Export Point Curve Presets…": "Ekspor preset kurva titik…", + "Export Presets": "Ekspor preset", + "Export Presets…": "Ekspor preset…", + "Export Slideshow": "Ekspor tayangan slide", + "Export Slideshow Template": "Ekspor templat tayangan slide", + "Export Slideshow: folder for the JPEG files": "Ekspor tayangan slide: folder untuk file JPEG", + "Export Slideshow…": "Ekspor tayangan slide…", + "Export Synonyms": "Ekspor sinonim", + "Export User Presets…": "Ekspor preset pengguna…", + "Export Video…": "Ekspor video…", + "Export stopped unexpectedly": "Ekspor berhenti tiba-tiba", + "Export to": "Ekspor ke", + "Export with Preset": "Ekspor dengan preset", + "Export with Previous": "Ekspor seperti sebelumnya", + "Exporting": "Mengekspor", + "Exporting contact sheet": "Mengekspor lembar kontak", + "Exporting slideshow": "Mengekspor tayangan slide", + "Export…": "Ekspor…", + "Exposure": "Eksposur", + "Exposure (shutter · aperture · ISO)": "Eksposur (kecepatan rana · bukaan · ISO)", + "Extended Description": "Deskripsi lengkap", + "External Editing": "Pengeditan eksternal", + "External Editor": "Editor eksternal", + "Face Boxes": "Kotak wajah", + "Face Model Downloads": "Unduhan model wajah", + "Face Models": "Model wajah", + "Face Models on the GPU": "Model wajah di GPU", + "Face Recognition On/Off": "Pengenalan wajah aktif/nonaktif", + "Face Tracking": "Pelacakan wajah", + "Face boxes": "Kotak wajah", + "Face detector": "Pendeteksi wajah", + "Face models (ONNX)": "Model wajah (ONNX)", + "Face names written to XMP by Lightroom and other apps show up here.": "Nama wajah yang ditulis ke XMP oleh Lightroom dan aplikasi lain akan muncul di sini.", + "Face recognition": "Pengenalan wajah", + "Face tracking": "Pelacakan wajah", + "Faces": "Wajah", + "Faces of a Person": "Wajah seseorang", + "Faded Matte": "Matte Pudar", + "Faded Slide": "Slide Pudar", + "Faded Urban": "Urban Pudar", + "Failed": "Gagal", + "Failed its last test": "Gagal pada pengujian terakhir", + "Favorite Mask Effect": "Jadikan efek masker favorit", + "Favorite Preset": "Jadikan preset favorit", + "Favorite Profile": "Jadikan profil favorit", + "Favorites": "Favorit", + "Feather": "Feather", + "File": "File", + "File & date": "File & tanggal", + "File Extension": "Ekstensi file", + "File Format": "Format file", + "File Name": "Nama file", + "File Path": "Jalur file", + "File Size": "Ukuran file", + "File Type": "Jenis file", + "File name": "Nama file", + "File types:": "Jenis file:", + "Filename": "Nama file", + "Filename captions": "Keterangan nama file", + "Files with the same content as another file in this import: only the first is imported.": "File yang isinya sama dengan file lain dalam impor ini: hanya file pertama yang diimpor.", + "Fill": "Isi", + "Fill Edges": "Isi tepi", + "Fill Location": "Isi lokasi", + "Fill Location from GPS": "Isi lokasi dari GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Isi Kota, Provinsi, dan Negara yang kosong dari GPS (nama tempat GeoNames, offline)", + "Film": "Film", + "Filmstrip": "Strip film", + "Filmstrip (/)": "Strip film (/)", + "Filter": "Filter", + "Filter Bar": "Bilah filter", + "Filter Keywords": "Filter kata kunci", + "Filter Presets": "Preset filter", + "Filter bar": "Bilah filter", + "Filter fields": "Filter kolom", + "Find Blemishes": "Cari noda", + "Find Dust Spots": "Cari bintik debu", + "Find Missing Photos": "Cari foto hilang", + "Find Missing Photos…": "Cari foto hilang…", + "Find Similar Photos": "Cari foto serupa", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Cari foto yang baru masuk ke atau hilang dari folder ini di disk, serta file sidecar XMP yang diubah oleh aplikasi lain", + "Find tools and commands…": "Cari alat dan perintah…", + "Finding faces": "Mencari wajah", + "Find…": "Cari…", + "First": "Pertama", + "First commit": "Commit pertama", + "Fit": "Pas", + "Fit (F)": "Pas (F)", + "Fit on Screen": "Pas di layar", + "Fit to width": "Pas dengan lebar", + "Fit visible": "Paskan ke area terlihat", + "Five stars": "Lima bintang", + "Fix the marked rules to save this album.": "Perbaiki aturan yang ditandai agar album ini dapat disimpan.", + "Fixed keys": "Tombol tetap", + "Flag": "Tanda", + "Flag as Pick": "Tandai sebagai pilihan", + "Flag as Reject": "Tandai sebagai ditolak", + "Flagged as Pick": "Ditandai sebagai pilihan", + "Flagged as Reject": "Ditandai sebagai ditolak", + "Flash": "Flash", + "Flexible Spot": "Spot fleksibel", + "Flip H": "Balik H", + "Flip Horizontal": "Balik horizontal", + "Flip Overlay (⇧O)": "Balik arah overlay (⇧O)", + "Flip V": "Balik V", + "Flip Vertical": "Balik vertikal", + "Float panel": "Jadikan panel mengambang", + "Flow": "Aliran", + "Fluorescent": "Fluoresen", + "Focal Length": "Panjang fokus", + "Focal length": "Panjang fokus", + "Focus": "Fokus", + "Focus (assisted culling)": "Fokus (seleksi foto berbantuan)", + "Focus point": "Titik fokus", + "Folder": "Folder", + "Folder name (renamed on disk; its photos follow)": "Nama folder (diganti di disk; foto di dalamnya ikut diperbarui)", + "Folders": "Folder", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Folder dan {date} memakai waktu pengambilan; foto tanpa waktu pengambilan memakai tanggal hari ini.", + "Food": "Makanan", + "Forget Unchanged Local Photos": "Lupakan foto lokal yang tidak diubah", + "Forget unchanged photos": "Lupakan foto yang tidak berubah", + "Form fields panel": "Panel kolom formulir", + "Format": "Format", + "Forward": "Maju", + "Found automatically": "Terdeteksi otomatis", + "Fr": "Jm", + "Frame rate": "Kecepatan frame", + "Frame time": "Durasi frame", + "Frame update": "Pembaruan frame", + "Free": "Bebas", + "Fresh & Bright": "Segar & Cerah", + "Friday": "Jumat", + "Full": "Penuh", + "Full Screen Preview": "Pratinjau layar penuh", + "Full screen mode": "Mode layar penuh", + "Full size": "Ukuran penuh", + "GPS Track Log": "Log jejak GPS", + "GPU Rendering": "Rendering GPU", + "GPU rendering": "Rendering GPU", + "Gallery Wall": "Dinding galeri", + "General": "Umum", + "Generated demo photo": "Foto demo buatan", + "Geometry": "Geometri", + "Get Develop Settings": "Ambil pengaturan olah", + "Global Hue": "Hue global", + "Global Luminance": "Luminans global", + "Global Saturation": "Saturasi global", + "Glossy": "Mengilap", + "Go back to listing them by name": "Kembali ke daftar menurut nama", + "Go to History Step": "Lompat ke langkah riwayat", + "Golden": "Rasio emas", + "Golden Glow": "Hangat Keemasan", + "Golden Hour": "Golden Hour", + "Grab bag": "Daftar nama", + "Grain": "Butiran", + "Graphic": "Grafis", + "Graphics card": "Kartu grafis", + "Green": "Hijau", + "Green Amount": "Kekuatan hijau", + "Green Hue": "Hue hijau", + "Green Hue High": "Hue hijau atas", + "Green Hue Low": "Hue hijau bawah", + "Green Luminance": "Luminans hijau", + "Green Primary": "Primer hijau", + "Green Saturation": "Saturasi hijau", + "Green channel": "Kanal hijau", + "Green label": "Label hijau", + "Grid": "Grid", + "Grid Info": "Info grid", + "Gritty Street": "Jalanan Kasar", + "Group": "Grup", + "Group by Date: Automatic": "Kelompokkan menurut tanggal: Otomatis", + "Group by Day": "Kelompokkan menurut hari", + "Group by Month": "Kelompokkan menurut bulan", + "Group by Year": "Kelompokkan menurut tahun", + "Group by date": "Kelompokkan menurut tanggal", + "Group into Stack": "Kelompokkan menjadi tumpukan", + "Group name": "Nama grup", + "Grouped by parent": "Dikelompokkan menurut induk", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Dikelompokkan: kata kunci di bawah induk yang sama ditampilkan dalam satu baris berlabel nama induknya; daftar panjang dan saran dipersingkat.", + "Guided": "Terpandu", + "Guided Upright": "Tegakkan terpandu", + "Guided Upright Guides": "Garis panduan Tegakkan terpandu", + "HDR": "HDR", + "HDR Headroom": "Ruang HDR", + "HDR Merge": "Gabung HDR", + "HDR Merge Preview": "Pratinjau gabung HDR", + "HDR Panorama Merge": "Gabung panorama HDR", + "HDR Panorama Merge Preview": "Pratinjau gabung panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR dengan pengaturan terakhir", + "HDR Panoramas": "Panorama HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR TIFF needs 32-bit float.": "TIFF HDR memerlukan float 32-bit.", + "HDR for Each Bracket Set": "HDR untuk setiap set bracket", + "HDR output": "Output HDR", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorama HDR", + "HDR with Last Settings": "HDR dengan pengaturan terakhir", + "HDR…": "Gabung HDR…", + "Has Edits": "Diedit", + "Has GPS": "Ada data GPS", + "Heal": "Perbaiki", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Perbaiki bintik gelap kecil dan samar yang ditinggalkan debu sensor di area halus", + "Height": "Tinggi", + "Height (px)": "Tinggi (px)", + "Help": "Bantuan", + "Hide LightCraft": "Sembunyikan LightCraft", + "Hide Mask": "Sembunyikan masker", + "Hide Others": "Sembunyikan yang lain", + "Hide This Section": "Sembunyikan bagian ini", + "Hides this shortcut only; the folder and its photos stay as they are": "Hanya menyembunyikan pintasan ini; folder dan fotonya tetap seperti semula", + "High": "Tinggi", + "High Contrast B&W": "Hitam Putih Kontras Tinggi", + "Highlight": "Sorotan", + "Highlights": "Sorotan", + "Highlights Hue": "Hue sorotan", + "Highlights Luminance": "Luminans sorotan", + "Highlights Saturation": "Saturasi sorotan", + "Highlights split": "Batas sorotan", + "Histogram": "Histogram", + "History": "Riwayat", + "History & Activity (Y)": "Riwayat & aktivitas (Y)", + "Hold View Until Refreshed": "Tahan tampilan hingga diperbarui", + "Home": "Folder pribadi", + "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Jarak tepi horizontal (% dari sisi pendek)", + "Horizontal text": "Teks horizontal", + "Hours": "Jam", + "How to install the model": "Cara menginstal model", + "Hue": "Hue", + "Hue Range": "Rentang hue", + "Human Eye Tracking": "Pelacakan mata manusia", + "I have read these terms and accept them for my own use": "Saya telah membaca ketentuan ini dan menyetujuinya untuk penggunaan pribadi", + "ICC Profiles": "Profil ICC", + "ISO Speed": "ISO", + "ISO speed": "ISO", + "Identity plate": "Pelat identitas", + "If file exists": "Jika file sudah ada", + "Image": "Gambar", + "Image on Black": "Gambar di latar hitam", + "Image on White": "Gambar dengan latar putih", + "Import": "Impor", + "Import Date": "Tanggal impor", + "Import Keywords": "Impor kata kunci", + "Import Keywords…": "Impor kata kunci…", + "Import Lightroom Catalog": "Impor katalog Lightroom", + "Import Lightroom Catalog…": "Impor katalog Lightroom…", + "Import Mask Effects": "Impor efek masker", + "Import Photos": "Impor foto", + "Import Photos…": "Impor foto…", + "Import Point Curve Presets": "Impor preset kurva titik", + "Import Point Curve Presets…": "Impor preset kurva titik…", + "Import Presets": "Impor preset", + "Import Presets…": "Impor preset…", + "Import Profiles": "Impor profil", + "Import Profiles & Presets…": "Impor profil & preset…", + "Import Slideshow Template": "Impor templat tayangan slide", + "Import Slideshow Templates": "Impor templat tayangan slide", + "Import as new": "Impor sebagai baru", + "Import from Device": "Impor dari perangkat", + "Import from Folder…": "Impor dari folder…", + "Import new photos": "Impor foto baru", + "Import photos to get started": "Impor foto untuk memulai", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Impor foto melalui File → Impor foto… (Cmd+Shift+I), atau seret ke sini", + "Imported": "Diimpor", + "Imported Photos": "Foto yang diimpor", + "Importing": "Mengimpor", + "Importing Lightroom Catalog": "Mengimpor katalog Lightroom", + "Importing Lightroom catalog": "Mengimpor katalog Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Impor memindai sumber untuk mencari foto, tetapi tidak menambahkan foldernya ke Lokal (gunakan Lokal → Telusuri folder… untuk bekerja di sebuah folder tanpa mengimpor).", + "Import…": "Impor…", + "In Color": "Berwarna", + "In Photo Grid and Square Grid: Flag as Pick and advance": "Di Grid foto dan Grid persegi: tandai sebagai pilihan lalu lanjut", + "In Photo Grid and Square Grid: expand or collapse the stack": "Di Grid foto dan Grid persegi: bentangkan atau ciutkan tumpukan", + "In Recently Deleted": "Di album Baru dihapus", + "In Use": "Sedang digunakan", + "In a Stack": "Dalam tumpukan", + "In library": "Di pustaka", + "In memory — nothing is saved": "Di memori — tidak ada yang disimpan", + "In use": "Sedang digunakan", + "In-memory session: these settings last until LightCraft quits.": "Sesi dalam memori: pengaturan ini berlaku hingga LightCraft ditutup.", + "Include on Export": "Sertakan saat ekspor", + "Include selected photos": "Sertakan foto yang dipilih", + "Include subfolders": "Sertakan subfolder", + "Increase Brush Feather": "Perbesar feather kuas", + "Increase Brush Size": "Perbesar ukuran kuas", + "Increase Flag Status": "Naikkan status tanda", + "Increase Grid Thumbnail Size": "Perbesar thumbnail grid", + "Increase Rating": "Naikkan rating", + "Index Faces": "Indeks wajah", + "Index Faces in the Background": "Buat indeks wajah di latar belakang", + "Info": "Info", + "Info (I)": "Info (I)", + "Info Overlay Off": "Overlay info nonaktif", + "Info Overlay: Exposure": "Overlay info: Eksposur", + "Info Overlay: File": "Overlay info: File", + "Info overlay": "Overlay info", + "Insert at the cursor": "Sisipkan di posisi kursor", + "Inspect Denoise Model": "Periksa model pengurangan noise", + "Inspect Face Model File": "Periksa file model wajah", + "Inspect Lightroom Catalog": "Periksa katalog Lightroom", + "Inspect Photo": "Periksa foto", + "Inspecting Lightroom Catalog": "Memeriksa katalog Lightroom", + "Install Denoise Model": "Instal model pengurangan noise", + "Install Face Model": "Instal model wajah", + "Install a denoise model to enable AI Denoise on this photo": "Instal model untuk mengaktifkan pengurangan noise AI pada foto ini", + "Install from file…": "Instal dari file…", + "Install the SAM 3 Model": "Instal model SAM 3", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Instal model ONNX Anda sendiri beserta file denoise-model.json di sebelahnya. Build ini tidak menyediakan unduhan model.", + "Instant ’70s": "Instant ’70s", + "Intent:": "Metode rendering:", + "Interface": "Antarmuka", + "Interface language": "Bahasa antarmuka", + "Intersect": "Iriskan dengan", + "Intersect with": "Iriskan dengan", + "Into one folder": "Ke satu folder", + "Intro": "Intro", + "Intro screen": "Layar pembuka", + "Invert": "Balikkan", + "Invert Component": "Balikkan komponen", + "Invert Mask": "Balikkan masker", + "It tests this album, so testing it back would loop": "Album itu sudah menjadikan album ini sebagai syarat; menjadikannya syarat di sini akan menimbulkan perulangan", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Hasil pengurangan noise foto ini belum dibuat, dan tidak dibuat secara otomatis (Pengaturan, Pengurangan noise AI).", + "JPEG (Large)": "JPEG (besar)", + "JPEG (Small)": "JPEG (kecil)", + "Join the ArtCraft Discord": "Gabung ke Discord ArtCraft", + "Join the ArtCraft Discord…": "Gabung ke Discord ArtCraft…", + "Join the ArtCraft community on Discord": "Bergabung dengan komunitas ArtCraft di Discord", + "Keep in Local": "Simpan di Lokal", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Simpan di drive yang ruangnya cukup: ukuran pratinjau pintar sekitar 1 MB per foto. Cache thumbnail tetap di pustaka.", + "Keyboard Shortcuts": "Pintasan keyboard", + "Keyword": "Kata kunci", + "Keyword Count": "Jumlah kata kunci", + "Keyword Info": "Info kata kunci", + "Keyword Lists": "Daftar kata kunci", + "Keyword Name": "Nama kata kunci", + "Keyword Painter": "Kuas kata kunci", + "Keyword Set": "Set kata kunci", + "Keyword Sets": "Set kata kunci", + "Keyword Suggestions": "Saran kata kunci", + "Keyword Tag Options": "Opsi tag kata kunci", + "Keyword to paint": "Kata kunci yang akan diterapkan", + "Keywords": "Kata kunci", + "Keywords & People": "Kata kunci & orang", + "Keywords (K)": "Kata kunci (K)", + "Keywords (⌥1–⌥9)": "Kata kunci (⌥1–⌥9)", + "Keywords of the selection": "Kata kunci foto yang dipilih", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Kata kunci yang Anda tambahkan muncul di sini; terapkan dengan ⌥1–⌥9.", + "LUT-based": "Berbasis LUT", + "Label": "Label", + "Label Set": "Set label", + "Landscape": "Lanskap", + "Landscape (wide)": "Lanskap (lebar)", + "Language": "Bahasa", + "Last": "Terakhir", + "Last GPU fallback": "Fallback GPU terakhir", + "Last commit": "Commit terakhir", + "Last loupe render": "Render terakhir tampilan tunggal", + "Last view": "Tampilan terakhir", + "Lateral Chromatic Aberration": "Aberasi kromatik lateral", + "Layer": "Lapisan", + "Layers": "Lapisan", + "Layout": "Tata letak", + "Layout Image Matte": "Matte gambar tata letak", + "Layout Image Opacity": "Opasitas gambar tata letak", + "Leave them": "Biarkan saja", + "Left": "Kiri", + "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.": "Kiri: rentang standar (SDR) hingga putih SDR. Kanan: rentang HDR, satu pita untuk setiap stop di atas putih SDR (garis oranye: ruang HDR). Jika Visualisasikan rentang HDR aktif, warna pita mengikuti visualisasi tersebut.", + "Lens": "Lensa", + "Lens Blur": "Buram lensa", + "Lens Corrections": "Koreksi lensa", + "Letter": "Letter", + "Level": "Horizontal", + "Library Folders": "Folder pustaka", + "Library Info": "Info pustaka", + "Library Originals": "File asli pustaka", + "Library Preferences": "Preferensi pustaka", + "Library State": "Status pustaka", + "Library name": "Nama pustaka", + "Library saved": "Pustaka disimpan", + "Licence:": "Lisensi:", + "Light": "Cahaya", + "Light Mode": "Mode terang", + "Light Theme": "Tema terang", + "LightCraft Default": "Bawaan LightCraft", + "LightCraft Help": "Bantuan LightCraft", + "LightCraft Preset": "Preset LightCraft", + "LightCraft Presets": "Preset LightCraft", + "LightCraft Website": "Situs web LightCraft", + "LightCraft could not start": "LightCraft gagal dijalankan", + "LightCraft couldn't find where to keep your library.": "LightCraft tidak dapat menemukan lokasi untuk menyimpan pustaka Anda.", + "LightCraft couldn't open the library at": "LightCraft tidak dapat membuka pustaka di", + "LightCraft does not know this model, so it assumed:": "LightCraft tidak mengenali model ini, jadi menggunakan asumsi berikut:", + "LightCraft on GitHub": "LightCraft di GitHub", + "LightCraft stopped": "LightCraft berhenti", + "LightCraft website": "Situs web LightCraft", + "LightCraft — Second Window": "LightCraft — Jendela kedua", + "Lightroom Catalog Import": "Impor katalog Lightroom", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Katalog Lightroom diimpor ke pustaka yang sedang terbuka; folder katalog terpisah tidak dibuat.", + "Lightroom import cancelled": "Impor Lightroom dibatalkan", + "Lightroom inspection cancelled": "Pemeriksaan Lightroom dibatalkan", + "Lights": "Terang", + "Lights Out": "Lampu padam", + "Limit": "Batas", + "Limit file size (KB, 0 = off)": "Batasi ukuran file (KB, 0 = nonaktif)", + "Line delta": "Selisih baris", + "Linear": "Linear", + "Linear Gradient": "Gradien linear", + "Lines +/−": "Baris +/−", + "Lines added": "Baris ditambahkan", + "Lines deleted": "Baris dihapus", + "Link all margins": "Tautkan semua margin", + "List Albums": "Tampilkan daftar album", + "List Develop Controls": "Tampilkan daftar kontrol olah", + "List History": "Tampilkan daftar riwayat", + "List Presets": "Tampilkan daftar preset", + "List Profiles": "Tampilkan daftar profil", + "List this folder in Local from now on": "Tampilkan folder ini di Lokal mulai sekarang", + "Loading preview…": "Memuat pratinjau…", + "Local": "Lokal", + "Local library — no cloud account needed": "Pustaka lokal — tidak perlu akun cloud", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Foto lokal belum ada di pustaka: tambahkan ke Foto saya terlebih dahulu, lalu pindahkan", + "Locate Missing Files…": "Temukan file hilang…", + "Locate Missing File…": "Temukan file hilang…", + "Locate Photo": "Temukan foto", + "Location": "Lokasi", + "Lock": "Kunci", + "Lock Crop Aspect": "Kunci aspek pangkas", + "Lock On AF": "Lock-on AF", + "Locked": "Terkunci", + "Long Edge": "Sisi panjang", + "Looking for changes": "Mencari perubahan", + "Looking for photos…": "Mencari foto…", + "Looking up places": "Mencari nama tempat", + "Lossless": "Lossless", + "Low": "Rendah", + "Luminance": "Luminans", + "Luminance Range": "Rentang luminans", + "Lush Greens": "Hijau Rimbun", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100, dan R8. File CR3 yang belum bisa dibaca dekoder tetap dibuka dari JPEG tersematnya, seperti sebelumnya.", + "Magenta": "Magenta", + "Magenta Hue": "Hue magenta", + "Magenta Luminance": "Luminans magenta", + "Magenta Saturation": "Saturasi magenta", + "Make Candidate the Select": "Jadikan kandidat sebagai pilihan", + "Make it now": "Buat sekarang", + "Make the denoised picture of the photos I am looking at": "Buat hasil pengurangan noise untuk foto yang sedang dilihat", + "Manage Effect Presets…": "Kelola preset efek…", + "Manual": "Manual", + "Margins": "Margin", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Margin dihitung berdasarkan proporsi sisi terpendek slide, sehingga tampilan slide sama di layar mana pun.", + "Marquee zoom": "Zoom area pilihan", + "Mask Adjustment": "Penyesuaian masker", + "Mask Effect Presets": "Preset efek masker", + "Masking": "Masker", + "Masking (M)": "Masker (M)", + "Masks": "Masker", + "Match": "Cocokkan", + "Match Total Exposures": "Samakan total eksposur", + "Matrix/TRC": "Matrix/TRC", + "Matte": "Matte", + "Matte Soft": "Matte Lembut", + "Med": "Sedang", + "Medium Contrast": "Kontras sedang", + "Megapixels": "Megapiksel", + "Memory Budget": "Alokasi memori", + "Memory Usage": "Penggunaan memori", + "Memory budget": "Alokasi memori", + "Memory for caches": "Memori untuk cache", + "Menu": "Menu", + "Merge": "Gabungkan", + "Merge Keywords": "Gabungkan kata kunci", + "Merge into…": "Gabungkan ke…", + "Merged": "Digabungkan", + "Merged PRs": "PR yang digabungkan", + "Merging": "Menggabungkan", + "Merging…": "Menggabungkan…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Yang berlaku adalah ketentuan Meta, bukan LightCraft. Dengan mengunduhnya, Anda menyetujui ketentuan tersebut.", + "Metadata": "Metadata", + "Metadata Preset": "Preset metadata", + "Metadata Presets": "Preset metadata", + "Metadata preset": "Preset metadata", + "Metadata read": "Metadata dibaca", + "Midnight": "Tengah malam", + "Midpoint": "Titik tengah", + "Midtones": "Midtone", + "Midtones Hue": "Hue midtone", + "Midtones Luminance": "Luminans midtone", + "Midtones Saturation": "Saturasi midtone", + "Midtones split": "Batas midtone", + "Minimize": "Minimalkan", + "Missing Photos": "Foto hilang", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Metadata yang tidak tersedia ({camera}, {title}…) dibiarkan kosong; jika nama yang dihasilkan kosong, nama asli tetap dipakai.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Foto yang hilang dipindahkan ke Baru dihapus dan dapat dipulihkan; tidak ada file di disk yang diubah.", + "Misty Morning": "Pagi Berkabut", + "Mo": "Sn", + "Mode": "Mode", + "Model": "Model", + "Models": "Model", + "Modified Date": "Tanggal diubah", + "Monday": "Senin", + "Monitor profile": "Profil monitor", + "Mono Red Filter": "Mono Filter Merah", + "Mono Rich": "Mono Pekat", + "Mono Soft": "Mono Lembut", + "Monochrome": "Monokrom", + "Month": "Bulan", + "Moody": "Moody", + "Moody Portrait": "Potret Moody", + "More": "Lainnya", + "More preset options": "Opsi preset lainnya", + "Move": "Pindahkan", + "Move Album": "Pindahkan album", + "Move Down": "Pindahkan ke bawah", + "Move Down in Stack": "Pindahkan ke bawah dalam tumpukan", + "Move Folder": "Pindahkan folder", + "Move Folder To…": "Pindahkan folder ke…", + "Move Keyword": "Pindahkan kata kunci", + "Move Mask": "Pindahkan masker", + "Move Mask Effect to Group": "Pindahkan efek masker ke grup", + "Move Photos": "Pindahkan foto", + "Move Preset to Group": "Pindahkan preset ke grup", + "Move Preset to New Group": "Pindahkan preset ke grup baru", + "Move Print Cell": "Pindahkan sel cetak", + "Move Print Cell to Page": "Pindahkan sel cetak ke halaman", + "Move Selected Photos Here": "Pindahkan foto yang dipilih ke sini", + "Move Sidebar Section": "Pindahkan bagian bilah samping", + "Move Up": "Pindahkan ke atas", + "Move Up in Stack": "Naikkan dalam tumpukan", + "Move in Stack": "Pindahkan dalam tumpukan", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Pindahkan file (ke folder Originals/ pustaka, atau folder pilihan Anda) dan hapus dari lokasi asalnya", + "Move them": "Pindahkan", + "Move to": "Pindahkan ke", + "Move to Folder": "Pindahkan ke folder", + "Move to Folder…": "Pindahkan ke folder…", + "Move to Group": "Pindahkan ke grup", + "Move to group": "Pindahkan ke grup", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Pindahkan: file dipindahkan ke folder di bawah; setiap file asli (beserta file sidecar XMP-nya) baru dihapus dari sumber setelah salinannya diverifikasi. Duplikat dan file yang gagal tetap di tempatnya.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Memindahkan semua foto yang diimpor dari disk ini ke Baru dihapus; tidak ada file yang diubah", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Memindahkan file beserta file sidecar XMP-nya ke folder ini; file yang sudah ada tidak akan ditimpa", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Memindahkan foto yang diimpor dari folder ini ke Baru dihapus; file di disk tidak diubah", + "Multi": "Multi", + "Muted": "Redup", + "Muted Film": "Film Kalem", + "My Photos": "Foto saya", + "My Photos Panel": "Panel Foto saya", + "Name": "Nama", + "Name (A–Z)": "Nama (A–Z)", + "Name Face": "Beri nama wajah", + "Name Faces": "Beri nama wajah", + "Name of the folder the original is in": "Nama folder tempat file asli berada", + "Named": "Bernama", + "Named People": "Orang bernama", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nama label muncul di menu label, bilah filter, dan panel Info, serta ditulis ke XMP. Kosong = nama warnanya.", + "Navigator": "Navigator", + "Needs Work": "Perlu diperbaiki", + "Neon Dusk": "Senja Neon", + "Neon Night": "Malam Neon", + "Nested group": "Grup bertingkat", + "Neutral": "Netral", + "Never": "Tidak pernah", + "New": "Baru", + "New Album": "Album baru", + "New Album…": "Album baru…", + "New Folder on Disk": "Folder baru di disk", + "New Folder on Disk…": "Folder baru di disk…", + "New Folder…": "Folder baru…", + "New Group…": "Grup baru…", + "New Library": "Pustaka baru", + "New Library: Choose Where to Put It": "Pustaka baru: pilih lokasi penyimpanannya", + "New Library…": "Pustaka baru…", + "New Print Page": "Halaman cetak baru", + "New Smart Album": "Album pintar baru", + "New Smart Album from Filter…": "Album pintar baru dari filter…", + "New Smart Album…": "Album pintar baru…", + "New album": "Album baru", + "New album…": "Album baru…", + "New blank PDF": "PDF kosong baru", + "New name": "Nama baru", + "New template name": "Nama templat baru", + "New time": "Waktu baru", + "New…": "Baru…", + "Next Appearance Mode": "Mode tampilan berikutnya", + "Next History Step": "Langkah riwayat berikutnya", + "Next Photo": "Foto berikutnya", + "Next Slide": "Slide berikutnya", + "Next up for AI Denoise…": "Berikutnya dalam antrean pengurangan noise AI…", + "Night Blue": "Biru Malam", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "NEF High Efficiency Nikon (HE / HE★) menggunakan kompresi intoPIX TicoRAW. LightCraft saat ini belum memiliki lisensi untuk codec ini, sehingga yang tersedia hanya pratinjau JPEG bawaan kamera, bukan data sensor raw. Untuk mengedit data raw, potret dalam format NEF dengan kompresi lossless, atau konversikan file HE yang ada ke DNG menggunakan konverter berlisensi.", + "No": "Tidak", + "No Camera or Card Found": "Kamera atau kartu memori tidak ditemukan", + "No Date Groups": "Tanpa grup tanggal", + "No Label": "Tanpa label", + "No Recent Libraries": "Tidak ada pustaka terbaru", + "No Stars": "Tanpa bintang", + "No Tool": "Tanpa alat", + "No albums match": "Tidak ada album yang cocok", + "No albums yet": "Belum ada album", + "No automatic versions.": "Tidak ada versi otomatis.", + "No camera": "Tidak ada kamera", + "No contributor data was built into this copy.": "Build ini tidak menyertakan data kontributor.", + "No date": "Tanpa tanggal", + "No dust spots found": "Tidak ditemukan bintik debu", + "No edits yet.": "Belum ada edit.", + "No exposure information": "Tidak ada informasi eksposur", + "No filter presets yet": "Belum ada preset filter", + "No folders to browse": "Tidak ada folder untuk ditelusuri", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Prosesor grafis tidak ditemukan: LightCraft merender di CPU, yang jauh lebih lambat. Detailnya ada di Bantuan → Info sistem.", + "No imported folders yet": "Belum ada folder yang diimpor", + "No keywords are exported": "Tidak ada kata kunci yang diekspor", + "No keywords match": "Tidak ada kata kunci yang cocok", + "No keywords yet": "Belum ada kata kunci", + "No lens data embedded in this file (DNG lens opcodes only).": "File ini tidak menyertakan data lensa (hanya opcode lensa DNG).", + "No lens data embedded in this file.": "File ini tidak menyertakan data lensa.", + "No masks yet. Choose a mask type above.": "Belum ada masker. Pilih jenis masker di atas.", + "No matching commands": "Tidak ada perintah yang cocok", + "No matching photos": "Tidak ada foto yang cocok", + "No metadata found": "Metadata tidak ditemukan", + "No model credits were built into this copy.": "Build ini tidak menyertakan kredit model.", + "No named people in these photos": "Tidak ada orang bernama di foto ini", + "No named people yet": "Belum ada orang bernama", + "No new presets": "Tidak ada preset baru", + "No photo has it; the keywords below it go too.": "Tidak ada foto yang memakai kata kunci ini; kata kunci di bawahnya juga akan dihapus.", + "No photo selected": "Tidak ada foto yang dipilih", + "No photos": "Tidak ada foto", + "No photos found": "Foto tidak ditemukan", + "No photos in this folder": "Tidak ada foto di folder ini", + "No photos match the active filters": "Tidak ada foto yang cocok dengan filter aktif", + "No photos match the filters (View → Clear Filters)": "Tidak ada foto yang cocok dengan filter (Tampilan → Hapus filter)", + "No photos yet": "Belum ada foto", + "No raw photos yet: cameras appear here once their photos are in the library.": "Belum ada foto raw: kamera akan muncul di sini setelah fotonya masuk ke pustaka.", + "No reference photo": "Tidak ada foto referensi", + "No usable GPU found: rendering on the CPU": "Tidak ditemukan GPU yang dapat digunakan: rendering dengan CPU", + "No versions yet. Create one to keep this look.": "Belum ada versi. Buat versi untuk menyimpan hasil edit ini.", + "Noise": "Noise", + "Noise Reduction": "Pengurangan noise", + "Non-raw photos": "Foto non-raw", + "None": "Tidak ada", + "None (the display is treated as sRGB)": "Tidak ada (layar dianggap sRGB)", + "None yet: save the current settings as a template below.": "Belum ada: simpan pengaturan saat ini sebagai templat di bawah.", + "Not Now": "Nanti saja", + "Not loaded yet": "Belum dimuat", + "Not saved": "Belum disimpan", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Tidak ada yang diunduh sebelum Anda menyetujuinya. Model hanya disimpan di komputer ini; LightCraft tidak pernah mengunggah atau membagikannya.", + "Nothing to compare": "Tidak ada yang bisa dibandingkan", + "Nothing to survey": "Tidak ada foto untuk ditampilkan di Survei", + "Nudge Develop Value": "Sesuaikan sedikit nilai olah", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "File JPEG bernomor (name-001.jpg…) di folder baru yang dinamai pada langkah berikutnya.", + "OK": "OK", + "ONNX model": "Model ONNX", + "Object": "Objek", + "Object Mask": "Masker objek", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Masker Objek dan Deskripsi menggunakan SAM 3, model segmentasi dari Meta. Model ini bukan bagian dari LightCraft, dan semua fitur lain tetap berfungsi tanpanya.", + "October 2026": "Oktober 2026", + "Off": "Nonaktif", + "Offset": "Offset", + "Offset X": "Offset X", + "Offset Y": "Offset Y", + "On NixOS, start it from `nix develop`.": "Di NixOS, jalankan dari `nix develop`.", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Satu halaman per slide (sisi panjang dalam piksel = poin), lengkap dengan durasi slide dan transisi memudar untuk mode presentasi di penampil.", + "Only": "Hanya", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Hanya foto dengan kekuatan pengurangan noise AI di atas 0 yang diproses. Saat ekspor, hasilnya selalu dibuat jika belum ada.", + "Opacity": "Opasitas", + "Opacity (%)": "Opasitas (%)", + "Open Library": "Buka pustaka", + "Open Library…": "Buka pustaka…", + "Open Log Folder": "Buka folder log", + "Open Original in Other Editor (RAW, no copy)": "Buka file asli di editor lain (RAW, tanpa salinan)", + "Open Recent Library": "Buka pustaka terbaru", + "Open Settings": "Buka pengaturan", + "Open in": "Buka di", + "Open in Detail": "Buka di Detail", + "Open page": "Buka halaman", + "Open the model's page": "Buka halaman model", + "Open the place in OpenStreetMap": "Buka lokasi di OpenStreetMap", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Membuka halaman model di browser untuk membaca informasinya atau mengunduh filenya secara manual.", + "Open…": "Buka…", + "Optics": "Optik", + "Optimize Library": "Optimalkan pustaka", + "Optional name": "Nama (opsional)", + "Options": "Opsi", + "Orange": "Oranye", + "Orange Hue": "Hue oranye", + "Orange Luminance": "Luminans oranye", + "Orange Saturation": "Saturasi oranye", + "Orientation": "Orientasi", + "Original": "Asli", + "Original + Settings": "Asli + pengaturan", + "Original extension without the dot": "Ekstensi asli tanpa titik", + "Original file name without its extension": "Nama file asli tanpa ekstensi", + "Original files": "File asli", + "Original offline · editing the smart preview": "File asli offline · mengedit pratinjau pintar", + "Original offline · no smart preview": "File asli offline · tanpa pratinjau pintar", + "Originals": "File asli", + "Other editor": "Editor lain", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Gambar lain (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Lainnya…", + "Overlay": "Overlay", + "Overlays": "Overlay", + "Override colour": "Gunakan warna khusus", + "Overwrite": "Timpa", + "Own duration (off: the slide duration)": "Durasi sendiri (nonaktif: ikuti durasi slide)", + "Pages": "Halaman", + "Paint": "Lukis", + "Paint only areas like the one under the brush": "Sapukan hanya pada area yang mirip dengan area di bawah kuas", + "Paint over a distraction on the photo to remove it.": "Sapukan kuas pada objek yang mengganggu di foto untuk menghapusnya.", + "Paint overlay": "Overlay warna", + "Panels": "Panel", + "Panorama": "Panorama", + "Panorama Merge": "Gabung panorama", + "Panorama Merge Preview": "Pratinjau gabung panorama", + "Panorama with Last Settings": "Panorama dengan pengaturan terakhir", + "Panoramas": "Panorama", + "Panorama…": "Panorama…", + "Paper": "Kertas", + "Parametric curve": "Kurva parametrik", + "Paste": "Tempel", + "Paste Edit Settings": "Tempel pengaturan edit", + "Paste Metadata": "Tempel metadata", + "Paste Selected Settings": "Tempel pengaturan yang dipilih", + "Paste Selected Settings…": "Tempel pengaturan yang dipilih…", + "Paste Settings": "Tempel pengaturan", + "Paste Settings from Previous": "Tempel pengaturan dari foto sebelumnya", + "Paste from Previous": "Tempel dari foto sebelumnya", + "Paste pages": "Tempel halaman", + "Pastel Haze": "Kabut Pastel", + "Pause / Resume Slideshow": "Jeda / lanjutkan tayangan slide", + "Pen Pressure": "Tekanan pena", + "Pending photo action cancelled": "Tindakan foto yang tertunda dibatalkan", + "Pending photo action cancelled because its library or photo changed": "Tindakan foto yang tertunda dibatalkan karena pustaka atau fotonya berubah", + "People": "Orang", + "People Count": "Jumlah orang", + "Percent": "Persen", + "Percentage": "Persentase", + "Perceptual": "Perseptual", + "Performance": "Performa", + "Person": "Orang", + "Perspective": "Perspektif", + "Pet Eye": "Mata hewan", + "Pet Eye Catchlight": "Catchlight mata hewan", + "Photo": "Foto", + "Photo Above": "Foto di atas", + "Photo Below": "Foto di bawah", + "Photo Grid": "Grid foto", + "Photo Grid (G)": "Grid foto (G)", + "Photo Merge": "Gabung foto", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Foto → Edit di → Edit di PhotoCraft (⇧⌘E, atau klik kanan foto) merender salinan beserta hasil edit Anda, menumpuknya dengan foto asli, lalu membukanya di PhotoCraft. Simpan di sana, dan salinan di sini ikut diperbarui. Jika berformat PSD, PhotoCraft menyimpan langsung ke file tersebut. Kosongkan lokasi kecuali Anda menggunakan salinan portabel.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Edit di editor eksternal (⇧⌘E) membuat salinan TIFF 16-bit, menumpuknya dengan file asli, lalu membukanya dengan program yang diisi di sini (nama aplikasi di macOS, jalur program di sistem lain).", + "PhotoCraft": "PhotoCraft", + "Photos": "Foto", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Foto ditampilkan melalui profil ICC monitor Anda sehingga warnanya akurat di layar wide-gamut maupun layar yang sudah dikalibrasi. Tampilan Detail juga memakai seluruh gamut layar. Histogram, cache pratinjau, dan ekspor tidak terpengaruh.", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Foto yang diedit dalam HDR: JPEG dengan gain map HDR (tampil baik di mana pun, sorotan lebih terang di layar HDR), AVIF 10-bit, PNG dan JPEG XL 16-bit Rec. 2020 PQ; TIFF memerlukan float 32-bit", + "Photos per bracket (0 = auto)": "Foto per bracket (0 = otomatis)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Foto yang hanya dilihat di Lokal, tanpa pernah ditambahkan atau diubah, dikeluarkan dari katalog jika foldernya tidak dibuka selama jangka waktu ini (diperiksa saat pustaka dibuka). File tetap ada di disk; membuka folder itu lagi akan menampilkannya kembali.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Foto tetap ada di Semua foto. Menghapus folder juga menghapus album di dalamnya. Tindakan ini bisa diurungkan.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Foto yang masuk ke folder ini (dari tethering, pemindai, atau aplikasi sinkronisasi) langsung ditambahkan begitu selesai disalin.", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Foto yang Anda impor akan muncul di sini. Tidak ada yang disalin atau dipindahkan kecuali Anda memintanya.", + "Pick": "Pilihan", + "Pick Flag": "Tanda pilihan", + "Pick a colour on the photo with the eyedropper.": "Pilih warna pada foto dengan pipet.", + "Pick a date": "Pilih tanggal", + "Pick the sharpest photo of each burst": "Pilih foto paling tajam dari setiap bidikan beruntun", + "Picked": "Pilihan", + "Picks": "Pilihan", + "Pictures": "Gambar", + "Pixels": "Piksel", + "Place Names Status": "Status nama tempat", + "Place names download cancelled": "Unduhan nama tempat dibatalkan", + "Place names downloaded": "Nama tempat diunduh", + "Plain Black": "Hitam polos", + "Playback": "Pemutaran", + "Point Color": "Warna titik", + "Point Curve": "Kurva titik", + "Point Curve Presets": "Preset kurva titik", + "Point curve": "Kurva titik", + "Portrait": "Potret", + "Portrait (tall)": "Potret (tegak)", + "Position": "Posisi", + "Preferences": "Preferensi", + "Preferences…": "Preferensi…", + "Prepare AI Masks": "Siapkan masker AI", + "Preset": "Preset", + "Preset name": "Nama preset", + "Presets": "Preset", + "Presets & Profiles": "Preset & profil", + "Presets (Shift+P)": "Preset (Shift+P)", + "Presets ▾": "Preset ⌄", + "Press keys…": "Tekan tombol…", + "Preview": "Pratinjau", + "Preview Build Progress": "Progres pembuatan pratinjau", + "Preview only": "Pratinjau saja", + "Preview only: editing the camera's embedded JPEG": "Hanya pratinjau: yang diedit adalah JPEG bawaan kamera", + "Preview size": "Ukuran pratinjau", + "Preview unavailable": "Pratinjau tidak tersedia", + "Previews": "Pratinjau", + "Previous History Step": "Langkah riwayat sebelumnya", + "Previous Photo": "Foto sebelumnya", + "Previous Slide": "Slide sebelumnya", + "Previous settings": "Pengaturan sebelumnya", + "Primaries (x, y)": "Primer (x, y)", + "Print": "Cetak", + "Print Settings": "Pengaturan cetak", + "Print Templates": "Templat cetak", + "Print to PDF": "Cetak ke PDF", + "Print…": "Cetak…", + "Processor": "Prosesor", + "Profile": "Profil", + "Profile Browser": "Penjelajah profil", + "Profile Distortion": "Distorsi profil", + "Profile Menu": "Menu profil", + "Profile Vignetting": "Vinyet profil", + "Profile:": "Profil:", + "Profiles": "Profil", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Profil yang ditetapkan di pengaturan warna desktop Anda biasanya ada di ~/.local/share/icc.", + "Progressive": "Progresif", + "Projection": "Proyeksi", + "Proof copy created": "Salinan soft proof dibuat", + "Properties": "Properti", + "Public Domain": "Domain publik", + "Public domain": "Domain publik", + "Pupil Size": "Ukuran pupil", + "Purge Unused Keywords": "Hapus kata kunci yang tidak digunakan", + "Purple": "Ungu", + "Purple Amount": "Kekuatan ungu", + "Purple Hue": "Hue ungu", + "Purple Hue High": "Hue ungu atas", + "Purple Hue Low": "Hue ungu bawah", + "Purple Luminance": "Luminans ungu", + "Purple Saturation": "Saturasi ungu", + "Put New Keywords Inside": "Tempatkan kata kunci baru di dalamnya", + "Put New Keywords Inside This Keyword": "Masukkan kata kunci baru ke dalam kata kunci ini", + "Put the locations you removed from Local back (no files change)": "Kembalikan lokasi yang dikeluarkan dari Lokal (tidak ada file yang diubah)", + "Quality": "Kualitas", + "Query Photos": "Kueri foto", + "Quick Collection": "Koleksi cepat", + "Quick Develop": "Olah cepat", + "Quiet Green": "Hijau Teduh", + "Quit": "Keluar", + "Quit Anyway": "Tetap keluar", + "Quit LightCraft": "Keluar dari LightCraft", + "Quit while tasks are running?": "Keluar saat tugas masih berjalan?", + "Quit with unsaved changes?": "Keluar tanpa menyimpan perubahan?", + "Radial": "Radial", + "Radial Gradient": "Gradien radial", + "Radius": "Radius", + "Random": "Acak", + "Range": "Rentang", + "Rate ★": "Beri rating ★", + "Rate ★ and advance": "Beri rating ★ lalu lanjut", + "Rate ★★": "Beri rating ★★", + "Rate ★★ and advance": "Beri rating ★★ lalu lanjut", + "Rate ★★★": "Beri rating ★★★", + "Rate ★★★ and advance": "Beri rating ★★★ lalu lanjut", + "Rate ★★★★": "Beri rating ★★★★", + "Rate ★★★★ and advance": "Beri rating ★★★★ lalu lanjut", + "Rate ★★★★★": "Beri rating ★★★★★", + "Rate ★★★★★ and advance": "Beri rating ★★★★★ lalu lanjut", + "Rating": "Rating", + "Rating cleared": "Rating dihapus", + "Rating stars": "Bintang rating", + "Ratings & flags": "Rating & tanda", + "Raw": "Raw", + "Raw default": "Bawaan raw", + "Raw defaults": "Bawaan raw", + "Raw files": "File raw", + "Raw photos": "Foto raw", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Foto raw sebagai DNG, dengan hasil edit tersemat. Opsi ukuran, warna, dan output tidak berlaku.", + "Read Focus Points": "Baca titik fokus", + "Read Focus Points in the Background": "Baca titik fokus di latar belakang", + "Read Metadata from File": "Baca metadata dari file", + "Read metadata updates from XMP sidecars": "Baca pembaruan metadata dari file sidecar XMP", + "Read mode": "Mode baca", + "Read the SAM License": "Baca lisensi SAM", + "Reading": "Membaca", + "Reading Lightroom catalog": "Membaca katalog Lightroom", + "Reading Lightroom catalog…": "Membaca katalog Lightroom…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Membaca file sidecar akan mengganti rating, label, metadata, dan hasil edit foto dengan isi file tersebut.", + "Reading focus points": "Membaca titik fokus", + "Reading folder": "Membaca folder", + "Reading folder…": "Membaca folder…", + "Reading photos…": "Membaca foto…", + "Real name": "Nama asli", + "Recent": "Terakhir dipakai", + "Recent Keywords": "Kata kunci terbaru", + "Recent Keywords is built in: choose another name": "Kata kunci terbaru adalah nama bawaan: pilih nama lain", + "Recent Libraries": "Pustaka terbaru", + "Recently Added": "Baru ditambahkan", + "Recently Deleted": "Baru dihapus", + "Recheck Preview-Only Raws": "Periksa ulang file raw yang hanya berupa pratinjau", + "Recognise faces (experimental)": "Kenali wajah (eksperimental)", + "Recognition": "Pengenalan", + "Recognition models": "Model pengenalan", + "Red": "Merah", + "Red Eye": "Mata merah", + "Red Hue": "Hue merah", + "Red Luminance": "Luminans merah", + "Red Primary": "Primer merah", + "Red Saturation": "Saturasi merah", + "Red channel": "Kanal merah", + "Red label": "Label merah", + "Red/Cyan Fringe": "Fringe merah/sian", + "Redo": "Ulangi", + "Reference": "Referensi", + "Reference View": "Tampilan referensi", + "Reference the files where they are": "Rujuk file di lokasinya saat ini", + "Refine": "Perhalus", + "Refine AI Mask Detail": "Perhalus detail masker AI", + "Refine Edges": "Perhalus tepi", + "Refine Mask Edges": "Perhalus tepi masker", + "Refine Saturation": "Perhalus saturasi", + "Refining the mask's detail…": "Memperhalus detail masker…", + "Refresh Source": "Segarkan sumber", + "Refresh Source (/)": "Segarkan sumber (/)", + "Refresh View": "Segarkan tampilan", + "Reject": "Tolak", + "Reject below focus": "Tolak jika fokus di bawah batas", + "Rejected": "Ditolak", + "Rejects": "Ditolak", + "Relative": "Relatif", + "Relink Photo": "Tautkan ulang foto", + "Relink photos whose file was renamed or moved": "Tautkan ulang foto yang filenya diganti namanya atau dipindahkan", + "Relinked": "Ditautkan ulang", + "Reload Changed Files": "Muat ulang file yang berubah", + "Reload from Disk": "Muat ulang dari disk", + "Remove": "Hapus", + "Remove (H)": "Hapus (H)", + "Remove CA": "Hapus CA", + "Remove Chromatic Aberration": "Hapus aberasi kromatik", + "Remove Denoise Model": "Hapus model pengurangan noise", + "Remove Disk from Library": "Keluarkan disk dari pustaka", + "Remove Disk from Library…": "Keluarkan disk dari pustaka…", + "Remove Face Box": "Hapus kotak wajah", + "Remove Face Model": "Hapus model wajah", + "Remove Folder Color Label": "Hapus label warna folder", + "Remove Folder from Library": "Keluarkan folder dari pustaka", + "Remove a filter above, or choose Clear all": "Hapus salah satu filter di atas, atau pilih Hapus semua", + "Remove from Album": "Keluarkan dari album", + "Remove from Favorites": "Keluarkan dari Favorit", + "Remove from Library…": "Keluarkan dari pustaka…", + "Remove from Local": "Keluarkan dari Lokal", + "Remove from Photo": "Hapus dari foto", + "Remove from Selected Photos": "Hapus dari foto yang dipilih", + "Remove from Stack": "Keluarkan dari tumpukan", + "Remove from Target Album": "Keluarkan dari album tujuan", + "Remove from favorites": "Keluarkan dari Favorit", + "Remove from survey": "Keluarkan dari survei", + "Remove location info": "Hapus info lokasi", + "Remove missing photos from the library": "Keluarkan foto yang hilang dari pustaka", + "Remove shortcut": "Hapus pintasan", + "Remove this face box (undo with Edit ▸ Undo)": "Hapus kotak wajah ini (urungkan lewat Edit → Urungkan)", + "Remove this filter": "Hapus filter ini", + "Remove this group": "Hapus grup ini", + "Remove this rule": "Hapus aturan ini", + "Removed": "Dikeluarkan", + "Rename": "Ganti nama", + "Rename Album": "Ganti nama album", + "Rename Component": "Ganti nama komponen", + "Rename Folder": "Ganti nama folder", + "Rename Folder…": "Ganti nama folder…", + "Rename Keyword": "Ganti nama kata kunci", + "Rename Keyword…": "Ganti nama kata kunci…", + "Rename Mask": "Ganti nama masker", + "Rename Mask Effect": "Ganti nama efek masker", + "Rename Mask Effect Group": "Ganti nama grup efek masker", + "Rename Person": "Ganti nama orang", + "Rename Photos": "Ganti nama foto", + "Rename Photos…": "Ganti nama foto…", + "Rename Preset": "Ganti nama preset", + "Rename Preview": "Pratinjau penggantian nama", + "Rename Slideshow Template": "Ganti nama templat tayangan slide", + "Rename Template Tags": "Tag templat penggantian nama", + "Rename Version": "Ganti nama versi", + "Rename…": "Ganti nama…", + "Render behind image": "Tampilkan di belakang gambar", + "Rendering": "Rendering", + "Rendering…": "Merender…", + "Reorder Album": "Ubah urutan album", + "Reorder Masks": "Ubah urutan masker", + "Reset": "Atur ulang", + "Reset All": "Atur ulang semua", + "Reset All Curves": "Atur ulang semua kurva", + "Reset All Keyboard Shortcuts": "Atur ulang semua pintasan keyboard", + "Reset Before to Import State": "Atur ulang Sebelum ke kondisi saat diimpor", + "Reset Control": "Atur ulang kontrol", + "Reset Crop": "Atur ulang pangkasan", + "Reset Edits": "Atur ulang edit", + "Reset Panel Layout": "Atur ulang tata letak panel", + "Reset Parametric Curve": "Atur ulang kurva parametrik", + "Reset Print Settings": "Atur ulang pengaturan cetak", + "Reset Section": "Atur ulang bagian", + "Reset Sidebar Sections": "Atur ulang bagian bilah samping", + "Reset Tone Curve": "Atur ulang kurva tone", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Atur ulang semua kurva: kurva titik (semua kanal) dan parametrik (klik dua kali kanal untuk mengatur ulang kanal itu saja)", + "Reset all edits": "Atur ulang semua edit", + "Reset all edits (Cmd+Shift+R)": "Atur ulang semua edit (Cmd+Shift+R)", + "Reset layout": "Atur ulang tata letak", + "Reshuffle": "Acak ulang", + "Resize Face Box": "Ubah ukuran kotak wajah", + "Resize Print Cell": "Ubah ukuran sel cetak", + "Resize panel window": "Ubah ukuran jendela panel", + "Resize panels": "Ubah ukuran panel", + "Resize to": "Ubah ukuran ke", + "Resolution (ppi)": "Resolusi (ppi)", + "Restore": "Pulihkan", + "Restore (no shortcut)": "Pulihkan (tanpa pintasan)", + "Restore Library from Backup…": "Pulihkan pustaka dari cadangan…", + "Restore Version": "Pulihkan versi", + "Restore keeps their edits; Import as new deletes the old record first.": "Pulihkan mempertahankan hasil edit; Impor sebagai baru menghapus data lama terlebih dahulu.", + "Restore them": "Pulihkan", + "Retouch": "Retouch", + "Return creates this keyword and gives it to the selected photos": "Tekan Return untuk membuat kata kunci ini dan menerapkannya ke foto yang dipilih", + "Reverse the order": "Balikkan urutan", + "Revert": "Kembalikan", + "Review": "Tinjauan", + "Review Import": "Tinjau impor", + "Right": "Kanan", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Klik kanan foto → Jadikan foto referensi, lalu Tampilan → Tampilan referensi (Shift+R)", + "Right-click a preset to delete it": "Klik kanan preset untuk menghapusnya", + "Rights Usage Terms": "Ketentuan penggunaan", + "Rotate": "Putar", + "Rotate Crop": "Putar area pangkas", + "Rotate Crop Aspect": "Putar aspek pangkas", + "Rotate Left": "Putar ke kiri", + "Rotate Right": "Putar ke kanan", + "Roughness": "Kekasaran", + "Roundness": "Kebulatan", + "Rules": "Aturan", + "Run face models on the CPU only": "Jalankan model wajah hanya di CPU", + "SAM 3 download stopped: it resumes where it left off next time.": "Unduhan SAM 3 dihentikan: lain kali unduhan dilanjutkan dari titik terakhir.", + "SDR": "SDR", + "SDR Brightness": "Kecerahan SDR", + "SDR Clarity": "Kejernihan SDR", + "SDR Contrast": "Kontras SDR", + "SDR Highlights": "Sorotan SDR", + "SDR Shadows": "Bayangan SDR", + "SDR Whites": "Putih SDR", + "Sa": "Sb", + "Same as raw default": "Sama dengan bawaan raw", + "Same folder as original photo": "Folder yang sama dengan foto asli", + "Sample Mask Color": "Ambil sampel warna masker", + "Sample a colour on the photo": "Ambil sampel warna dari foto", + "Saturation": "Saturasi", + "Saturation Range": "Rentang saturasi", + "Saturday": "Sabtu", + "Save": "Simpan", + "Save As…": "Simpan sebagai…", + "Save Color Label Set": "Simpan set label warna", + "Save Current Filter as Preset…": "Simpan filter saat ini sebagai preset…", + "Save Current Keywords as Set…": "Simpan kata kunci saat ini sebagai set…", + "Save Current Settings as New Effect Preset…": "Simpan pengaturan saat ini sebagai preset efek baru…", + "Save Export Preset": "Simpan preset ekspor", + "Save Filter Preset": "Simpan preset filter", + "Save Keyword Set": "Simpan set kata kunci", + "Save Mask Effect": "Simpan efek masker", + "Save Metadata Preset": "Simpan preset metadata", + "Save Metadata Preset…": "Simpan preset metadata…", + "Save Metadata to File": "Simpan metadata ke file", + "Save Point Curve Preset": "Simpan preset kurva titik", + "Save Point Curve…": "Simpan kurva titik…", + "Save Print Template": "Simpan templat cetak", + "Save Slideshow Template": "Simpan templat tayangan slide", + "Save as Preset": "Simpan sebagai preset", + "Save as Set…": "Simpan sebagai set…", + "Save as Smart Album…": "Simpan sebagai album pintar…", + "Save as Template": "Simpan sebagai templat", + "Save as a new set": "Simpan sebagai set baru", + "Save as set": "Simpan sebagai set", + "Save as…": "Simpan sebagai…", + "Save preset": "Simpan preset", + "Saving Lightroom import index…": "Menyimpan indeks impor Lightroom…", + "Scale": "Skala", + "Scan Folder for Changes": "Pindai perubahan di folder", + "Scan failed": "Pemindaian gagal", + "Scanning": "Memindai", + "Scanning folder": "Memindai folder", + "Scanning…": "Memindai…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Skor tetap tersimpan di foto: gunakan Fokus dan Terbaik dari bidikan serupa untuk memfilter atau membuat album pintar.", + "Screen": "Layar", + "Scroll menu down": "Gulir menu ke bawah", + "Scroll menu up": "Gulir menu ke atas", + "Search": "Cari", + "Search Photos": "Cari foto", + "Search albums": "Cari album", + "Search commands or keys": "Cari perintah atau tombol", + "Search…": "Cari…", + "Seasons": "Musim", + "Second Window": "Jendela kedua", + "Select": "Pilih", + "Select All": "Pilih semua", + "Select Luminance Range": "Pilih rentang luminans", + "Select Mask": "Pilih masker", + "Select Only Active Photo": "Pilih foto aktif saja", + "Select Photos": "Pilih foto", + "Select Spot": "Pilih bintik", + "Select a photo to edit": "Pilih foto untuk diedit", + "Select a photo to preview its slide": "Pilih foto untuk melihat pratinjau slidenya", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Pilih foto, lalu aktifkan opsi ini untuk menjadikannya latar slide di belakang setiap foto.", + "Select all": "Pilih semua", + "Select by": "Pilih menurut", + "Select by Flag, Rating or Label": "Pilih menurut tanda, rating, atau label", + "Select none": "Kosongkan pilihan", + "Select photos to give them keywords.": "Pilih foto untuk menambahkan kata kunci.", + "Select photos, then choose View → Survey (N)": "Pilih foto, lalu buka Tampilan → Survei (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Pilih dua foto, lalu pilih Tampilan → Bandingkan (Shift+C)", + "Selected": "Dipilih", + "Selecting…": "Memilih…", + "Selenium Tone": "Tone Selenium", + "Send Feedback…": "Kirim masukan…", + "Separate several keywords with commas. Return gives them to the selected photos.": "Pisahkan beberapa kata kunci dengan koma. Tekan Return untuk menambahkannya ke foto yang dipilih.", + "Separated by commas": "Pisahkan dengan koma", + "Sepia Tone": "Tone Sepia", + "Sequence number zero-padded to N digits (1–9)": "Nomor urut dengan nol di depan hingga N digit (1–9)", + "Sequence number, counting from the start number": "Nomor urut, dihitung dari nomor awal", + "Services": "Layanan", + "Set": "Set", + "Set Album Cover": "Jadikan sampul album", + "Set Before": "Jadikan Sebelum", + "Set Color Label": "Atur label warna", + "Set Crop": "Terapkan pangkasan", + "Set Develop Values": "Atur nilai olah", + "Set Effect for New Masks": "Atur efek untuk masker baru", + "Set Flag": "Beri tanda", + "Set Folder Color Label": "Atur label warna folder", + "Set Image Zoom and Pan": "Atur zoom dan posisi gambar", + "Set Keyboard Shortcut": "Atur pintasan keyboard", + "Set Mask Adjustments": "Atur penyesuaian masker", + "Set Point Curve": "Atur kurva titik", + "Set Print Cell Photo": "Atur foto sel cetak", + "Set Print Settings": "Atur pengaturan cetak", + "Set Profile": "Tetapkan profil", + "Set Rating": "Beri rating", + "Set Top of Stack": "Jadikan teratas di tumpukan", + "Set as Album Cover": "Jadikan sampul album", + "Set as Before": "Jadikan Sebelum", + "Set as Reference Photo": "Jadikan foto referensi", + "Set as Target Album": "Jadikan album tujuan", + "Set as Target Album (B adds to it)": "Jadikan album tujuan (tekan B untuk menambahkan)", + "Set as Top of Stack": "Jadikan teratas di tumpukan", + "Set date & time": "Atur tanggal & waktu", + "Set name": "Nama set", + "Set the mix from the photo's colours": "Atur campuran berdasarkan warna foto", + "Set up AI Denoise…": "Siapkan pengurangan noise AI…", + "Settings": "Pengaturan", + "Settings file problem": "Masalah file pengaturan", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Pengaturan awal untuk foto raw baru. Mengubahnya tidak memengaruhi foto yang sudah ada di pustaka.", + "Settings pasted": "Pengaturan ditempel", + "Settings to include": "Pengaturan yang disertakan", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Aktifkan kembali lewat Pengaturan → Minta konfirmasi sebelum memindahkan foto ke folder lain di disk", + "Settings…": "Pengaturan…", + "Shade": "Teduh", + "Shadow": "Bayangan", + "Shadows": "Bayangan", + "Shadows Hue": "Hue bayangan", + "Shadows Luminance": "Luminans bayangan", + "Shadows Saturation": "Saturasi bayangan", + "Shadows Tint": "Rona bayangan", + "Shadows split": "Batas bayangan", + "Sharpen": "Pertajam", + "Sharpening": "Penajaman", + "Sharpness": "Ketajaman", + "Shift": "Geser", + "Shift by": "Geser sebanyak", + "Shooting Data": "Data pemotretan", + "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", + "Show Source": "Tampilkan sumber", + "Show Thumbnails": "Tampilkan thumbnail", + "Show all overlays": "Tampilkan semua overlay", + "Show commands without a shortcut": "Tampilkan perintah tanpa pintasan", + "Show destination gamut warning": "Tampilkan peringatan gamut tujuan", + "Show display gamut warning": "Tampilkan peringatan gamut layar", + "Show every keyword of the selected photos": "Tampilkan semua kata kunci foto yang dipilih", + "Show fewer": "Tampilkan lebih sedikit", + "Show file names": "Tampilkan nama file", + "Show guides": "Tampilkan garis panduan", + "Show in Compare": "Tampilkan di Bandingkan", + "Show in Explorer": "Tampilkan di Explorer", + "Show in File Manager": "Tampilkan di pengelola file", + "Show in Finder": "Tampilkan di Finder", + "Show in Library": "Tampilkan di pustaka", + "Show less": "Tampilkan lebih sedikit", + "Show on Map": "Tampilkan di peta", + "Show overlay (O)": "Tampilkan overlay (O)", + "Show photos": "Tampilkan foto", + "Show ratings, flags and edit badges": "Tampilkan rating, tanda, dan lencana edit", + "Show the Navigator while zoomed in": "Tampilkan Navigator saat diperbesar", + "Show the import review when a camera or card is connected": "Tampilkan tinjauan impor saat kamera atau kartu memori terhubung", + "Show the template tags; click one to insert it": "Tampilkan tag templat; klik salah satu untuk menyisipkannya", + "Show this wheel's effect": "Tampilkan efek roda ini", + "Show/Hide Mask": "Tampilkan/Sembunyikan masker", + "Show/hide My Photos panel": "Tampilkan/sembunyikan panel Foto saya", + "Shows": "Isi tampilan", + "Shutter Speed": "Kecepatan rana", + "Shutter speed": "Kecepatan rana", + "Side panels": "Panel samping", + "Sidebar Sections": "Bagian bilah samping", + "Sidecar names": "Nama file sidecar", + "Silver": "Perak", + "Simulate Paper & Ink": "Simulasikan kertas & tinta", + "Size": "Ukuran", + "Size (% of short edge)": "Ukuran (% dari sisi pendek)", + "Size limit": "Batas ukuran", + "Skip": "Lewati", + "Skipped: files already in the library elsewhere": "Dilewati: file sudah ada di lokasi lain dalam pustaka", + "Skipped: files that can't be read": "Dilewati: file yang tidak dapat dibaca", + "Sky": "Langit", + "Slide": "Slide", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Slide memakai tata letak, overlay, serta layar pembuka dan penutup dari tayangan slide, sesuai urutan tampilan.", + "Slideshow": "Tayangan slide", + "Slideshow Export Status": "Status ekspor tayangan slide", + "Slideshow Layout Settings": "Pengaturan tata letak tayangan slide", + "Slideshow Options…": "Opsi tayangan slide…", + "Slideshow Overlay Settings": "Pengaturan overlay tayangan slide", + "Slideshow Playback Settings": "Pengaturan pemutaran tayangan slide", + "Slideshow Slide Preview": "Pratinjau slide", + "Slideshow Templates": "Templat tayangan slide", + "Slideshow Title Settings": "Pengaturan judul tayangan slide", + "Slideshow paused": "Tayangan slide dijeda", + "Slideshow resumed": "Tayangan slide dilanjutkan", + "Slideshow · Space pauses · Esc ends": "Tayangan slide · Spasi untuk jeda · Esc untuk mengakhiri", + "Smart Album": "Album pintar", + "Smart Album Rule Fields": "Kolom aturan album pintar", + "Smart Preview Status": "Status pratinjau pintar", + "Smart Previews Location": "Lokasi pratinjau pintar", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Album pintar diperbarui secara otomatis: ubah aturannya, atau gunakan Foto → Hapus foto", + "Smart preview available": "Pratinjau pintar tersedia", + "Smart previews": "Pratinjau pintar", + "Smart previews folder changed": "Folder pratinjau pintar diubah", + "Smoothness": "Kehalusan", + "Soft B&W": "Hitam Putih Lembut", + "Soft Pastel": "Pastel Lembut", + "Soft Proofing": "Soft proofing", + "Soft Skin": "Kulit Halus", + "Solo Mode": "Mode solo", + "Some rules need fixing (Edit Smart Album…):": "Beberapa aturan perlu diperbaiki (Edit album pintar…):", + "Sort": "Urutkan", + "Sort Albums A–Z": "Urutkan album A–Z", + "Sort Albums by Name": "Urutkan album menurut nama", + "Sort Contents A–Z": "Urutkan isi A–Z", + "Sort by": "Urutkan menurut", + "Source": "Sumber", + "Source code on GitHub": "Kode sumber di GitHub", + "Specific folder": "Folder tertentu", + "Speed": "Kecepatan", + "Spherical": "Sferis", + "Spiral": "Spiral", + "Split Stack": "Pisahkan tumpukan", + "Spot": "Spot", + "Spring Fresh": "Segar Musim Semi", + "Square": "Persegi", + "Square Grid": "Grid persegi", + "Square Grid (G toggles)": "Grid persegi (G untuk beralih)", + "Square Grid: show file names and formats": "Grid persegi: tampilkan nama dan format file", + "Stack": "Tumpukan", + "Stack photos taken within this time of each other:": "Tumpuk foto yang diambil dalam selang waktu ini:", + "Stacks": "Tumpukan", + "Standard": "Standar", + "Star rating (0–5)": "Rating bintang (0–5)", + "Start at": "Mulai dari", + "Start number": "Nomor awal", + "Starting": "Memulai", + "Starting…": "Memulai…", + "Startup": "Saat dibuka", + "State / Province": "Provinsi / negara bagian", + "Stop": "Hentikan", + "Stop Building Previews": "Hentikan pembuatan pratinjau", + "Stop Using as Target Album": "Berhenti menggunakan sebagai album tujuan", + "Stopped": "Dihentikan", + "Stopped by an error (see the log)": "Berhenti karena kesalahan (lihat log)", + "Stopping…": "Menghentikan…", + "Straighten": "Luruskan", + "Straighten Tool": "Alat pelurus", + "Stroke border": "Garis tepi", + "Strong Contrast": "Kontras kuat", + "Style": "Gaya", + "Su": "Mg", + "Subfolder": "Subfolder", + "Subfolder name": "Nama subfolder", + "Subject": "Subjek", + "Subtract": "Kurangi", + "Subtract from this mask": "Kurangi dari masker ini", + "Suggest Names for Faces": "Sarankan nama untuk wajah", + "Suggestions": "Saran", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Menyarankan siapa yang ada di foto berdasarkan wajah yang sudah Anda beri nama. Semuanya tetap di komputer Anda.", + "Summer Haze": "Kabut Musim Panas", + "Sunday": "Minggu", + "Survey": "Survei", + "Survey (N)": "Survei (N)", + "Swap": "Tukar", + "Swap Aspect (X)": "Tukar aspek (X)", + "Swap Before and After": "Tukar Sebelum dan Sesudah", + "Swap Before and After Settings": "Tukar pengaturan Sebelum dan Sesudah", + "Swap Compare Photos": "Tukar foto yang dibandingkan", + "Switch light / dark theme": "Ganti tema terang / gelap", + "Sync Settings": "Sinkronkan pengaturan", + "Sync with System": "Ikuti sistem", + "Synchronize": "Sinkronkan", + "Synchronize Folder": "Sinkronkan folder", + "Synchronize Folder…": "Sinkronkan folder…", + "Synchronizing folder": "Menyinkronkan folder", + "Synchronizing…": "Menyinkronkan…", + "Synonyms": "Sinonim", + "System": "Sistem", + "System Info": "Info sistem", + "System Info…": "Info sistem…", + "System default": "Bawaan sistem", + "Table": "Tabel", + "Tags": "Tag", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: lihat Tag di samping templat. Nama file diganti di disk (beserta file sidecar XMP-nya); nama yang sudah ada diberi akhiran -1, -2…", + "Take a snapshot": "Ambil snapshot", + "Targeted Adjustment": "Penyesuaian terarah", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Penyesuaian terarah aktif: seret ke atas atau ke bawah pada foto · Esc untuk berhenti", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Penyesuaian terarah: seret ke atas/bawah pada foto untuk menyesuaikan warna di area itu", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Penyesuaian terarah: seret ke atas/bawah pada foto untuk menyesuaikan kurva di titik tersebut", + "Teal & Amber": "Teal & Amber", + "Teal & Orange": "Teal & Orange", + "Temp": "Suhu", + "Temperature": "Suhu", + "Template": "Templat", + "Templates": "Templat", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sesi sementara — pustaka Anda tidak dibuka, jadi perubahan apa pun di sini tidak disimpan.", + "Test Denoise Model": "Uji model pengurangan noise", + "Test Face Model": "Uji model wajah", + "Tested and works on this computer": "Sudah diuji dan berfungsi di komputer ini", + "Text": "Teks", + "Text direction": "Arah teks", + "Text overlays": "Overlay teks", + "Texture": "Tekstur", + "Th": "Km", + "That includes the photos in the folders inside it.": "Termasuk foto di dalam subfoldernya.", + "That key can't be used as a shortcut": "Tombol itu tidak dapat digunakan sebagai pintasan", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 sudah terinstal: masker Objek dan Deskripsi siap digunakan.", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model pengurangan noise sudah terinstal dan digunakan: slider Kekuatan pengurangan noise AI di panel Detail kini dapat dipakai", + "The download didn't work:": "Unduhan gagal:", + "The ending screen, after the last photo.": "Layar penutup, setelah foto terakhir.", + "The export watermark, drawn on the photo and sized relative to it.": "Tanda air ekspor, digambar di atas foto dengan ukuran yang menyesuaikan foto.", + "The first time, macOS asks whether LightCraft may control Photos.": "Saat pertama kali, macOS akan menanyakan apakah LightCraft boleh mengontrol Photos.", + "The graphics card is set up when the next photo is made.": "Kartu grafis akan disiapkan saat foto berikutnya diproses.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Impor membaca katalog tanpa mengubahnya, lalu menambahkan foto, hasil edit, kata kunci, dan koleksinya ke pustaka yang sedang terbuka.", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Intro diputar sekali sebelum foto pertama; penutup diputar setelah foto terakhir jika Ulangi nonaktif. Pelat identitas yang dipakai adalah yang diatur di Overlay. Judul memudar seperti slide dan tidak pernah dipindahkan oleh urutan acak.", + "The intro screen, before the first photo.": "Layar pembuka, sebelum foto pertama.", + "The library is up to date with this folder.": "Pustaka sudah sesuai dengan folder ini.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model hanya disimpan di komputer ini. LightCraft tidak pernah mengunggah atau membagikannya.", + "The number at the end of the original name (IMG_0042 → 0042)": "Nomor di akhir nama asli (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Ekstensi asli selalu ditambahkan; {ext} hanya menambahkannya juga ke dalam nama.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "File asli tanpa perubahan, masing-masing disertai file sidecar XMP yang menyimpan hasil editnya.", + "The photo has these settings": "Foto ini memiliki pengaturan berikut", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Foto di tampilan ini dijadikan slide dengan pengaturan di atas. Progresnya ditampilkan di daftar aktivitas.", + "The processor does the work.": "Seluruh pemrosesan ditangani prosesor.", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Foto yang dipilih ditampilkan sebagai slide. Saat diputar, slide memenuhi layar; pratinjau aspek hanya memengaruhi pratinjau ini.", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "File dan sidecar XMP-nya ikut dipindahkan di disk. Edit → Urungkan akan memindahkannya kembali.", + "Their photos and the keywords below them come together under one keyword.": "Foto serta kata kunci di bawahnya digabungkan menjadi satu kata kunci.", + "Their photos in the grid": "Foto orang ini di grid", + "Theme": "Tema", + "There is no model file to install": "Tidak ada file model untuk diinstal", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Foto ini hanya ada di memori. Buka pustaka di disk, lalu impor ulang katalog untuk menyimpannya.", + "They can be restored from Recently Deleted until it is emptied.": "Foto masih dapat dipulihkan dari Baru dihapus hingga album tersebut dikosongkan.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Foto akan dipindahkan ke Baru dihapus dan dapat dipulihkan; file di disk tidak diubah.", + "Thirds": "Aturan sepertiga", + "This Computer": "Komputer ini", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Build ini belum bisa mengunduh model. Untuk menginstalnya secara manual, letakkan model.safetensors, vocab.json, dan merges.txt dari facebook/sam3 milik Meta ke folder di atas: masker Objek dan Deskripsi langsung berfungsi setelah file tersebut tersedia.", + "This build cannot run denoise models.": "Build ini tidak dapat menjalankan model pengurangan noise.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Build ini tidak dapat menjalankan model pengenalan: model bisa ditambahkan dan dipilih, tetapi tidak bisa digunakan.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Build ini tidak memiliki tempat untuk menyimpan model pengurangan noise, sehingga pengurangan noise AI tidak tersedia. (Tersedia di aplikasi desktop.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Build ini tidak memiliki tempat untuk menyimpan model wajah, sehingga daftar model tidak tersedia. (Tersedia di aplikasi desktop.)", + "This file cannot be used yet": "File ini belum dapat digunakan", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Folder ini tidak tersedia (drive terputus?). Pratinjau pintar tidak akan dibuat atau dipakai sampai folder ini tersedia lagi atau Anda memilih folder lain.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Folder ini tidak ditemukan: mungkin sudah dipindahkan atau diganti namanya, atau disknya tidak terhubung.", + "This group is empty: add a rule or remove it.": "Grup ini kosong: tambahkan aturan atau hapus grup ini.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ini katalog Lightroom, bukan pustaka LightCraft. Impor katalog ini ke pustaka LightCraft?", + "This is the album you're editing": "Ini album yang sedang Anda edit", + "This photo can't be opened": "Foto ini tidak dapat dibuka", + "This raw is now developed from its raw data": "File raw ini sekarang diolah dari data raw-nya", + "This raw variant still can't be decoded": "Varian raw ini belum dapat didekode", + "This row covers more than one disk: drop on a folder inside it": "Baris ini mencakup lebih dari satu disk: letakkan di salah satu folder di dalamnya", + "This screen is off: turn it on under Titles to play it.": "Layar ini nonaktif: aktifkan di Judul untuk menampilkannya.", + "Threshold": "Ambang batas", + "Thumbnail cache": "Cache thumbnail", + "Thursday": "Kamis", + "Tick the box to accept the model's terms first": "Centang kotak untuk menyetujui ketentuan model terlebih dahulu", + "Tidying the thumbnail cache": "Merapikan cache thumbnail", + "Time zone": "Zona waktu", + "Tint": "Rona", + "Title": "Judul", + "Title metadata": "Metadata judul", + "Title text": "Teks judul", + "Titles": "Judul", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Untuk menyerahkan file aslinya ke editor ini (raw tanpa salinan, misalnya DxO PhotoLab), klik kanan foto → Edit di → Buka file asli di editor lain (RAW, tanpa salinan).", + "Toggle Keyword from Set": "Pasang/lepas kata kunci dari set", + "Toggle Mask": "Aktifkan/nonaktifkan masker", + "Toggle Section": "Buka/tutup bagian", + "Toggle Zoom": "Alihkan zoom", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Teks lain ditampilkan apa adanya.", + "Tone Curve": "Kurva tone", + "Tools": "Alat", + "Top": "Atas", + "Top Level": "Tingkat teratas", + "Tracking": "Pelacakan", + "Transfer": "Transfer", + "Treatment": "Mode warna", + "Triangle": "Segitiga", + "Try Again": "Coba lagi", + "Try Saving Again": "Coba simpan lagi", + "Try again": "Coba lagi", + "Try the graphics card again": "Coba gunakan kartu grafis lagi", + "Tu": "Sl", + "Tuesday": "Selasa", + "Tungsten": "Tungsten", + "Turn Off": "Nonaktifkan", + "Turn off": "Nonaktifkan", + "Turn on Include subfolders, or pick another folder under Local": "Aktifkan Sertakan subfolder, atau pilih folder lain di Lokal", + "Type": "Jenis", + "Uncheck All": "Hapus semua centang", + "Uncheck the files of this type": "Hapus centang file jenis ini", + "Undo": "Urungkan", + "Unedited": "Belum diedit", + "Unedited raws": "Raw yang belum diedit", + "Unflag": "Hapus tanda", + "Unflagged": "Tanpa tanda", + "Ungroup Stack": "Pisahkan tumpukan", + "Unknown": "Tidak diketahui", + "Unknown Date": "Tanggal tidak diketahui", + "Unknown licence": "Lisensi tidak diketahui", + "Unknown tags stay as typed — check the preview for a {typo}.": "Tag yang tidak dikenal dibiarkan apa adanya — periksa pratinjau untuk memastikan tidak ada {typo}.", + "Unnamed Faces": "Wajah tanpa nama", + "Unrated": "Tanpa rating", + "Unreadable": "Tidak terbaca", + "Update": "Perbarui", + "Update Mask Effect": "Perbarui efek masker", + "Update Mask Shape": "Perbarui bentuk masker", + "Update Preset with Current Settings": "Perbarui preset dengan pengaturan saat ini", + "Update Rules from Current Filter": "Perbarui aturan dari filter saat ini", + "Update Slideshow Template": "Perbarui templat tayangan slide", + "Update Version": "Perbarui versi", + "Update Version with Current Settings": "Perbarui versi dengan pengaturan saat ini", + "Update to Current Process": "Perbarui ke versi proses terbaru", + "Update with Current Settings": "Perbarui dengan pengaturan saat ini", + "Updated the edits saved in the external editor": "Edit yang disimpan di editor eksternal diperbarui", + "Upright": "Tegakkan", + "Urban": "Urban", + "Use": "Gunakan", + "Use Keyword Set": "Gunakan set kata kunci", + "Use Library Folder": "Gunakan folder pustaka", + "Use Screen Profile": "Gunakan profil layar", + "Use camera-specific defaults": "Gunakan pengaturan bawaan khusus kamera", + "Use export watermark": "Gunakan tanda air ekspor", + "Use sRGB": "Gunakan sRGB", + "Use selected photo": "Gunakan foto yang dipilih", + "Use the GPU for rendering": "Gunakan GPU untuk rendering", + "User Presets": "Preset pengguna", + "Username": "Nama pengguna", + "Value": "Nilai", + "Variance": "Varians", + "Version": "Versi", + "Version name": "Nama versi", + "Versions": "Versi", + "Versions (Shift+V)": "Versi (Shift+V)", + "Vertical": "Vertikal", + "Vertical inset (% of short edge)": "Jarak tepi vertikal (% dari sisi pendek)", + "Vertical text": "Teks vertikal", + "Vibrance": "Vibrance", + "Video": "Video", + "Video Duration": "Durasi video", + "Videos": "Video", + "View": "Tampilan", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Penampil yang tidak membaca chunk cICP pada PNG menampilkan nilai PQ sebagai sRGB: datar dan pudar. Agar gambar tampak benar di mana saja, gunakan JPEG (gain map).", + "Vignette": "Vinyet", + "Vignetting": "Vinyet", + "Vintage": "Vintage", + "Virtual Copy": "Salinan virtual", + "Visible": "Terlihat", + "Visualize HDR": "Visualisasikan HDR", + "Visualize Spots": "Visualisasikan bintik", + "Visualize Spots (A)": "Visualisasikan bintik (A)", + "Visualize range": "Visualisasikan rentang", + "Vivid": "Cerah", + "Vivid Pop": "Warna Hidup", + "Wait for the import to finish": "Tunggu hingga impor selesai", + "Waiting to start…": "Menunggu untuk dimulai…", + "Warm Film": "Film Hangat", + "Warm Glow": "Hangat Bercahaya", + "Warm Print": "Cetakan Hangat", + "Warm Table": "Meja Hangat", + "Watched folder": "Folder pantauan", + "Watermark": "Tanda air", + "We": "Rb", + "Wednesday": "Rabu", + "What's New": "Yang baru", + "When rated or hovered": "Saat diberi rating atau saat kursor di atasnya", + "White Balance": "Keseimbangan putih", + "White Balance Selector": "Pemilih keseimbangan putih", + "White Balance Selector (W)": "Pemilih keseimbangan putih (W)", + "White on Black": "Putih di atas hitam", + "Whites": "Putih", + "Whole names": "Nama lengkap", + "Wide": "Lebar", + "Width": "Lebar", + "Width (% of photo)": "Lebar (% dari foto)", + "Width (px)": "Lebar (px)", + "Width × Height": "Lebar × Tinggi", + "Will Export": "Akan diekspor", + "Will be replaced": "Akan diganti", + "Will be restored": "Akan dipulihkan", + "Window": "Jendela", + "Winter Blue": "Biru Musim Dingin", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Berfungsi untuk file raw dari kamera dengan sensor standar (Bayer). File lain tetap memakai pengurangan noise biasa.", + "Write Waiting XMP Sidecars": "Tulis file sidecar XMP yang tertunda", + "Wrote the XMP sidecars that were waiting": "File sidecar XMP yang tertunda telah ditulis", + "XMP Sidecar Preferences": "Preferensi file sidecar XMP", + "XMP sidecars": "File sidecar XMP", + "Year": "Tahun", + "Yellow": "Kuning", + "Yellow Hue": "Hue kuning", + "Yellow Luminance": "Luminans kuning", + "Yellow Saturation": "Saturasi kuning", + "Yellow label": "Label kuning", + "Yes": "Ya", + "You can close this: the download continues, and resumes if interrupted.": "Jendela ini boleh ditutup: unduhan tetap berjalan dan akan dilanjutkan jika terputus.", + "Your Name": "Nama Anda", + "Your library couldn't be opened": "Pustaka Anda tidak dapat dibuka", + "Your library is empty": "Pustaka Anda kosong", + "Your name or studio": "Nama Anda atau nama studio", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Templat Anda sendiri memerlukan folder pengaturan, yang tidak tersedia di build ini; templat bawaan tetap berfungsi.", + "Your templates": "Templat Anda", + "Zone": "Zona", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Perbesar", + "Zoom Out": "Perkecil", + "Zoom to Fit": "Zoom agar pas", + "Zoom to Focus Point": "Zoom ke titik fokus", + "Zoom to fill frame": "Zoom hingga memenuhi bingkai", + "Zoom to page level": "Zoom ke ukuran halaman", + "a literal %": "karakter % biasa", + "a removed photo": "foto yang telah dikeluarkan", + "all": "semua", + "and": "dan", + "any": "salah satu", + "are empty": "kosong", + "aren't empty": "tidak kosong", + "blue": "biru", + "cached picture": "gambar dari cache", + "cached pictures": "gambar dalam cache", + "can't be tested that way": "tidak dapat diuji dengan cara itu", + "can't match any rating from 0 to 5": "tidak cocok dengan rating mana pun dari 0 sampai 5", + "can't test a folder; choose an album in it": "folder tidak dapat dijadikan syarat; pilih album di dalamnya", + "comma, separated": "pisahkan dengan koma", + "commercial use allowed": "penggunaan komersial diizinkan", + "commercial use subject to GPL conditions": "penggunaan komersial tunduk pada ketentuan GPL", + "contains": "berisi", + "copyrighted": "dilindungi hak cipta", + "day 01–31": "tanggal 01–31", + "days": "hari", + "denoiseModel.accept": "denoiseModel.accept", + "discarding smart previews": "membuang pratinjau pintar", + "doesn't contain": "tidak mengandung", + "e.g. sky · the red car · car, road": "contoh (dalam bahasa Inggris): sky · the red car · car, road", + "ends with": "diakhiri dengan", + "false": "tidak", + "green": "hijau", + "has the first date after the second": "tanggal pertama lebih lambat dari tanggal kedua", + "hour 00–23": "jam 00–23", + "hours": "jam", + "image": "gambar", + "in use": "digunakan", + "includes the folders inside it": "termasuk folder di dalamnya", + "installed": "terinstal", + "is": "adalah", + "is <": "bernilai <", + "is >": "bernilai >", + "is after": "setelah", + "is before": "sebelum", + "is between": "di antara", + "is empty": "kosong", + "is in the last": "dalam kurun terakhir", + "is unknown": "tidak diketahui", + "is yes or no": "harus Ya atau Tidak", + "is ≤": "bernilai ≤", + "is ≥": "bernilai ≥", + "isn't": "bukan", + "isn't a field rules know": "bukan kolom yang dikenali aturan", + "isn't empty": "tidak kosong", + "isn't in the last": "di luar kurun terakhir", + "isn't one of the choices": "bukan salah satu pilihan yang tersedia", + "keep names — or e.g. {date}_{seq:3}": "pertahankan nama — atau mis. {date}_{seq:3}", + "latitude, longitude": "lintang, bujur", + "makes AI Denoise available": "mengaktifkan pengurangan noise AI", + "memory only": "hanya di memori", + "minute": "menit", + "month 01–12": "bulan 01–12", + "months": "bulan", + "names an album that no longer exists": "merujuk ke album yang sudah tidak ada", + "needs a date like 2026, 2026-08 or 2026-08-14": "memerlukan tanggal seperti 2026, 2026-08, atau 2026-08-14", + "needs a number": "harus berupa angka", + "needs a number more than 0": "memerlukan angka lebih dari 0", + "needs a time like 1/250 or 2": "harus berupa waktu, misalnya 1/250 atau 2", + "needs an album": "memerlukan album", + "needs hours, days, weeks, months or years": "harus dalam satuan jam, hari, minggu, bulan, atau tahun", + "needs something to look for (or use “are empty”)": "memerlukan teks yang dicari (atau gunakan “kosong”)", + "needs something to look for (or use “aren't empty”)": "memerlukan teks yang dicari (atau gunakan “tidak kosong”)", + "needs something to look for (or use “is empty”)": "memerlukan teks yang dicari (atau gunakan “kosong”)", + "needs something to look for (or use “isn't empty”)": "memerlukan teks yang dicari (atau gunakan “tidak kosong”)", + "needs text": "memerlukan teks", + "needs two values": "harus berisi dua nilai", + "new keyword": "kata kunci baru", + "non-commercial use only": "hanya untuk penggunaan nonkomersial", + "none": "tidak ada", + "none (CPU rendering)": "tidak ada (rendering CPU)", + "none of": "tidak satu pun dari", + "not added to Apple Photos (cancelled)": "tidak ditambahkan ke Apple Photos (dibatalkan)", + "not available here": "tidak tersedia di sini", + "of the following rules:": "aturan berikut:", + "or": "atau", + "or drop a .onnx file on the window": "atau seret file .onnx ke jendela ini", + "or drop photos and folders into this window": "atau seret foto dan folder ke jendela ini", + "or drop photos into this window": "atau seret foto ke jendela ini", + "photo has its denoised picture.": "foto sudah memiliki gambar hasil pengurangan noise.", + "photo waiting": "foto dalam antrean", + "photos have their denoised picture.": "foto sudah memiliki hasil pengurangan noise.", + "photos waiting": "foto dalam antrean", + "pick": "pilihan", + "previews": "pratinjau", + "publicDomain": "domain publik", + "purple": "ungu", + "raw": "raw", + "red": "merah", + "reject": "ditolak", + "saved in": "disimpan di", + "second": "detik", + "smart album value|No": "Tidak", + "smart album value|Yes": "Ya", + "smart previews": "pratinjau pintar", + "starts using it for AI Denoise": "mulai menggunakannya untuk pengurangan noise AI", + "starts with": "diawali dengan", + "terms unclear": "ketentuan tidak jelas", + "the graphics card": "kartu grafis", + "the graphics card is not used": "kartu grafis tidak digunakan", + "the photos in view": "foto yang sedang ditampilkan", + "the selected photos": "foto yang dipilih", + "this photo": "foto ini", + "this screen's profile": "profil layar ini", + "true": "ya", + "unknown": "tidak diketahui", + "video": "video", + "weeks": "minggu", + "would make this album include itself through other smart albums": "akan membuat album ini memuat dirinya sendiri melalui album pintar lain", + "year, 2 digits": "tahun, 2 digit", + "year, 4 digits": "tahun, 4 digit", + "years": "tahun", + "yellow": "kuning", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} adalah waktu pengambilan; foto tanpa waktu pengambilan memakai waktu impornya.", + "© 2026 Your Name": "© 2026 Nama Anda", + "© Your Name": "© Nama Anda", + "− harder border · + softer border": "− tepi lebih tegas · + tepi lebih lembut", + "≤ at most": "≤ maksimal", + "≥ at least": "≥ minimal", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 8edab510a..ccd01e6a7 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -245,55 +245,57 @@ "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "{name} opened in the other editor": "{name} opened in the other editor", - "{s} s of AV1 video": "{s} s of AV1 video", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "{count} more warnings.": "{count} more warnings.", - "· {in_source} identical copies": "· {in_source} identical copies", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "{n} preset{}": "{n} preset{}", - " — kept but not applied: {}": " — kept but not applied: {}", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "Saved template {name}": "Saved template {name}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "Delete “{name}”?": "Delete “{name}”?", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "new, inside {parent}": "new, inside {parent}", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Originali mancanti: {missing}. I loro dati nel catalogo sono disponibili; usa Trova foto mancanti per ricollegarli.", + "{name} opened in the other editor": "{name} aperto nell'altro editor", + "{s} s of AV1 video": "{s} s di video AV1", + "Delete {n} effect preset{}? This can't be undone.": "Eliminare i preset effetto ({n})?{:.0} L'operazione non si può annullare.", + "{count} more warnings.": "Altri avvisi: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} immagini del catalogo non sono state importate perché i loro record nel catalogo sono incompleti.", + "· {in_source} identical copies": "· copie identiche: {in_source}", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Unione {what} aggiunta a {mp}, la dimensione massima di un panorama (la dimensione piena è {full})", + "{n} preset{}": "Preset: {n}{:.0}", + " — kept but not applied: {}": " — conservati ma non applicati: {}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Unione {what} aggiunta a {mp}: la dimensione piena, {full}, richiede più memoria di quella disponibile su questo computer", + "Saved template {name}": "Modello {name} salvato", + "LightCraft stopped unexpectedly: {e}": "LightCraft si è chiuso in modo imprevisto: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Rinomina i volti in tutta la libreria ({n}){:.0}. I file non vengono modificati.", + " · {n} skipped (could not be rendered)": " · saltate: {n} (non è stato possibile eseguire il rendering)", + "Delete “{name}”?": "Eliminare “{name}”?", + "Keywords of {n} selected photos": "Parole chiave delle foto selezionate: {n}", + "new, inside {parent}": "nuova, dentro {parent}", + "Delete these {n} effect presets?": "Eliminare questi preset effetto ({n})?", "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "Skipped {problem}": "Skipped {problem}", - "Updated {name}": "Updated {name}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "Slideshow exported: {what}": "Slideshow exported: {what}", - "Exported to {path}": "Exported to {path}", - "…and {more} more": "…and {more} more", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "{n} JPEG files": "{n} JPEG files", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {name}": "Imported {name}", - "{n} pages": "{n} pages", - " · kept, not applied: {}": " · kept, not applied: {}", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "{photos} catalog records processed; {collections} collections.": "Record del catalogo elaborati: {photos}; raccolte: {collections}.", + "Skipped {problem}": "Saltato: {problem}", + "Updated {name}": "{name} aggiornato", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Non è stato possibile scrivere i file XMP di alcune foto ({n}): la loro cartella è scollegata? LightCraft continua a riprovare.", + "Apply to “{mask}” on Selected Photos": "Applica a “{mask}” sulle foto selezionate", + "Update “{name}” with Current Settings": "Aggiorna “{name}” con le impostazioni attuali", + "Slideshow exported: {what}": "Presentazione esportata: {what}", + "Exported to {path}": "Esportato in {path}", + "…and {more} more": "…e altri {more}", + "Create the keyword “{name}”": "Crea la parola chiave “{name}”", + "{n} JPEG files": "File JPEG: {n}", + "Exported {} effect preset{}": "Preset effetto esportati: {}{:.0}", + "Imported {name}": "{name} importato", + "{n} pages": "Pagine: {n}", + " · kept, not applied: {}": " · conservato, non applicato: {}", + "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.", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "Applied {name}": "Applied {name}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{n} originals opened in the other editor": "{n} originals opened in the other editor", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "Effect: {name}": "Effect: {name}", - " in {g} group{}": " in {g} group{}", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", - "Already in “{}”": "Already in “{}”", - "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", - "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." + "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", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” è già una persona. Unirle ne crea una sola; Annulla le separa di nuovo.", + "{n} originals opened in the other editor": "Originali aperti nell'altro editor: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft si è chiuso in modo imprevisto: {e}\n\nI dettagli sono nel file di log: {path}", + "Effect: {name}": "Effetto: {name}", + " in {g} group{}": " in gruppi: {g}{:.0}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Spazio colore: Rec. 2020 {curve} (AVIF HDR)", + "{n} selected photos · {pages} pages": "Foto selezionate: {n} · pagine: {pages}", + "Already in “{}”": "Già in “{}”", + "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}" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 382fb80c9..759fe53d0 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -8,11 +8,13 @@ "+ Rule": "+ Regola", "0: nothing is rejected.": "0: non viene scartato niente.", "1 day": "1 giorno", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · modifiche attuali · adatta senza ritagliare", "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", "= exactly": "= esattamente", "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", "A one-time download of about": "Un download unico di circa", + "A pen's pressure sets the size and flow of the brush": "La pressione della penna regola dimensione e flusso del pennello", "AI Mask Model Status": "Stato del modello IA per le maschere", "About": "Informazioni", "About LightCraft": "Informazioni su LightCraft", @@ -38,6 +40,9 @@ "Add to Favorites": "Aggiungi ai preferiti", "Add to Local": "Aggiungi a Locale", "Add to My Photos": "Aggiungi a Le mie foto", + "Can't be moved here": "Non si possono spostare qui", + "This row covers more than one disk: drop on a folder inside it": "Questa riga comprende più di un disco: rilascia su una cartella al suo interno", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Le foto locali non sono ancora nella libreria: prima Aggiungi a Le mie foto, poi spostale", "Add to Selected Photos": "Aggiungi alle foto selezionate", "Add to Target Album": "Aggiungi all'album di destinazione", "Add to this mask": "Aggiungi a questa maschera", @@ -153,7 +158,7 @@ "Blue channel": "Canale blu", "Blue label": "Etichetta blu", "Blue/Yellow Fringe": "Frange blu/gialle", - "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Blurry photos below the score are flagged as rejects.": "Le foto sfocate sotto il punteggio vengono contrassegnate come scartate.", "Boundary Warp": "Deformazione dei bordi", "Bright & Airy": "Luminoso e arioso", "Bring All to Front": "Porta tutto in primo piano", @@ -198,6 +203,7 @@ "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", "Choose Folder…": "Scegli cartella…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Scegli dove salvare il PDF dopo la conferma. Stampa il PDF dal tuo visualizzatore.", "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", "Choose…": "Scegli…", "Cinematic": "Cinematografico", @@ -248,6 +254,9 @@ "Color Range": "Intervallo colori", "Color label cleared": "Etichetta colore tolta", "Color space": "Spazio colore", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Spazio colore: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Spazio colore: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Spazio colore: Rec. 2020 PQ, 16 bit (PNG HDR)", "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", "Colors": "Colori", "Combine files…": "Combina file…", @@ -264,7 +273,14 @@ "Concrete Cool": "Cemento freddo", "Confirm before deleting": "Chiedi conferma prima di eliminare", "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Move Photos": "Sposta foto", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "I loro file e i file XMP collegati vengono spostati sul disco. Modifica → Annulla li riporta indietro.", + "Don't ask again": "Non chiedere più", + "Another library was opened since the photos were dropped: nothing was moved": "Dopo il trascinamento delle foto è stata aperta un'altra libreria: non è stato spostato nulla", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Impostazioni → Chiedi conferma prima di spostare le foto in un'altra cartella sul disco la riattiva", + "Confirm before moving photos to another folder on disk": "Chiedi conferma prima di spostare le foto in un'altra cartella sul disco", "Constrain Crop": "Vincola il ritaglio", + "Contact Sheet PDF": "PDF provino a contatto", "Continue": "Continua", "Continue Without Saving": "Continua senza salvare", "Contrast": "Contrasto", @@ -389,6 +405,7 @@ "Detail (D)": "Dettaglio (D)", "Details": "Dettagli", "Diagonal": "Diagonale", + "Dimensions": "Dimensioni", "Discard Preview Cache": "Elimina cache delle anteprime", "Display P3": "Display P3", "Display name": "Nome visualizzato", @@ -457,7 +474,7 @@ "Export Now": "Esporta ora", "Export Point Curve Presets": "Esporta preset di curva a punti", "Export Point Curve Presets…": "Esporta preset di curva a punti…", - "Export Presets": "Esporta predefiniti", + "Export Presets": "Esporta preset", "Export Presets…": "Esporta preset…", "Export User Presets…": "Esporta preset utente…", "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", @@ -465,7 +482,7 @@ "Export with Previous": "Esporta con le impostazioni precedenti", "Exporting": "Esportazione in corso", "Export…": "Esporta…", - "Exposure": "Tempo di esposizione", + "Exposure": "Esposizione", "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", "Extended Description": "Descrizione estesa", "External Editor": "Editor esterno", @@ -489,6 +506,7 @@ "File name": "Nome file", "File types:": "Tipi di file:", "Filename": "Nome file", + "Filename captions": "Didascalie con il nome del file", "Fill": "Riempi", "Fill Edges": "Riempi i bordi", "Film": "Pellicola", @@ -548,6 +566,10 @@ "Friday": "Venerdì", "Full": "Completo", "Full Screen Preview": "Anteprima a schermo intero", + "Hold View Until Refreshed": "Mantieni la vista fino all'aggiornamento", + "HDR TIFF needs 32-bit float.": "Il TIFF HDR richiede virgola mobile a 32 bit.", + "HDR output": "Uscita HDR", + "Letter": "Lettera", "Lights Out": "Luci spente", "Full screen mode": "Modalità schermo intero", "Full size": "Dimensione piena", @@ -603,6 +625,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "Panorami HDR", "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR for Each Bracket Set": "HDR per ogni serie a forcella", "HDR…": "HDR…", "Has Edits": "Con modifiche", "Has GPS": "Con GPS", @@ -646,7 +669,7 @@ "Import Photos…": "Importa foto…", "Import Point Curve Presets": "Importa preset di curva a punti", "Import Point Curve Presets…": "Importa preset di curva a punti…", - "Import Presets": "Importa predefiniti", + "Import Presets": "Importa preset", "Import Presets…": "Importa preset…", "Import Profiles": "Importa profili", "Import Profiles & Presets…": "Importa profili e preset…", @@ -764,6 +787,7 @@ "Local folders": "Cartelle locali", "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", "Locate Missing File…": "Trova file mancante…", + "Locate Missing Files…": "Individua file mancanti…", "Locate Photo": "Trova foto", "Location": "Posizione", "Lock": "Blocca", @@ -900,7 +924,12 @@ "No edits yet.": "Ancora nessuna modifica.", "No exposure information": "Nessuna informazione di esposizione", "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file.": "Questo file non contiene dati dell'obiettivo.", "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "Export to": "Esporta in", + "Specific folder": "Cartella specifica", + "Same folder as original photo": "Stessa cartella della foto originale", + "Tidying the thumbnail cache": "Riordino della cache delle miniature", "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", "No matching commands": "Nessun comando corrispondente", "No matching photos": "Nessuna foto corrispondente", @@ -911,6 +940,11 @@ "No new presets": "Nessun nuovo preset", "No photo selected": "Nessuna foto selezionata", "No photos": "Nessuna foto", + "Your library is empty": "La tua libreria è vuota", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Le foto che importi compaiono qui. Nulla viene copiato o spostato, a meno che tu non lo scelga.", + "or drop photos and folders into this window": "oppure trascina foto e cartelle in questa finestra", + "Import photos to get started": "Importa delle foto per iniziare", + "or drop photos into this window": "oppure trascina foto in questa finestra", "No photos found": "Nessuna foto trovata", "No photos in this folder": "Nessuna foto in questa cartella", "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", @@ -986,6 +1020,7 @@ "Paste from Previous": "Incolla dalla precedente", "Paste pages": "Incolla pagine", "Pastel Haze": "Foschia pastello", + "Pen Pressure": "Pressione della penna", "People": "Persone", "Percent": "Percentuale", "Percentage": "Percentuale", @@ -1000,6 +1035,7 @@ "Photo Merge": "Unione di foto", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", "Photos": "Foto", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Foto modificate in HDR: JPEG con gain map HDR (si vede bene ovunque, luci più brillanti sugli schermi HDR), AVIF a 10 bit, PNG e JPEG XL a 16 bit Rec. 2020 PQ; il TIFF richiede virgola mobile a 32 bit", "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", @@ -1020,9 +1056,9 @@ "Preferences": "Preferenze", "Preferences…": "Preferenze…", "Prepare AI Masks": "Prepara le maschere IA", - "Preset": "Predefinito", + "Preset": "Preset", "Preset name": "Nome del preset", - "Presets": "Predefiniti", + "Presets": "Preset", "Presets & Profiles": "Preset e profili", "Presets (Shift+P)": "Preset (Maiusc+P)", "Presets and profiles": "Preset e profili", @@ -1123,6 +1159,7 @@ "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", "Refresh Source": "Aggiorna l'origine", "Refresh Source (/)": "Aggiorna l'origine (/)", + "Refresh View": "Aggiorna vista", "Reject": "Scarta", "Reject below focus": "Scarta sotto la soglia di messa a fuoco", "Rejected": "Rifiutato", @@ -1188,7 +1225,30 @@ "Reset Section": "Reimposta sezione", "Reset Tone Curve": "Reimposta curva dei toni", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits": "Ripristina tutte le modifiche", "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", + "Wide": "Ampio", + "Center": "Centro", + "Flexible Spot": "Spot flessibile", + "Zone": "Zona", + "Expanded Flexible Spot": "Spot flessibile espanso", + "Custom AF Area": "Area AF personalizzata", + "Face tracking": "Tracciamento volti", + "Lock On AF": "AF con aggancio", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracciamento", + "Face Tracking": "Tracciamento volti", + "Animal Eye Tracking": "Tracciamento occhi degli animali", + "Human Eye Tracking": "Tracciamento occhi umani", + "Read Focus Points": "Leggi punti di messa a fuoco", + "Reading focus points": "Lettura dei punti di messa a fuoco", "Reshuffle": "Rimescola", "Resize Face Box": "Ridimensiona riquadro del volto", "Resize panel window": "Ridimensiona la finestra del pannello", @@ -1314,7 +1374,7 @@ "Shadows Tint": "Tinta delle ombre", "Shadows split": "Divisione delle ombre", "Sharpen": "Nitidezza", - "Sharpening": "Contrasto", + "Sharpening": "Nitidezza", "Sharpness": "Nitidezza", "Shift": "Maiusc", "Shift by": "Sposta di", @@ -1327,6 +1387,8 @@ "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", @@ -1341,10 +1403,12 @@ "Show in Explorer": "Mostra in Esplora file", "Show in File Manager": "Mostra nel gestore file", "Show in Finder": "Mostra nel Finder", + "Show in Folders": "Mostra in Cartelle", "Show on Map": "Mostra sulla mappa", "Show overlay (O)": "Mostra sovrapposizione (O)", "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the import review when a camera or card is connected": "Mostra la revisione dell'importazione quando si collega una fotocamera o una scheda", "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", "Show/Hide Mask": "Mostra/nascondi maschera", "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", @@ -1372,6 +1436,7 @@ "Soft Pastel": "Pastello morbido", "Soft Proofing": "Prova colore a video", "Soft Skin": "Pelle morbida", + "Solo Mode": "Un pannello alla volta", "Sort": "Ordina", "Sort by": "Ordina per", "Source": "Origine", @@ -1518,6 +1583,7 @@ "Vibrance": "Vividezza", "Videos": "Video", "View": "Visualizza", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "I visualizzatori che non leggono il blocco cICP del PNG mostrano i valori PQ come sRGB: piatti e slavati. Per un'immagine che appaia corretta ovunque, usa JPEG (gain map).", "Viewing and sharing": "Visione e condivisione", "Vignette": "Vignettatura", "Vignetting": "Vignettatura", @@ -1545,11 +1611,12 @@ "Width": "Larghezza", "Width (% of photo)": "Larghezza (% della foto)", "Width (px)": "Larghezza (px)", - "Width × Height": "Larghezza × altezza", + "Width & Height": "Larghezza & altezza", "Will be replaced": "Verrà sostituita", "Will be restored": "Verrà ripristinata", "Window": "Finestra", "Winter Blue": "Blu d'inverno", + "Wrote the XMP sidecars that were waiting": "Scritti i file XMP che erano in attesa", "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", "XMP sidecars": "File XMP collaterali", "Year": "Anno", @@ -1716,6 +1783,8 @@ "cached picture": "immagine in cache", "cached pictures": "immagini in cache", "commercial use allowed": "uso commerciale consentito", + "Reading photos…": "Lettura delle foto…", + "Adding photos…": "Aggiunta delle foto…", "denoiseModel.accept": "denoiseModel.accept", "in use": "in uso", "installed": "installato", @@ -1751,6 +1820,95 @@ "Decrease Rating": "Diminuisci valutazione", "Increase Rating": "Aumenta valutazione", "Click to type a value": "Fai clic per scrivere un valore", + "Run face models on the CPU only": "Esegui i modelli dei volti solo sulla CPU", + "Detection": "Rilevamento", + "Recognition": "Riconoscimento", + "Not loaded yet": "Non ancora caricato", + "Checking the GPU…": "Controllo della GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modello per i volti usa la GPU solo quando un controllo mostra che dà gli stessi risultati più velocemente.", + "Checking the GPU for faces": "Controllo della GPU per i volti", + "Include selected photos": "Includi le foto selezionate", + "Choose Profile…": "Scegli profilo…", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Modifica in HDR: luci oltre il bianco SDR, per esportazioni e schermi HDR", + "Edit in Other Editor": "Modifica nell'altro editor", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Modifica nell'altro editor (⌥⌘E) apre la copia in questa applicazione (il nome di un'app su macOS, il percorso di un programma altrove).", + "Edit in PhotoCraft": "Modifica in PhotoCraft", + "External Editing": "Modifica esterna", + "Found automatically": "Trovato automaticamente", + "HDR": "HDR", + "LUT-based": "Basato su LUT", + "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.": "A sinistra: la gamma standard (SDR) fino al bianco SDR. A destra: la gamma HDR, una fascia per ogni stop sopra il bianco SDR (la linea arancione: il margine HDR). Con Visualizza gamma HDR attivo, le fasce ne prendono i colori.", + "Matrix/TRC": "Matrice/TRC", + "Monitor profile": "Profilo monitor", + "None (the display is treated as sRGB)": "Nessuno (lo schermo viene trattato come sRGB)", + "Other editor": "Altro editor", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Foto → Modifica in → Modifica in PhotoCraft (⇧⌘E, o clic destro su una foto) esegue il rendering di una copia con le tue modifiche, la impila con l'originale e la apre in PhotoCraft. Salva lì e la copia si aggiorna qui. Come PSD, PhotoCraft salva direttamente su di essa. Lascia vuota la posizione a meno che tu non usi una copia portatile.", + "PhotoCraft": "PhotoCraft", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Le foto vengono mostrate attraverso il profilo ICC del monitor, così i colori sono corretti su schermi calibrati e a gamut esteso, e la vista Dettaglio usa tutto il gamut dello schermo. Istogramma, cache delle anteprime ed esportazioni non cambiano.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Le foto restano in Tutte le foto. Eliminando una cartella si eliminano anche i suoi album. Puoi annullare l'operazione.", + "Primaries (x, y)": "Primari (x, y)", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "I profili assegnati nelle impostazioni colore del desktop di solito sono in ~/.local/share/icc.", + "Progressive": "Progressivo", + "SDR": "SDR", + "Use Screen Profile": "Usa profilo dello schermo", + "Use sRGB": "Usa sRGB", + "this screen's profile": "il profilo di questo schermo", + "Show photos": "Mostra foto", + "Their photos in the grid": "Le loro foto nella griglia", + "Click to name this face": "Fai clic per dare un nome a questo volto", + "Apple Photos Imports": "Importazioni da Foto di Apple", + "Background Tasks": "Attività in background", + "Cancel Background Task": "Annulla attività in background", + "Contact Sheet PDF…": "PDF provino a contatto…", + "Create Keyword": "Crea parola chiave", + "Display": "Schermo", + "Display Profile": "Profilo dello schermo", + "Edit Keyword": "Modifica parola chiave", + "Export Contact Sheet PDF": "Esporta PDF provino a contatto", + "Find Blemishes": "Trova imperfezioni", + "Keyword Info": "Informazioni parola chiave", + "Move Keyword": "Sposta parola chiave", + "Put New Keywords Inside": "Metti dentro le nuove parole chiave", + "Scan Folder for Changes": "Cerca modifiche nella cartella", + "Set Folder Color Label": "Imposta etichetta colore cartella", + "Remove Folder Color Label": "Rimuovi etichetta colore cartella", + "Show in Compare": "Mostra in Confronto", + "Update to Current Process": "Aggiorna al processo attuale", + "HDR Headroom": "Margine HDR", + "SDR Brightness": "Luminosità SDR", + "SDR Clarity": "Chiarezza SDR", + "SDR Contrast": "Contrasto SDR", + "SDR Highlights": "Luci SDR", + "SDR Shadows": "Ombre SDR", + "SDR Whites": "Bianchi SDR", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Spazio colore: Rec. 2020 HLG (AVIF HDR)", + "or drop a .onnx file on the window": "oppure trascina un file .onnx sulla finestra", + "Already installed. Installing again is harmless.": "Già installato. Installarlo di nuovo non crea problemi.", + "Enter to confirm, Esc to cancel": "Invio per confermare, Esc per annullare", + "Add a model file…": "Aggiungi un file di modello…", + "Open Settings": "Apri impostazioni", + "Select none": "Deseleziona tutto", + "Select all": "Seleziona tutto", + "Visualize HDR": "Visualizza HDR", + "Add a Face Recognition Model": "Aggiungi un modello di riconoscimento dei volti", + "Choose Monitor Profile": "Scegli profilo monitor", + "Display profile not used": "Profilo dello schermo non usato", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Trascina per impostare tonalità e saturazione. Tieni premuto Maiusc per mantenere la tonalità, Cmd/Ctrl per mantenere la saturazione, Alt/Opzione per una regolazione fine. Doppio clic per ripristinare.", + "Edit In": "Modifica in", + "Face models (ONNX)": "Modelli dei volti (ONNX)", + "ICC Profiles": "Profili ICC", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100 e R8. I file CR3 che il decoder non legge ancora si aprono comunque dal JPEG incorporato, come prima.", + "Next History Step": "Passo successivo della cronologia", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "I NEF Nikon ad alta efficienza (HE / HE★) usano la compressione intoPIX TicoRAW. LightCraft al momento non ha una licenza per questo codec, quindi è disponibile solo l'anteprima JPEG incorporata dalla fotocamera, non i dati raw del sensore. Per modificare i dati raw, scatta in NEF compresso senza perdita oppure converti i file HE esistenti in DNG con un convertitore dotato di licenza.", + "Open Original in Other Editor (RAW, no copy)": "Apri l'originale nell'altro editor (RAW, senza copia)", + "Photo Above": "Foto sopra", + "Photo Below": "Foto sotto", + "Previous History Step": "Passo precedente della cronologia", + "Select Only Active Photo": "Seleziona solo la foto attiva", + "Show this wheel's effect": "Mostra l'effetto di questa ruota", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Regolazione mirata attiva: trascina in su o in giù sulla foto · Esc per terminare", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Per passare invece a questo editor il file originale (il raw, senza copia: ad esempio DxO PhotoLab), fai clic destro su una foto → Modifica in → Apri l'originale nell'altro editor (RAW, senza copia).", "Angle": "Angolo", "Video": "Video", "Copyrighted": "Protetta da copyright", @@ -1814,6 +1972,15 @@ "Keyword List": "Elenco parole chiave", "Filter Keywords": "Filtra parole chiave", "No keywords yet": "Ancora nessuna parola chiave", + "Show fewer": "Mostra meno", + "Whole names": "Nomi interi", + "Grouped by parent": "Raggruppate per parola chiave superiore", + "Keywords of the selection": "Parole chiave della selezione", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Raggruppate: le parole chiave dentro la stessa parola chiave condividono una riga col suo nome, e gli elenchi lunghi e i suggerimenti vengono accorciati.", + "Show every keyword of the selected photos": "Mostra tutte le parole chiave delle foto selezionate", + "new keyword": "nuova parola chiave", + "Return creates this keyword and gives it to the selected photos": "Invio crea questa parola chiave e la assegna alle foto selezionate", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separa più parole chiave con le virgole. Invio le assegna alle foto selezionate.", "No keywords match": "Nessuna parola chiave corrisponde", "Keyword Name": "Nome della parola chiave", "Synonyms": "Sinonimi", @@ -1953,6 +2120,111 @@ "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", "Adding to Apple Photos": "Aggiunta a Foto di Apple", "Add Face Model…": "Aggiungi modello dei volti…", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nessun processore grafico trovato: LightCraft esegue il rendering sulla CPU, molto più lentamente. Dettagli in Aiuto → Info sistema.", + "On NixOS, start it from `nix develop`.": "Su NixOS, avvialo da `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "File con lo stesso contenuto di un altro file di questa importazione: viene importato solo il primo.", + "Lightroom Catalog Import": "Importazione catalogo di Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Queste foto sono solo in memoria. Apri una libreria su disco e importa di nuovo il catalogo per salvarle.", + "Show in Library": "Mostra nella libreria", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "I cataloghi di Lightroom vengono importati nella libreria aperta; non viene creata una cartella di catalogo separata.", + "Destination library": "Libreria di destinazione", + "Playback": "Riproduzione", + "Layout": "Layout", + "Link all margins": "Collega tutti i margini", + "Margins": "Margini", + "Left": "Sinistra", + "Right": "Destra", + "Top": "In alto", + "Bottom": "In basso", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "I margini sono una frazione del lato corto della diapositiva, così le diapositive appaiono uguali su qualsiasi schermo.", + "Show guides": "Mostra guide", + "Aspect preview": "Anteprima proporzioni", + "Zoom to fill frame": "Ingrandisci per riempire la cornice", + "Stroke border": "Bordo a tratto", + "Colour": "Colore", + "Cast shadow": "Ombra portata", + "Offset": "Spostamento", + "Backdrop": "Fondale", + "Background colour": "Colore di sfondo", + "Colour wash": "Velatura di colore", + "Background image": "Immagine di sfondo", + "(not in the library)": "(non nella libreria)", + "Use selected photo": "Usa la foto selezionata", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Seleziona una foto, poi attiva questa opzione per riempire con essa lo sfondo dietro ogni foto.", + "Reset layout": "Ripristina layout", + "Select a photo to preview its slide": "Seleziona una foto per vedere l'anteprima della sua diapositiva", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto selezionata come diapositiva. La riproduzione riempie lo schermo; l'anteprima delle proporzioni cambia solo questa anteprima.", + "Overlays": "Sovrapposizioni", + "Identity plate": "Targa di identità", + "Your name or studio": "Il tuo nome o studio", + "Anchored to": "Ancorato a", + "Slide": "Diapositiva", + "Render behind image": "Disegna dietro l'immagine", + "Rating stars": "Stelle di valutazione", + "Text overlays": "Testi sovrapposti", + "Add text overlay": "Aggiungi testo sovrapposto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Segnaposto: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Il resto del testo appare così come lo scrivi.", + "Use export watermark": "Usa la filigrana di esportazione", + "The export watermark, drawn on the photo and sized relative to it.": "La filigrana di esportazione, disegnata sulla foto e dimensionata rispetto a essa.", + "Titles": "Titoli", + "Intro screen": "Schermata iniziale", + "Ending screen": "Schermata finale", + "Shows": "Mostra", + "Colour only": "Solo colore", + "Override colour": "Sostituisci colore", + "Own duration (off: the slide duration)": "Durata propria (disattivata: la durata della diapositiva)", + "Duration": "Durata", + "Title text": "Testo del titolo", + "Intro": "Apertura", + "Ending": "Chiusura", + "End": "Fine", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'apertura viene riprodotta una volta prima della prima foto; la chiusura dopo l'ultima foto, quando Ripeti è disattivato. La targa di identità è quella impostata in Sovrapposizioni. I titoli si dissolvono come le diapositive e l'ordine casuale non li sposta mai.", + "This screen is off: turn it on under Titles to play it.": "Questa schermata è disattivata: attivala in Titoli per riprodurla.", + "The intro screen, before the first photo.": "La schermata iniziale, prima della prima foto.", + "The ending screen, after the last photo.": "La schermata finale, dopo l'ultima foto.", + "Templates": "Modelli", + "Built-in": "Integrati", + "Your templates": "I tuoi modelli", + "None yet: save the current settings as a template below.": "Ancora nessuno: salva le impostazioni attuali come modello qui sotto.", + "Before the last template": "Prima dell'ultimo modello", + "Previous settings": "Impostazioni precedenti", + "New template name": "Nome del nuovo modello", + "Save as Template": "Salva come modello", + "Import…": "Importa…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "I tuoi modelli richiedono una cartella delle impostazioni, che questa build non ha; quelli integrati funzionano.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Fai clic su un modello per applicarlo; passaci sopra per vederne l'anteprima sulla foto selezionata. Fai clic destro su uno dei tuoi per aggiornarlo, rinominarlo, esportarlo o eliminarlo.", + "Import Slideshow Templates": "Importa modelli di presentazione", + "Export Slideshow Template": "Esporta modello di presentazione", + "Slideshow Templates": "Modelli di presentazione", + "Plain Black": "Nero pieno", + "Gallery Wall": "Parete da galleria", + "Edge to Edge": "Da bordo a bordo", + "Cinema Wide": "Cinema panoramico", + "Shooting Data": "Dati di scatto", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Ogni foto il più grande possibile, su nero, con una breve dissolvenza incrociata. Nulla in sovrimpressione.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una parete grigio chiaro con passe-partout bianco e un'ombra morbida; didascalia e valutazione sotto la foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Ogni foto ritagliata per riempire tutto lo schermo, con dissolvenze incrociate lente.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un'inquadratura 16:9 con bande nere sopra e sotto, il titolo nell'angolo, dissolvenze al nero e un cartello finale.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Uno sfondo ardesia scuro con esposizione, ISO e lunghezza focale, poi fotocamera e obiettivo, sotto ogni foto.", + "Exporting slideshow": "Esportazione della presentazione", + "Export Slideshow…": "Esporta presentazione…", + "Export Slideshow": "Esporta presentazione", + "Export Slideshow: folder for the JPEG files": "Esporta presentazione: cartella per i file JPEG", + "Frame rate": "Fotogrammi al secondo", + "Container": "Contenitore", + "Export PDF…": "Esporta PDF…", + "Export JPEG…": "Esporta JPEG…", + "Export Video…": "Esporta video…", + "Automatically show full screen": "Mostra automaticamente a schermo intero", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una pagina per diapositiva (il lato lungo in pixel = punti), con la durata e la dissolvenza della diapositiva per la modalità presentazione del visualizzatore.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "File JPEG numerati (nome-001.jpg…) in una nuova cartella, il cui nome si sceglie al passo successivo.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, ogni diapositiva per la sua durata con le sue dissolvenze. Senza audio: la musica non viene ancora esportata.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Le diapositive usano layout, sovrapposizioni e schermate iniziale e finale della presentazione, nell'ordine della vista.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Le foto nella vista come diapositive, con le impostazioni qui sopra. L'avanzamento compare nella pila delle attività.", + "Checking raw files": "Controllo dei file raw", + "1 raw can now be developed from its raw data": "1 raw ora può essere sviluppato dai suoi dati raw", + "This raw is now developed from its raw data": "Questo raw ora viene sviluppato dai suoi dati raw", + "This raw variant still can't be decoded": "Questa variante raw non si può ancora decodificare", "Cancel Face Model Download": "Annulla il download del modello dei volti", "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", @@ -1972,6 +2244,7 @@ "Go back to listing them by name": "Torna all'elenco per nome", "Index Faces": "Indicizza i volti", "Index Faces in the Background": "Indicizza i volti in background", + "Click to type a value, drag to change it": "Fai clic per digitare un valore, trascina per cambiarlo", "Inspect Face Model File": "Esamina il file del modello dei volti", "Install Face Model": "Installa il modello dei volti", "Library Folders": "Cartelle della libreria", @@ -1990,5 +2263,87 @@ "Test Face Model": "Testa il modello dei volti", "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", - "Unnamed Faces": "Volti senza nome" + "Unnamed Faces": "Volti senza nome", + "smart album value|No": "No", + "smart album value|Yes": "Sì", + "Add Print Cell": "Aggiungi cella di stampa", + "Apply Mask Effect": "Applica effetto maschera", + "Apply Print Template": "Applica modello di stampa", + "Apply Slideshow Template": "Applica modello di presentazione", + "Arrange Print Cell": "Disponi cella di stampa", + "Auto Layout": "Layout automatico", + "Choose Layout Image…": "Scegli immagine di layout…", + "Clear Print Layout": "Cancella layout di stampa", + "Cycle Flag": "Alterna bandiera", + "Cycle Tool Overlay": "Alterna sovrapposizione strumento", + "Decrease Flag Status": "Riduci stato bandiera", + "Decrease Grid Thumbnail Size": "Riduci dimensione miniature griglia", + "Delete Mask Effect": "Elimina effetto maschera", + "Delete Print Cell": "Elimina cella di stampa", + "Delete Print Template": "Elimina modello di stampa", + "Delete Slideshow Template": "Elimina modello di presentazione", + "Duplicate Mask Effect": "Duplica effetto maschera", + "End Slideshow": "Termina presentazione", + "Export Effect Presets…": "Esporta preset effetti…", + "Export Mask Effects": "Esporta effetti maschera", + "Face Models on the GPU": "Modelli dei volti sulla GPU", + "Favorite Mask Effect": "Effetto maschera preferito", + "Import Mask Effects": "Importa effetti maschera", + "Import Slideshow Template": "Importa modello di presentazione", + "Increase Flag Status": "Aumenta stato bandiera", + "Increase Grid Thumbnail Size": "Aumenta dimensione miniature griglia", + "Layout Image Matte": "Passe-partout immagine di layout", + "Layout Image Opacity": "Opacità immagine di layout", + "Manage Effect Presets…": "Gestisci preset effetti…", + "Mask Effect Presets": "Preset effetti maschera", + "Move Mask Effect to Group": "Sposta effetto maschera nel gruppo", + "Move Print Cell": "Sposta cella di stampa", + "Move Print Cell to Page": "Sposta cella di stampa nella pagina", + "New Print Page": "Nuova pagina di stampa", + "Next Slide": "Diapositiva successiva", + "Pause / Resume Slideshow": "Pausa / riprendi presentazione", + "Previous Slide": "Diapositiva precedente", + "Print Settings": "Impostazioni di stampa", + "Print Templates": "Modelli di stampa", + "Print to PDF": "Stampa su PDF", + "Read Focus Points in the Background": "Leggi punti di messa a fuoco in background", + "Recheck Preview-Only Raws": "Ricontrolla i raw con sola anteprima", + "Rename Mask Effect": "Rinomina effetto maschera", + "Rename Mask Effect Group": "Rinomina gruppo di effetti maschera", + "Rename Person": "Rinomina persona", + "Rename Slideshow Template": "Rinomina modello di presentazione", + "Reset Print Settings": "Ripristina impostazioni di stampa", + "Resize Print Cell": "Ridimensiona cella di stampa", + "Save Current Settings as New Effect Preset…": "Salva le impostazioni attuali come nuovo preset effetto…", + "Save Mask Effect": "Salva effetto maschera", + "Save Print Template": "Salva modello di stampa", + "Save Slideshow Template": "Salva modello di presentazione", + "Set Effect for New Masks": "Imposta effetto per le nuove maschere", + "Set Print Cell Photo": "Imposta foto della cella di stampa", + "Set Print Settings": "Imposta impostazioni di stampa", + "Show Layout Image": "Mostra immagine di layout", + "Slideshow Export Status": "Stato esportazione presentazione", + "Slideshow Layout Settings": "Impostazioni layout presentazione", + "Slideshow Options…": "Opzioni presentazione…", + "Slideshow Overlay Settings": "Impostazioni sovrapposizioni presentazione", + "Slideshow Playback Settings": "Impostazioni riproduzione presentazione", + "Slideshow Slide Preview": "Anteprima diapositiva della presentazione", + "Slideshow Title Settings": "Impostazioni titoli presentazione", + "Update Mask Effect": "Aggiorna effetto maschera", + "Update Slideshow Template": "Aggiorna modello di presentazione", + "Write Waiting XMP Sidecars": "Scrivi i file XMP in attesa", + "Color Overlay": "Sovrapposizione colore", + "Color Overlay on B&W": "Sovrapposizione colore su B/N", + "Image on Black": "Immagine su nero", + "Image on White": "Immagine su bianco", + "White on Black": "Bianco su nero", + "View Options…": "Opzioni di visualizzazione…", + "View Options": "Opzioni di visualizzazione", + "Loupe View": "Vista lente", + "Show Info Overlay": "Mostra sovrapposizione informazioni", + "Loupe Info 1": "Info lente 1", + "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." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 616c150cf..ce56500b5 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", "· {in_source} identical copies": "・同一のコピー {in_source} 件", "{count} more warnings.": "ほかに{count}件の警告があります。", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "カタログ内のレコードが不完全なため、{skipped}件の画像を読み込めませんでした。", "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。", "Applied {name}": "{name} を適用しました", @@ -295,5 +296,6 @@ "Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると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})" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2dc80934f..4c55ee494 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -391,6 +391,7 @@ "Detail (D)": "詳細(D)", "Details": "詳細", "Diagonal": "対角線", + "Dimensions": "寸法", "Discard Preview Cache": "プレビューキャッシュを破棄", "Display P3": "Display P3", "Display name": "表示名", @@ -1310,6 +1311,7 @@ "Show in Explorer": "エクスプローラーで表示", "Show in File Manager": "ファイルマネージャーで表示", "Show in Finder": "Finderで表示", + "Show in Folders": "フォルダで表示", "Show on Map": "地図で表示", "Show overlay (O)": "重ね表示(O)", "Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示", @@ -1499,7 +1501,7 @@ "Width": "幅", "Width (% of photo)": "幅(写真に対する%)", "Width (px)": "幅(px)", - "Width × Height": "幅 × 高さ", + "Width & Height": "幅 & 高さ", "Will be replaced": "置き換えられます", "Will be restored": "復元されます", "Window": "ウィンドウ", diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index 9b08b0d5d..c3b48ef62 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -278,6 +278,7 @@ "Updated {name}": "Updated {name}", "new, inside {parent}": "new, inside {parent}", "{count} more warnings.": "{count} more warnings.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "კატალოგის {skipped} სურათი არ დაიმპორტდა, რადგან მათი ჩანაწერები კატალოგში არასრულია.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", "{n} JPEG files": "{n} JPEG files", "{n} pages": "{n} pages", @@ -295,5 +296,6 @@ "Already in “{}”": "Already in “{}”", "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." } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index f6530929d..066f2ecd2 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1927,6 +1927,7 @@ "Show in Explorer": "Explorer-ში ჩვენება", "Show in File Manager": "ფაილების მენეჯერში ჩვენება", "Show in Finder": "Finder-ში ჩვენება", + "Show in Folders": "საქაღალდეებში ჩვენება", "Show less": "ნაკლების ჩვენება", "Show on Map": "რუკაზე ჩვენება", "Show overlay (O)": "ზედდების ჩვენება (O)", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 1886098df..e3ce316a0 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -248,52 +248,54 @@ "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", "· {in_source} identical copies": "· identyczne kopie: {in_source}", "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}", - " in {g} group{}": " in {g} group{}", - " · kept, not applied: {}": " · kept, not applied: {}", - " — kept but not applied: {}": " — kept but not applied: {}", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "Effect: {name}": "Effect: {name}", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "{n} preset{}": "{n} preset{}", - "{count} more warnings.": "{count} more warnings.", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "Applied {name}": "Applied {name}", - "Delete “{name}”?": "Delete “{name}”?", - "Updated {name}": "Updated {name}", - "Exported to {path}": "Exported to {path}", - "Saved template {name}": "Saved template {name}", - "Imported {name}": "Imported {name}", - "Skipped {problem}": "Skipped {problem}", - "…and {more} more": "…and {more} more", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "{n} pages": "{n} pages", - "{n} JPEG files": "{n} JPEG files", - "{s} s of AV1 video": "{s} s of AV1 video", - "Slideshow exported: {what}": "Slideshow exported: {what}", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "new, inside {parent}": "new, inside {parent}", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + " in {g} group{}": " w grupach: {g}{:.0}", + " · 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ąć.", + "Effect: {name}": "Efekt: {name}", + "Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne 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}", + "{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}", + "Delete “{name}”?": "Usunąć „{name}”?", + "Updated {name}": "Zaktualizowano: {name}", + "Exported to {path}": "Wyeksportowano do {path}", + "Saved template {name}": "Zapisano szablon {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", + "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.", + "Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”", + "new, inside {parent}": "nowe, wewnątrz {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.", + "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})", + "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." } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 7416ce241..aba88ef44 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -393,6 +393,7 @@ "Detail (D)": "Szczegóły (D)", "Details": "Szczegóły", "Diagonal": "Przekątna", + "Dimensions": "Wymiary", "Discard Preview Cache": "Odrzuć pamięć podręczną podglądów", "Display P3": "Display P3", "Display name": "Nazwa wyświetlana", @@ -1338,6 +1339,7 @@ "Show in Explorer": "Pokaż w Eksploratorze", "Show in File Manager": "Pokaż w menedżerze plików", "Show in Finder": "Pokaż w Finderze", + "Show in Folders": "Pokaż w folderach", "Show on Map": "Pokaż na mapie", "Show overlay (O)": "Pokaż nakładkę (O)", "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", @@ -1511,7 +1513,7 @@ "Versions (Shift+V)": "Wersje (Shift+V)", "Vertical": "Pionowo", "Vertical text": "Tekst pionowy", - "Vibrance": "Dynamika", + "Vibrance": "Jaskrawość", "Videos": "Wideo", "View": "Widok", "Viewing and sharing": "Wyświetlanie i udostępnianie", @@ -1541,7 +1543,7 @@ "Width": "Szerokość", "Width (% of photo)": "Szerokość (% zdjęcia)", "Width (px)": "Szerokość (px)", - "Width × Height": "Szerokość × wysokość", + "Width & Height": "Szerokość & wysokość", "Will be replaced": "Zostanie zastąpiony", "Will be restored": "Zostanie przywrócony", "Window": "Okno", @@ -2157,5 +2159,115 @@ "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", "Unnamed Faces": "Nienazwane twarze", "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", - "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)", + "Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu", + "Refresh View": "Odśwież widok", + "Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia", + "Photo Below": "Zdjęcie poniżej", + "Photo Above": "Zdjęcie powyżej", + "Cycle Flag": "Przełącz oznaczenie", + "Increase Flag Status": "Podnieś status oznaczenia", + "Decrease Flag Status": "Obniż status oznaczenia", + "Update to Current Process": "Aktualizuj do bieżącej wersji procesu", + "HDR": "HDR", + "Previous History Step": "Poprzedni krok historii", + "Next History Step": "Następny krok historii", + "Find Blemishes": "Znajdź skazy", + "Keyword Info": "Informacje o słowie kluczowym", + "Create Keyword": "Utwórz słowo kluczowe", + "Edit Keyword": "Edytuj słowo kluczowe", + "Move Keyword": "Przenieś słowo kluczowe", + "Put New Keywords Inside": "Umieszczaj nowe słowa kluczowe wewnątrz", + "Mask Effect Presets": "Ustawienia wstępne efektów maski", + "Apply Mask Effect": "Zastosuj efekt maski", + "Save Mask Effect": "Zapisz efekt maski", + "Update Mask Effect": "Aktualizuj efekt maski", + "Rename Mask Effect": "Zmień nazwę efektu maski", + "Duplicate Mask Effect": "Duplikuj efekt maski", + "Delete Mask Effect": "Usuń efekt maski", + "Favorite Mask Effect": "Oznacz efekt maski jako ulubiony", + "Move Mask Effect to Group": "Przenieś efekt maski do grupy", + "Rename Mask Effect Group": "Zmień nazwę grupy efektów maski", + "Set Effect for New Masks": "Ustaw efekt dla nowych masek", + "Import Mask Effects": "Importuj efekty maski", + "Export Mask Effects": "Eksportuj efekty maski", + "HDR for Each Bracket Set": "HDR dla każdej serii ekspozycji", + "Write Waiting XMP Sidecars": "Zapisz oczekujące pliki pomocnicze XMP", + "Slideshow Playback Settings": "Ustawienia odtwarzania pokazu slajdów", + "Slideshow Layout Settings": "Ustawienia układu pokazu slajdów", + "Slideshow Overlay Settings": "Ustawienia nakładek pokazu slajdów", + "Slideshow Title Settings": "Ustawienia tytułów pokazu slajdów", + "Slideshow Templates": "Szablony pokazu slajdów", + "Apply Slideshow Template": "Zastosuj szablon pokazu slajdów", + "Save Slideshow Template": "Zapisz szablon pokazu slajdów", + "Update Slideshow Template": "Aktualizuj szablon pokazu slajdów", + "Rename Slideshow Template": "Zmień nazwę szablonu pokazu slajdów", + "Delete Slideshow Template": "Usuń szablon pokazu slajdów", + "Import Slideshow Template": "Importuj szablon pokazu slajdów", + "Export Slideshow Template": "Eksportuj szablon pokazu slajdów", + "Slideshow Slide Preview": "Podgląd slajdu pokazu", + "Export Slideshow": "Eksportuj pokaz slajdów", + "Slideshow Export Status": "Stan eksportu pokazu slajdów", + "Export Contact Sheet PDF": "Eksportuj arkusz stykowy PDF", + "Apple Photos Imports": "Dodawanie do aplikacji Zdjęcia", + "Print Settings": "Ustawienia drukowania", + "Set Print Settings": "Zmień ustawienia drukowania", + "Reset Print Settings": "Przywróć ustawienia drukowania", + "Add Print Cell": "Dodaj komórkę wydruku", + "Move Print Cell": "Przenieś komórkę wydruku", + "Resize Print Cell": "Zmień rozmiar komórki wydruku", + "Delete Print Cell": "Usuń komórkę wydruku", + "Arrange Print Cell": "Rozmieść komórkę wydruku", + "Set Print Cell Photo": "Ustaw zdjęcie komórki wydruku", + "Move Print Cell to Page": "Przenieś komórkę wydruku na stronę", + "Auto Layout": "Układ automatyczny", + "New Print Page": "Nowa strona wydruku", + "Clear Print Layout": "Wyczyść układ wydruku", + "Print to PDF": "Drukuj do PDF", + "Print Templates": "Szablony wydruku", + "Save Print Template": "Zapisz szablon wydruku", + "Apply Print Template": "Zastosuj szablon wydruku", + "Delete Print Template": "Usuń szablon wydruku", + "Recheck Preview-Only Raws": "Sprawdź ponownie pliki RAW dostępne tylko jako podgląd", + "Scan Folder for Changes": "Skanuj folder w poszukiwaniu zmian", + "Rename Person": "Zmień nazwę osoby", + "Read Focus Points": "Odczytaj punkty ostrości", + "Read Focus Points in the Background": "Odczytuj punkty ostrości w tle", + "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", + "Decrease Grid Thumbnail Size": "Zmniejsz rozmiar miniatur w siatce", + "Visualize HDR": "Wizualizuj HDR", + "Cycle Tool Overlay": "Przełącz nakładkę narzędzia", + "Choose Layout Image…": "Wybierz obraz układu…", + "Show Layout Image": "Pokaż obraz układu", + "Layout Image Opacity": "Krycie obrazu układu", + "Layout Image Matte": "Matowanie obrazu układu", + "Solo Mode": "Tryb solo", + "Slideshow Options…": "Opcje pokazu slajdów…", + "Export Slideshow…": "Eksportuj pokaz slajdów…", + "Pause / Resume Slideshow": "Wstrzymaj / wznów pokaz slajdów", + "Next Slide": "Następny slajd", + "Previous Slide": "Poprzedni slajd", + "End Slideshow": "Zakończ pokaz slajdów", + "Contact Sheet PDF…": "Arkusz stykowy PDF…", + "Edit in PhotoCraft": "Edytuj w PhotoCraft", + "Edit in Other Editor": "Edytuj w innym edytorze", + "Open Original in Other Editor (RAW, no copy)": "Otwórz oryginał w innym edytorze (RAW, bez kopii)", + "Save Current Settings as New Effect Preset…": "Zapisz bieżące ustawienia jako nowe ustawienie wstępne efektu…", + "Manage Effect Presets…": "Zarządzaj ustawieniami wstępnymi efektów…", + "Export Effect Presets…": "Eksportuj ustawienia wstępne efektów…", + "Display Profile": "Profil ekranu", + "HDR Headroom": "Zapas jasności HDR", + "SDR Brightness": "Jasność SDR", + "SDR Contrast": "Kontrast SDR", + "SDR Highlights": "Światła SDR", + "SDR Shadows": "Cienie SDR", + "SDR Whites": "Biele SDR", + "SDR Clarity": "Przejrzystość SDR", + "Face Models on the GPU": "Modele twarzy przetwarzane na GPU" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 2b17909ad..bab81a368 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "{n} originais abertos no outro editor", "· {in_source} identical copies": "· {in_source} cópias idênticas", "{count} more warnings.": "Mais {count} avisos.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} imagens do catálogo não foram importadas porque seus registros no catálogo estão incompletos.", "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.", "Applied {name}": "{name} aplicado", @@ -295,5 +296,6 @@ "Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.", "“{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})" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c9e9da05..5b4ae4409 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -411,6 +411,7 @@ "Detail (D)": "Detalhe (D)", "Details": "Detalhes", "Diagonal": "Diagonal", + "Dimensions": "Dimensões", "Discard Preview Cache": "Descartar cache de prévias", "Display P3": "Display P3", "Display name": "Nome de exibição", @@ -1387,6 +1388,7 @@ "Show in Explorer": "Mostrar no Explorador de Arquivos", "Show in File Manager": "Mostrar no gerenciador de arquivos", "Show in Finder": "Mostrar no Finder", + "Show in Folders": "Mostrar em Pastas", "Show on Map": "Mostrar no mapa", "Show overlay (O)": "Mostrar sobreposição (O)", "Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição", @@ -1594,7 +1596,7 @@ "Width": "Largura", "Width (% of photo)": "Largura (% da foto)", "Width (px)": "Largura (px)", - "Width × Height": "Largura × altura", + "Width & Height": "Largura & altura", "Will be replaced": "Será substituída", "Will be restored": "Será restaurada", "Window": "Janela", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 55d5671aa..1efc30268 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", "· {in_source} identical copies": "· идентичных копий: {in_source}", "{count} more warnings.": "Других предупреждений: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не импортированы изображения каталога: {skipped}. Их записи в каталоге неполные.", "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.", "Applied {name}": "Применено: {name}", @@ -295,5 +296,6 @@ "Already in “{}”": "Уже в «{}»", "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", + "Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5ead5101f..bf191bb51 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -347,6 +347,7 @@ "Detail (D)": "Детализация (D)", "Details": "Подробности", "Diagonal": "По диагонали", + "Dimensions": "Размеры", "Discard Preview Cache": "Удалить кэш просмотров", "Display P3": "Display P3", "Display theme": "Тема оформления", @@ -1155,6 +1156,7 @@ "Show in Explorer": "Показать в «Проводнике»", "Show in File Manager": "Показать в файловом менеджере", "Show in Finder": "Показать в Finder", + "Show in Folders": "Показать в папках", "Show on Map": "Показать на карте", "Show overlay (O)": "Показать наложение (O)", "Show ratings, flags and edit badges": "Показывать оценки, флаги и значки правок", @@ -1339,7 +1341,7 @@ "Width": "Ширина", "Width (% of photo)": "Ширина (% от фото)", "Width (px)": "Ширина (px)", - "Width × Height": "Ширина × высота", + "Width & Height": "Ширина & высота", "Window": "Окно", "Winter Blue": "Зимняя синева", "Wrote the XMP sidecars that were waiting": "Ожидавшие XMP-файлы записаны", diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json new file mode 100644 index 000000000..50e3fa4be --- /dev/null +++ b/crates/ui-egui/locales/sv-formats.json @@ -0,0 +1,301 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} med flera likadana filer: använd Leta upp)", + " ({} in Recently Deleted)": " ({} i Nyligen raderade)", + " in {g} group{}": " i {g} grupp{}", + " · kept, not applied: {}": " · behålls men tillämpas inte: {}", + " · {:.0}% clipped": " · {:.0} % klippt", + " · {failed} couldn't be rendered": " · {failed} kunde inte renderas", + " · {n} skipped (could not be rendered)": " · {n} överhoppade (kunde inte renderas)", + " · {queued} waiting": " · {queued} väntar", + " · {skipped} skipped (file exists)": " · {skipped} överhoppade (filen finns)", + " · {} duplicate{} skipped": " · {} dubblett{} överhoppade", + " · {} failed: {}": " · {} misslyckades: {}", + " · {} moved": " · {} flyttade", + " · {} not readable": " · {} kunde inte läsas", + " · {} original{} left at the source": " · {} original{} kvar i källan", + " · {} restored from Recently Deleted": " · {} återställda från Nyligen raderade", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importerar inte en lista med ; , < eller > i ett nyckelord: {names}", + " — kept but not applied: {}": " — behålls men tillämpas inte: {}", + " — left out, as a keyword list can't hold them: {names}": " — utelämnade, eftersom en nyckelordslista inte kan innehålla dem: {names}", + " — not carried over: {}{}": " — fördes inte över: {}{}", + "+{n} more": "+{n} till", + "+{} more": "+{} till", + ", {failed} file{} not readable": ", {failed} fil{} kunde inte läsas", + ", {} label": ", {} etikett", + ", {} star{}": ", {} stjärna{}", + "1 task is still running: {names}. Quitting stops it.": "1 uppgift körs fortfarande: {names}. Om du avslutar stoppas den.", + "; {kept} already had a location": "; {kept} hade redan en plats", + "; {n} gained synonyms": "; {n} fick synonymer", + "; {outside} outside its time range": "; {outside} utanför sitt tidsintervall", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR:er, {} commits, +{} / −{} rader (Δ {}), +{} / −{} binära resurser, {} – {}", + "Add {local_n} to My Photos": "Lägg till {local_n} i Mina foton", + "Add {} Photo{}": "Lägg till {} foto{}", + "Add {} Photo{}, restore {}": "Lägg till {} foto{}, återställ {}", + "Add “{}” to Favorites": "Lägg till ”{}” i Favoriter", + "Added {n} keyword{}": "Lade till {n} nyckelord{}", + "Added {n} photo{} to My Photos": "Lade till {n} foto{} i Mina foton", + "Added {n} photo{} to “{}”": "Lade till {n} foto{} i ”{}”", + "Added {what} to {name}": "Lade till {what} i {name}", + "Added “{keyword}” to {n} photo{}": "Lade till ”{keyword}” på {n} foto{}", + "Adding photos… {} of {}": "Lägger till foton… {} av {}", + "All {n} selected photos move by the same amount.": "Alla {n} markerade foton flyttas lika mycket.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Finns redan i biblioteket, i Nyligen raderade: {} foto{} markerade · högerklicka → Återställ, eller Radera permanent för att importera på nytt", + "Already in the library: {} photo{} selected in All Photos": "Finns redan i biblioteket: {} foto{} markerade i Alla foton", + "Applied {name}": "Tillämpade {name}", + "Apply to “{mask}” on Selected Photos": "Använd på ”{mask}” i markerade foton", + "Auto ({})": "Auto ({})", + "Auto Import: added {} photo{}": "Automatisk import: lade till {} foto{}", + "Auto Sync: edits apply to {n} photos": "Automatisk synkning: ändringar gäller {n} foton", + "Automatic ({} MB)": "Automatisk ({} MB)", + "Build 1:1 Previews ({scope})": "Bygg 1:1-förhandsgranskningar ({scope})", + "Build Smart Previews ({scope})": "Bygg smarta förhandsgranskningar ({scope})", + "Build Standard-Sized Previews ({scope})": "Bygg förhandsgranskningar i standardstorlek ({scope})", + "Building previews for {} photos…": "Bygger förhandsgranskningar för {} foton…", + "Click {}:1": "Klick {}:1", + "Couldn't open the editor: {e}": "Kunde inte öppna redigeraren: {e}", + "Create Keyword Tag Inside “{name}”…": "Skapa nyckelordstagg i ”{name}”…", + "Create the keyword “{name}”": "Skapa nyckelordet ”{name}”", + "Create {n} Virtual Copies": "Skapa {n} virtuella kopior", + "Creates {} stacks from {} of {scope}": "Skapar {} staplar av {} i {scope}", + "Decoder: {reason}": "Avkodare: {reason}", + "Delete \"{label}\"": "Radera ”{label}”", + "Delete \"{label}\" (and the mask)": "Radera ”{label}” (och masken)", + "Delete the effect preset “{name}”?": "Radera effektförinställningen ”{name}”?", + "Delete the keyword “{name}”?": "Radera nyckelordet ”{name}”?", + "Delete these {n} effect presets?": "Radera dessa {n} effektförinställningar?", + "Delete {n} effect preset{}? This can't be undone.": "Radera {n} effektförinställning{}? Det går inte att ångra.", + "Delete {n} Photos": "Radera {n} foton", + "Delete “{current}”": "Radera ”{current}”", + "Delete “{name}”?": "Radera ”{name}”?", + "Denoising {name}: {done} of {total} tiles{}": "Brusreducerar {name}: {done} av {total} rutor{}", + "Discard Smart Previews ({scope})": "Släng smarta förhandsgranskningar ({scope})", + "Done ({what}): {done} photo{plural}": "Klart ({what}): {done} foto{plural}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Laddas ned från {host}. När den har kommit fram och kontrollerats installerar LightCraft den, {after}.", + "Duplicate \"{label}\"": "Duplicera ”{label}”", + "Effect: {name}": "Effekt: {name}", + "Example: {example}": "Exempel: {example}", + "Exported to {path}": "Exporterade till {path}", + "Exported {n} keyword{}": "Exporterade {n} nyckelord{}", + "Exported {n} photo{}": "Exporterade {n} foto{}", + "Exported {ok} of {total} photo{}": "Exporterade {ok} av {total} foto{}", + "Exported {} effect preset{}": "Exporterade {} effektförinställning{}", + "Exported {} point curve preset{}": "Exporterade {} punktkurvförinställning{}", + "Exported {} preset{}": "Exporterade {} förinställning{}", + "Exporting {} of {total}": "Exporterar {} av {total}", + "Filter bar — {active} active filter{}": "Filterfält — {active} aktiva filter{}", + "Filtered: {label}": "Filtrerat: {label}", + "Focus {:.0}": "Fokus {:.0}", + "Folder “{}” (and its subfolders)": "Mappen ”{}” (och dess undermappar)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Hittade {n} saknade foto{}; {left} saknas fortfarande{unsure}", + "Healed {n} dust spot{}": "Retuscherade {n} dammfläck{}", + "Import cancelled · {} photo{} added": "Importen avbröts · {} foto{} tillagda", + "Import failed: {e}": "Importen misslyckades: {e}", + "Import warnings ({n}):\n{warnings}": "Importvarningar ({n}):\n{warnings}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Importerade {e} maskeffekt{} (Maskering → Effekt)", + "Imported {name}": "Importerade {name}", + "Imported {n} point curve preset{}": "Importerade {n} punktkurvförinställning{}", + "Imported {n} preset{}": "Importerade {n} förinställning{}", + "Imported {n} preset{}, {f} file{} not readable": "Importerade {n} förinställning{}, {f} fil{} kunde inte läsas", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importerade {profiles} profil{} (Profilbläddrare → deras grupper)", + "Imported {} photo{}": "Importerade {} foto{}", + "In line for AI Denoise ({ahead} ahead)…": "I kö för AI-brusreducering ({ahead} före)…", + "Insert at the cursor (also written {})": "Infoga vid textmarkören (skrivs även {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Det tas bort från {count} foto{}, tillsammans med nyckelorden under det. Ångra återställer det.", + "Keywords of {n} selected photos": "Nyckelord för {n} markerade foton", + "Label Set: {name}": "Etikettuppsättning: {name}", + "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 kan inte avkoda den här raw-varianten än ({}). Du redigerar kamerans inbäddade JPEG-förhandsgranskning, som redan innehåller kamerans bildstil (t.ex. Monokrom) och vitbalans.", + "LightCraft could not open its window: {e}": "LightCraft kunde inte öppna sitt fönster: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft kunde inte öppna sitt fönster: {e}\n\nDetaljer finns i loggfilen: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft avslutades oväntat: {e}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft avslutades oväntat: {e}\n\nDetaljer finns i loggfilen: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-katalogen har granskats: {photos} foton, {collections} samlingar, {missing} saknas", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-geometrin för {path} bevarades inte; sparad transformering med guidad Upright stöds inte.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-geometrin för {path} bevarades inte; den sparade transformeringen ignoreras eftersom Upright är av.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-geometrin för {path} bevarades inte; sparad Upright-transformering stöds inte.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-geometrin bevarades inte för {path}; {mode} beräknas om.", + "Lightroom import complete: {imported} photos added": "Lightroom-importen är klar: {imported} foton har lagts till", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-importen är klar: {imported} foton har lagts till, {failed} misslyckades", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-importen är klar: {imported} foton har lagts till; indexvarning: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom-importen misslyckades: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Granskningen av Lightroom misslyckades: {e}{warning}", + "Making the denoised picture… {}%": "Skapar den brusreducerade bilden… {}%", + "Matches: {}": "Matchar: {}", + "Merging… {stage} {:.0}%": "Sammanfogar… {stage} {:.0}%", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Typsnitt: {} (OFL). Ikoner: original.", + "Move {what} to Recently Deleted?": "Vill du flytta {what} till Nyligen raderade?", + "Moved {} photo(s)": "Flyttade {} foto(n)", + "Moved; {} photo(s) relinked": "Flyttat; {} foto(n) har länkats om", + "Named ({named_n})": "Namngivna ({named_n})", + "New Folder in “{name}”": "Ny mapp i ”{name}”", + "new, inside {parent}": "ny, inuti {parent}", + "none (CPU rendering): {why}": "ingen (CPU-rendering): {why}", + "not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}", + "On {have} of {of} selected photos": "På {have} av {of} markerade foton", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Målar ”{k}”: klicka på foton för att lägga till eller ta bort det · Esc avslutar", + "Paste into {n} photo{} — only settings that were copied are pasted": "Klistra in i {n} foto{} – endast inställningar som kopierades klistras in", + "Preset: {name}": "Förinställning: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Endast förhandsgranskning – du redigerar kamerans inbäddade JPEG ({})", + "Preview only — {}": "Endast förhandsgranskning – {}", + "Previews ready for {done} photo{plural}": "Förhandsgranskningar klara för {done} foto{plural}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Korrekturkopia ({})", + "Proof Preview · {}": "Korrekturförhandsgranskning · {}", + "Put inside “{parent}”": "Placera i ”{parent}”", + "Quick Develop · {n} photos": "Snabbframkalla · {n} foton", + "Quick Develop: {name}": "Snabbframkalla: {name}", + "Rated {}": "Betygsatt {}", + "Reading Lightroom catalog… {done} of {total}": "Läser Lightroom-katalogen… {done} av {total}", + "Reading photos… {done} of {total}": "Läser foton… {done} av {total}", + "Reading photos… {} of {}": "Läser foton… {} av {}", + "Redo {}": "Gör om {}", + "Remove “{name}”": "Ta bort ”{name}”", + "Remove “{name}” and its {count} photo{} from the library?": "Vill du ta bort ”{name}” och dess {count} foto{} från biblioteket?", + "Remove “{}” from Favorites": "Ta bort ”{}” från Favoriter", + "Removed from {}": "Borttaget från {}", + "Removed {what} from {name}": "Tog bort {what} från {name}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Tog bort ”{name}” från {n} foton. Redigera → Ångra lägger tillbaka det.", + "Rename {n} Photos…": "Byt namn på {n} foton…", + "Rename “{name}”": "Byt namn på ”{name}”", + "Renames {n} face{} across the library. The files are not changed.": "Byter namn på {n} ansikte{} i hela biblioteket. Filerna ändras inte.", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Byter namn på ”{from}” på {n} foto{} (även nyckelord under det). Använd | för nivåer, t.ex. Resor|Italien. Ett befintligt namn sammanfogar de två.", + "Rendering on the CPU: {why}": "Renderar på processorn: {why}", + "Replace {} with:": "Ersätt {} med:", + "Reset {name}": "Återställ {name}", + "Reset {} Channel": "Återställ kanalen {}", + "Restore {}": "Återställ {}", + "Restore {} Photo{}": "Återställ {} foto{}", + "Restored {} photo{} from Recently Deleted": "Återställde {} foto{} från Nyligen raderade", + "Running on the processor: {}.": "Körs på processorn: {}.", + "Running on {}.": "Körs på {}.", + "Saved export preset “{}”": "Exportförinställningen ”{}” har sparats", + "Saved template {name}": "Mallen {name} har sparats", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bedömer {scope} efter fokus och exponering och hittar likartade bilder tagna inom några sekunder från varandra (serier).", + "Show {hidden} hidden location{}": "Visa {hidden} dold plats{}", + "Showing {n} of {}": "Visar {n} av {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Visar modellens villkor, hämtar sedan {} från {host}, installerar den och börjar använda den. Inget annat skickas.", + "Skipped {problem}": "Hoppade över {problem}", + "Slideshow exported: {what}": "Bildspelet har exporterats: {what}", + "Smart album: {}": "Smart album: {}", + "Tagged {n} photo{} from the tracklog": "Taggade {n} foto{} från spårloggen", + "Template preview: {name}": "Förhandsgranskning av mall: {name}", + "The SAM 3 download failed: {e}": "Hämtningen av SAM 3 misslyckades: {e}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "XMP-sidofilerna för {n} foton kunde inte skrivas (är mappen offline?). LightCraft fortsätter att försöka.", + "the {n} selected photos": "de {n} markerade fotona", + "the {} photos in view": "de {} fotona i vyn", + "There is a keyword set “{name}” already": "Det finns redan en nyckelordsuppsättning ”{name}”", + "There is a “{name}” at the top level already. Merge the two?": "Det finns redan ett ”{name}” på översta nivån. Vill du sammanfoga dem?", + "these {count} photos": "dessa {count} foton", + "Trained on: {provenance}": "Tränad på: {provenance}", + "Undo {}": "Ångra {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Okända taggar ({}) behåll{s}{} som de skrevs (se Taggar)", + "Update “{name}” with Current Settings": "Uppdatera ”{name}” med aktuella inställningar", + "Updated {name}": "{name} har uppdaterats", + "Updated “{name}”": "”{name}” har uppdaterats", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} – ett fotobibliotek och raw-framkallare i ren Rust, utvecklat enligt clean room-metoden.", + "Version: {}": "Version: {}", + "Virtual copy “{name}” of {of}": "Virtuell kopia ”{name}” av {of}", + "Working on {what} for {} photos…": "Arbetar med {what} för {} foton…", + "XMP adjustments were not imported for {path}: {fields}.": "XMP-justeringar importerades inte för {path}: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-profilen \"{profile}\" tillämpades inte för {path}; färgerna kan skilja sig från Lightroom.", + "{:.1} MB": "{:.1} MB", + "{count} more warnings.": "{count} varningar till.", + "{count} smart preview{} · {:.1} MB": "{count} smarta förhandsgranskning{} · {:.1} MB", + "{done} of {total}": "{done} av {total}", + "{done} of {total} MB": "{done} av {total} MB", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "{f} mappar (och deras undermappar)", + "{label} Grading": "Gradering: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} på varje markerat foto", + "{label} {index} of {n}": "{label} {index} av {n}", + "{label}, {n} photos": "{label}, {n} foton", + "{matching} of {t} photos": "{matching} av {t} foton", + "{matching} photos": "{matching} foton", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} original saknas. Deras katalogposter finns kvar; använd Sök efter saknade foton för att länka om dem.", + "{mode} spot {} of {}": "{mode} – punkt {} av {}", + "{month}": "{month}", + "{month} {year}": "{month} {year}", + "{msg} ({failed} failed)": "{msg} ({failed} misslyckades)", + "{name} Label": "Etiketten {name}", + "{name} Off": "{name} av", + "{name} On": "{name} på", + "{name} opened for editing; it is stacked with the original": "{name} öppnades för redigering; den är staplad med originalet", + "{name} opened in the other editor": "{name} öppnades i den andra redigeraren", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} – dubbelklicka för att återställa", + "{n} added to Apple Photos": "{n} tillagda i Apple Photos", + "{n} added to Apple Photos album “{album}”": "{n} har lagts till i Apple Photos-albumet ”{album}”", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} ändring{} kunde inte skrivas till disken: {e}\nOm du avslutar nu går {} förlorade.", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} ändring{} har sparats i minnet men inte skrivits till disken: {e}\nLightCraft försöker igen automatiskt. Om du avslutar nu går {} förlorade.", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} ändring{} har sparats i minnet men inte skrivits till disken: {e} – LightCraft försöker igen", + "{n} found · {sel} selected": "{n} hittade · {sel} markerade", + "{n} JPEG files": "{n} JPEG-filer", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} lokala foto{} hoppades över: album innehåller foton från biblioteket (lägg först till i Mina foton)", + "{n} originals opened in the other editor": "{n} original har öppnats i den andra redigeraren", + "{n} pages": "{n} sidor", + "{n} photos": "{n} foton", + "{n} photos in view · {slides} slides · {time}": "{n} foton i vyn · {slides} bilder · {time}", + "{n} photos in {} are hidden by the filters above": "{n} foton i {} döljs av filtren ovan", + "{n} photos opened for editing; each is stacked with its original": "{n} foton har öppnats för redigering; vart och ett staplas med sitt original", + "{n} photo{}": "{n} foto{}", + "{n} photo{} match · updates automatically as photos change": "{n} foto{} matchar · uppdateras automatiskt när fotona ändras", + "{n} photo{} now · updates automatically as photos change": "{n} foto{} just nu · uppdateras automatiskt när fotona ändras", + "{n} preset{}": "{n} förinställning{}", + "{n} raws can now be developed from their raw data": "{n} raw-filer kan nu framkallas från sina rådata", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} likartade foto{} · Visa → Rensa filter för att se alla", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} uppgifter körs fortfarande: {names}. Om du avslutar stoppas de.", + "{n} {noun}": "{n} {noun}", + "{pct} %": "{pct} %", + "{photos} catalog records processed; {collections} collections.": "{photos} katalogposter bearbetade; {collections} samlingar.", + "{pid} (missing)": "{pid} (saknas)", + "{sel_n} selected · {counted}": "{sel_n} markerade · {counted}", + "{size} GB": "{size} GB", + "{stage}… {:.0}%": "{stage}… {:.0} %", + "{s} s of AV1 video": "{s} s AV1-video", + "{verb} {n} Photo{}": "{verb} {n} foto{}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day} {month} {year}", + "{what} merge added": "{what}-sammanfogning tillagd", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-sammanfogning tillagd med {mp}, den största storlek ett panorama kan ha (full storlek är {full})", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-sammanfogning tillagd med {mp}: full storlek, {full}, kräver mer minne än den här datorn har", + "{what} merge cancelled": "{what}-sammanfogning avbruten", + "{what} merge failed: {e}": "{what}-sammanfogning misslyckades: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}}-direktiv: {}", + "{} and higher": "{} och högre", + "{} contributors · {} commits": "{} bidragsgivare · {} commits", + "{} failed": "{} misslyckades", + "{} files": "{} filer", + "{} files and {f} folder{}": "{} filer och {f} mapp{}", + "{} item{}": "{} objekt{}", + "{} KB": "{} kB", + "{} label (click more labels to show any of them)": "Etiketten {} (klicka på fler etiketter för att visa foton med någon av dem)", + "{} MB": "{} MB", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} av 4 stödlinjer – dra längs linjer som ska vara vertikala eller horisontella.", + "{} of {} from {host}": "{} av {} från {host}", + "{} photos": "{} foton", + "{} photos · exposures {} EV": "{} foton · exponeringar {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foton · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "{} foto{} kunde inte läsas", + "{} sample{}": "{} sample{}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} fläck(ar) på det här fotot", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Sparas i bibliotekets mapp ”denoise”; de äldsta som inget foto använder tas bort först när gränsen nås.", + "{} — show their photos": "{} – visa deras foton", + "· {dups} already in the library": "· {dups} finns redan i biblioteket", + "· {in_source} identical copies": "· {in_source} identiska kopior", + "· {n} photo{}": "· {n} foto{}", + "· {trashed} in Recently Deleted": "· {trashed} i Nyligen raderade", + "“{parent}” has a “{name}” already. Merge the two?": "”{parent}” har redan en ”{name}”. Vill du sammanfoga dem?", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” är redan en person. Om du kombinerar dem blir de en person; Ångra skiljer dem åt igen.", + "• {assumption}": "• {assumption}", + "… and {more} more": "… och {more} till", + "…and {more} more": "…och {more} till", + "Already in “{}”": "Already in “{}”", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "{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}" +} diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json new file mode 100644 index 000000000..76f08c2d3 --- /dev/null +++ b/crates/ui-egui/locales/sv.json @@ -0,0 +1,2418 @@ +{ + " (library default)": " (bibliotekets standard)", + " (inverted)": " (inverterad)", + " · best of its burst": " · bäst i sin bildserie", + " · in a burst": " · i en bildserie", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "\"sparad i minnet men inte skriven till disk\", det övre fältet visar en varning och LightCraft fortsätter att försöka tills", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): en DNG kontrolleras mot rådata, skrivs till en temporär fil, synkas och läses tillbaka innan den får sitt", + "% of all commits": "% av alla commits", + "& Containing": "& innehåller", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Hjälp → Systeminfo och Inställningar → Prestanda anger varför GPU:n inte", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(t.ex. D750, D780, D850, D7500, Z 50) renderas inte längre nästan svarta eller med klippta skuggor: deras svartnivå var", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ärende #94): en HTTP-begäran från en webbsida kan inte längre bära ett kommando i sin brödtext. Rader begränsas till", + "(not in the library)": "(inte i biblioteket)", + "+ Group": "+ Grupp", + "+ Rule": "+ Regel", + ", picked": ", vald", + ", rejected": ", avvisad", + "0: nothing is rejected.": "0: ingenting avvisas.", + "1 day": "1 dag", + "1 raw can now be developed from its raw data": "1 råbild kan nu framkallas från sina rådata", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 på studio-mac)\") i stället för att låta båda skriva och tyst kasta varandras ändringar. En krasch lämnar aldrig", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080-förhandsgranskning, och importeras med sina metadata: tagningstid, kamera, objektiv, exponering, GPS och XMP. Deras rå", + "2026-04 or 2026-04-12": "2026-04 eller 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nya inbyggda förinställningar: Porträtt, Landskap, Urban, Mat, Årstider, Vintage och svartvita toningar.", + "4 MiB and connections at 16.": "4 MiB och anslutningar till 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630-drivrutiner): i Windows använder fönstret och GPU-renderingen endast DirectX 12 och läser aldrig in Vulkan-drivrutinen om inte", + "= exactly": "= exakt", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; på skiftlägesokänsliga volymer går ändringen av skiftläge ändå igenom.", + "`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`) tillsammans med sina XMP-sidofiler; varje original lämnar kortet först efter att dess", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "En 16:9-bild med svarta fält ovanför och nedanför, titeln i hörnet, toningar via svart och ett avslutande kort.", + "A blank template keeps the original names.": "En tom mall behåller originalnamnen.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "En ändring som inte kan sparas till disk (full eller urkopplad enhet) är inte längre tyst: kommandot rapporterar", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "En mörk skifferbakgrund med exponering, ISO och brännvidd, sedan kamera och objektiv, under varje foto.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ett filter döljer den här vyns foton: ändra det eller rensa det (Visa → Rensa filter)", + "A folder is already being synchronized": "En mapp synkroniseras redan", + "A folder is being synchronized": "En mapp synkroniseras", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "en full enhet) räknas inte längre som byggd och Bygg smarta förhandsgranskningar ersätter den.", + "A keyword set needs a name": "En nyckelordsuppsättning behöver ett namn", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Ett bibliotek kan bara vara öppet i ett program åt gången (ärende #99): att öppna ett bibliotek som LightCraft eller `lightcraft-cli` redan", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "En ljusgrå vägg med vit passepartout och en mjuk skugga; bildtexten och betyget under fotot.", + "a literal %": "ett bokstavligt %", + "A one-time download of about": "En engångsnedladdning på cirka", + "A pen's pressure sets the size and flow of the brush": "Pennans tryck styr penselns storlek och flöde", + "a removed photo": "ett borttaget foto", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "En sparning som misslyckas halvvägs (en full disk, en nätverksresurs som tappas) ser inte längre ut som en skadad katalog", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "En sovande NAS, en tappad nätverksresurs eller en USB-enhet som startar fryser inte längre fönstret (ärende #104):", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "En bild visas under sin längd, inklusive intoningen. En toning på 0 ger ett snitt.", + "About": "Om", + "About LightCraft": "Om LightCraft", + "Accept & Download": "Acceptera och hämta", + "Active": "Aktiv", + "Activity": "Aktivitet", + "Actual size": "Verklig storlek", + "Add": "Addera", + "Add a model file…": "Lägg till en modellfil…", + "Add a rule (⌥-click: a group)": "Lägg till en regel (⌥-klick: en grupp)", + "Add Catchlight": "Lägg till ögonreflex", + "Add Face Model…": "Lägg till ansiktsmodell…", + "Add Folder to Local": "Lägg till mapp i Lokalt", + "Add in place": "Lägg till på plats", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Lägg till på plats: biblioteket refererar till filerna där de ligger; ingenting kopieras eller flyttas.", + "Add keyword": "Lägg till nyckelord", + "Add Mask": "Lägg till mask", + "Add number": "Lägg till nummer", + "Add Object Click": "Lägg till objektklick", + "Add Point Color Sample": "Lägg till punktfärgprov", + "Add Red Eye Correction": "Lägg till korrigering av röda ögon", + "Add Remove Spot": "Lägg till fläckborttagning", + "Add Selected Photos": "Lägg till markerade foton", + "Add text overlay": "Lägg till textöverlägg", + "Add to": "Lägg till i", + "Add to Album": "Lägg till i album", + "Add to All Selected Photos": "Lägg till i alla markerade foton", + "Add to Apple Photos": "Lägg till i Apple Photos", + "Add to Favorites": "Lägg till i favoriter", + "Add to favorites": "Lägg till i favoriter", + "Add to Local": "Lägg till i Lokalt", + "Add to My Photos": "Lägg till i Mina foton", + "Add to My Photos First": "Lägg till i Mina foton först", + "Add to Selected Photos": "Lägg till i markerade foton", + "Add to Target Album": "Lägg till i målalbum", + "Add to this mask": "Lägg till i den här masken", + "Add/Subtract/Intersect Mask": "Addera/subtrahera/skär mask", + "Added to photos you import that don't already have it.": "Läggs till på foton du importerar som inte redan har det.", + "Adding": "Lägger till", + "Adding Lightroom photos…": "Lägger till Lightroom-foton…", + "Adding photos…": "Lägger till foton…", + "Adding to Apple Photos": "Lägger till i Apple Photos", + "Adding to Apple Photos…": "Lägger till i Apple Photos…", + "Advanced search…": "Avancerad sökning…", + "After": "Efter", + "After 30 days": "Efter 30 dagar", + "After 7 days": "Efter 7 dagar", + "After 90 days": "Efter 90 dagar", + "After a year": "Efter ett år", + "After installation, AI Denoise will turn on for the requesting photo.": "Efter installationen aktiveras AI-brusreducering för fotot som begärde den.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "efteråt (ärende #101): den ofullständiga skrivningen kapas innan LightCraft försöker igen, så nästa start spelar upp varje", + "again. Change the period (or turn it off) in Settings → Performance.": "igen. Ändra intervallet (eller stäng av det) i Inställningar → Prestanda.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "mot bibliotekets original söker inte längre igenom hela biblioteket när det inte finns något på den sökvägen.", + "AI Denoise": "AI-brusreducering", + "AI Denoise is enabled on the requesting photo": "AI-brusreducering är aktiverad på fotot som begärde den", + "AI Denoise is off: press Use on a model.": "AI-brusreducering är av: tryck på Använd på en modell.", + "AI Denoise Model": "Modell för AI-brusreducering", + "AI Denoise needs a model. Install one in Settings.": "AI-brusreducering behöver en modell. Installera en i Inställningar.", + "AI Mask Model Status": "Status för AI-maskmodell", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "AI-masker med SAM 3 (Objekt och Beskriv i panelen Maskering): klicka på ett objekt för att markera det (⌥-klick lämnar ett", + "Album": "Album", + "Albums": "Album", + "Albums hold library photos: Add to My Photos first": "Album innehåller biblioteksfoton: lägg till i Mina foton först", + "All": "Alla", + "all": "alla", + "All Metadata": "Alla metadata", + "All Metadata…": "Alla metadata…", + "All Photos": "Alla foton", + "All rights reserved": "Alla rättigheter förbehållna", + "All the photos imported from this disk are included.": "Alla foton som importerats från den här disken ingår.", + "All types": "Alla typer", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "redan har en `thumbs/`-mapp med andra bilder förlorar dem inte längre när cachen trimmas eller rensas.", + "Already installed. Installing again is harmless.": "Redan installerad. Det skadar inte att installera igen.", + "Already installed. Remove it before installing a replacement.": "Redan installerad. Ta bort den innan du installerar en ersättare.", + "Also the masks of the same name on the other selected photos": "Även maskerna med samma namn på de andra markerade fotona", + "Alt Text": "Alternativtext", + "Always": "Alltid", + "Amount": "Antal", + "Analyzing the photo for AI masks…": "Analyserar fotot för AI-masker…", + "Anchored to": "Förankrad vid", + "and": "och", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "och kontrollerar filen innan den används. Masker behåller sin markering, så de renderas och exporteras utan modellen.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "och alla importerade foton som en zip-fil, och Arkiv → Återställ bibliotek från säkerhetskopia… hämtar tillbaka det (det aktuella biblioteket", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "och fryser aldrig fönstret. Den är valfri och ingår inte i LightCraft (Metas SAM-licens, cirka 3,4 GB):", + "Angle": "Vinkel", + "any": "någon", + "Any camera": "Valfri kamera", + "Any keyword": "Valfritt nyckelord", + "Any lens": "Valfri lins", + "Any Searchable Text": "All sökbar text", + "Aperture": "Bländare", + "Appearance": "Utseende", + "Appearance Mode": "Utseendeläge", + "Appearance Settings…": "Utseendeinställningar…", + "Apple Photos Imports": "Importer från Apple Photos", + "Application": "Program", + "Apply": "Använd", + "Apply Color Label Set": "Använd färgetikettuppsättning", + "Apply Filter Preset": "Använd filterförinställning", + "Apply Metadata Preset": "Använd metadataförinställning", + "Apply Point Curve Preset": "Använd punktkurvsförinställning", + "Apply Preset": "Använd förinställning", + "Apply Settings JSON": "Använd inställnings-JSON", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Använd den lokala tonmappningen som lagras i raw-filen (Apple ProRAW), så som kameran renderar den", + "Apply to All Masks": "Använd på alla masker", + "Approved": "Godkänd", + "Aqua": "Akvamarin", + "Aqua Hue": "Nyans akvamarin", + "Aqua Luminance": "Luminans akvamarin", + "Aqua Saturation": "Mättnad akvamarin", + "are empty": "är tomma", + "aren't empty": "är inte tomma", + "Arrange Panel": "Panelen Ordna", + "ArtCraft Website": "ArtCrafts webbplats", + "ArtCraft — more creative apps": "ArtCraft — fler kreativa appar", + "as `.corrupt-