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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 5fc2092a9..d85332b23 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -33,4 +33,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..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,11 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - 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 + 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..b8fb01326 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,8 @@ jobs: 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..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +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/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 4b6d54289..216fdeca4 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" @@ -1072,6 +1081,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-ui" version = "0.1.0" @@ -3073,8 +3087,9 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "chrono", "eframe", "egui", "lightcraft-codecs", @@ -3098,8 +3113,9 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "chrono", "crc32fast", "lightcraft-develop", "lightcraft-geom", @@ -3115,7 +3131,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.8.0" +version = "0.11.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3131,10 +3147,11 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.8.0" +version = "0.11.0" dependencies = [ "avif-serialize", "bytemuck", + "dhat", "image", "image-webp", "jixel", @@ -3161,7 +3178,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3170,7 +3187,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.8.0" +version = "0.11.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3187,7 +3204,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3202,7 +3219,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3214,9 +3231,11 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.8.0" +version = "0.11.0" dependencies = [ "ab_glyph", + "chrono", + "craft-docpath", "harfrust", "jxl-oxide", "libc", @@ -3256,7 +3275,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3271,7 +3290,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.8.0" +version = "0.11.0" dependencies = [ "log", "rustls", @@ -3282,7 +3301,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.8.0" +version = "0.11.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3290,7 +3309,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.8.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3298,7 +3317,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.8.0" +version = "0.11.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3318,7 +3337,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.8.0" +version = "0.11.0" dependencies = [ "heic-rs", "rayon", @@ -3326,14 +3345,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.8.0" +version = "0.11.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3342,7 +3361,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3353,7 +3372,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3369,8 +3388,9 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "chrono", "lightcraft-geom", "lightcraft-tiff", "proptest", @@ -3381,7 +3401,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.8.0" +version = "0.11.0" dependencies = [ "image", "lightcraft-color", @@ -3399,7 +3419,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.8.0" +version = "0.11.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3411,7 +3431,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.8.0" +version = "0.11.0" dependencies = [ "libc", "lightcraft-color", @@ -3423,7 +3443,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.8.0" +version = "0.11.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3447,7 +3467,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.8.0" +version = "0.11.0" dependencies = [ "image", "lightcraft-color", @@ -3458,7 +3478,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.8.0" +version = "0.11.0" dependencies = [ "candle-core", "candle-nn", @@ -3472,7 +3492,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.8.0" +version = "0.11.0" dependencies = [ "lightcraft-raster", "serde", @@ -3481,15 +3501,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.8.0" +version = "0.11.0" [[package]] name = "lightcraft-testkit" -version = "0.8.0" +version = "0.11.0" [[package]] name = "lightcraft-tiff" -version = "0.8.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3498,7 +3518,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.8.0" +version = "0.11.0" dependencies = [ "craft-ui", "egui", @@ -3527,7 +3547,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.8.0" +version = "0.11.0" dependencies = [ "crc32fast", "eframe", @@ -6997,7 +7017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.8.0" +version = "0.11.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index ac8cbeca1..0a0d78e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } @@ -48,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"] } diff --git a/README.md b/README.md index 53ec908de..15f8a7d83 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@

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

- Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
+ Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.

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

@@ -129,7 +133,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col ## Before & after -Hold \\ to peek at the original, press Y for side by side, or Shift+Y for a +Press \\ to toggle the Before view, Y for side by side, or Shift+Y for a split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the [control channel](#built-for-agents). @@ -150,9 +154,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components -with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint, -Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise) -plus an overall Amount. +with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint, +Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise, +Moiré, Defringe), plus an overall Amount. + +Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately +installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use +classical heuristics. Existing model-generated masks render and export without the model installed.

Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene @@ -164,7 +172,7 @@ plus an overall Amount. ## Presets, profiles & the Color Mixer -Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp +Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose. @@ -205,10 +213,15 @@ 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. +The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe. +On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are +bundled. See [face models and setup](docs/faces.md). + @@ -281,7 +294,13 @@ lightcraft --control 7980 ~/Pictures/trip print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf ``` - Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. + The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation, + print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page + overlays (identity plate, watermark, page numbers) are not there yet. +- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order. + **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports + support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain + missing. See [docs/slideshow.md](docs/slideshow.md). - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. @@ -293,7 +312,7 @@ lightcraft --control 7980 ~/Pictures/trip - **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, - PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, + PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, as in PhotoCraft). - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched. @@ -319,17 +338,21 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and [docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in +- **By feature count we're at ~82%** (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). -- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for +- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - - **AI masks and denoise:** subject and sky selection are classical heuristics; + - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an + optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and + linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md); - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - - **on-screen HDR display, video and the Classic Print / Book / Map modules.** + - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and + colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback + and export work, with a dedicated module editor and music still missing. - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). | Area | Status | @@ -337,16 +360,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ | | Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ | | Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ | -| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | +| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing | +| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | | RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) | -| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ | -| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | -| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | -| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ | +| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill | +| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | +| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ | | Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ | | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | @@ -354,7 +377,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | -| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) | +| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) | +| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ | +| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays | +| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) | +| Video import/editing and Book module | ⬜ | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | ✅ works today · 🚧 in progress · ⬜ not started @@ -373,7 +400,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory cargo run --release -p lightcraft -- --control 7980 # with the automation channel cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 -cargo xtask ci # fmt, clippy, tests, layering, wasm checks +cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM ``` CI defaults to line-table debug information and one build job per 1.5 GB of RAM @@ -426,7 +453,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული, 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 @@ -491,9 +518,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version: -- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); +- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); work from public specs and black-box observation. -- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every +- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here. @@ -536,7 +563,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window | Format | x86_64 | aarch64 (ARM64) | Notes | |---|---|---|---| -| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | | Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `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` | | @@ -623,8 +650,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/README.zh-hans.md b/README.zh-hans.md index f2d155214..824913f14 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -14,11 +14,12 @@

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

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

@@ -299,9 +300,9 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): -- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在 +- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在 [docs/parity.md](docs/parity.md)。 -- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ +- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; @@ -453,9 +454,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本: -- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); +- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); 只依据公开规范和黑盒观察来工作。 -- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 +- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中 于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到 [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。 @@ -579,8 +580,6 @@ 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 团队和社区制作。 diff --git a/README.zh-hant.md b/README.zh-hant.md new file mode 100644 index 000000000..a17505f3f --- /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 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈, +色彩從拍攝到匯出始終保持乾淨。 + +
Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder
Photo Grid with albums nested in a folder, ratings and flags. Demo library.
+ + + + +
+ +### ☀️ 光線 +**曝光、對比、亮部、陰影、白色色階、黑色色階**,搭配邊緣感知的局部色調映射(對對數亮度做引導式濾波)。 +把亮部拉到 −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` 會覆寫這些預設值。 +日常開發指令保留各自的設定。 + +**記憶體受限機器上的 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 b0e233f5b..ec392e88d 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-17 · **Change:** minor (watermark completeness reassessed after #2052; current checklist recount synchronized) · **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 @@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | | Value | Kind | |---|---:|---| -| **Feature breadth** (checklist) | **83.3%** of 539 rows (P0 98.3%, P1 94.7%, P2 55.7%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | | **Mainstream practitioner** | **~51%** (46–56%) | estimated | @@ -44,12 +44,12 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| -| Features | ~61% | ~77% (checklist 83.3%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | +| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | 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,6 +117,9 @@ 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 photographers on a verified body; headline numbers unchanged. @@ -183,18 +186,21 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | LR-EXP-WATERMARK reassessed as partial for existing shaping, bidi and font limits (#2052); current checklist recount 83.4% to 83.3% of 539 rows. Stale headline figures synchronized to that recount; readiness and effort estimates unchanged | +| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | | 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-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..777831942 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,95 @@ 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 { + fn new(open: &Arc, idle: Duration) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let (stream, _) = listener.accept().unwrap(); + 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)); + assert_eq!(open.load(Ordering::SeqCst), 1); + connection.client.get_mut().write_all(input.as_bytes()).unwrap(); + 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)); + next.client.get_mut().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 mut connection = Connection::new(&open, Duration::from_millis(100)); + for id in [1, 2] { + writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap(); + let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!(req.method, "engine.commands"); + assert!(req.take()); + // 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})); + } + connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + 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] @@ -337,6 +448,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/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 bec67597c..4eb002cef 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,22 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.nledford] +real_name = "Nathaniel Ledford" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true + +[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 d180d013d..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. @@ -49,7 +50,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option { if !(first..=last).contains(&t) { return None; } - let tail = iso.trim().get(19..).unwrap_or(""); + let iso = iso.trim(); + // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes + let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or(""); Some(format!("{}{tail}", civil(t))) } @@ -154,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)] @@ -197,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")); @@ -222,6 +251,24 @@ mod tests { assert_eq!(normalize_iso("2026-02-30"), None); } + #[test] + fn shift_keeps_the_zone_of_a_time_without_seconds() { + assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00")); + assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z")); + assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00")); + assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00")); + 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/journal.rs b/crates/catalog/src/journal.rs index 289ee4fb8..edcfae22a 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> { const START: &[u8] = b"{\"seq\":"; (1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| { let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?; - (rec.0 <= last + 1).then_some((i, rec)) + (rec.0 <= last.saturating_add(1)).then_some((i, rec)) }) } @@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() }) } +/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow. +const MAX_SEQ: u64 = u64::MAX / 2; + /// Just the identification of `catalog.snap` (read before the catalog itself). #[derive(serde::Deserialize)] struct SnapHeader { @@ -331,6 +334,10 @@ impl Journal { return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version))); } let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; + // a seq this high can't come from real use; the next record's seq would wrap + if s.seq > MAX_SEQ { + return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq))); + } let mut catalog = s.catalog; report.repaired = catalog.repair(); for r in &report.repaired { @@ -392,7 +399,7 @@ impl Journal { let _ = op; report.stale += 1; } - Some((seq, op)) if seq == j.seq + 1 => { + Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => { if catalog.apply(op).is_err() { report.failed += 1; } else { @@ -476,7 +483,7 @@ impl Journal { let mut buf = String::new(); let mut seq = self.seq; for op in ops { - seq += 1; + seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?; buf.push_str(&encode_record(seq, op)); buf.push('\n'); } @@ -527,7 +534,7 @@ impl Journal { log::warn!("catalog: background snapshot failed ({e}); writing one now"); } let t0 = web_time::Instant::now(); - let seq = self.seq + unlogged; + let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?; let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) { Ok(t) => t, Err(e) => { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 0c7ca5112..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"); @@ -643,6 +644,7 @@ mod edited_tests { ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R6 Mark II", "CR3", false), ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), @@ -652,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 d1548f8bd..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..]), @@ -157,13 +188,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool { Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; + // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. + let cmp32 = |x: f32, s: &str| match num(s) { + Some(('>', v)) => x > v as f32, + Some(('<', v)) => x < v as f32, + Some((_, v)) => x == v as f32, + None => false, + }; return match field { "rating" | "stars" => cmp(p.rating as f64, val), "flag" => Flag::parse(val) == Some(p.flag), "label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)), "iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)), - "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)), - "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), + "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)), + "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)), "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), @@ -173,11 +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), - _ => false, + // 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, unquote(&t)) +} + +/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location, +/// 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). @@ -395,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 a713b7736..84e89d124 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -294,15 +294,17 @@ pub fn iso_seconds(s: &str) -> Option { if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return None; } - let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(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; + 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) }; + 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 8f1aaf151..f8b72dad8 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,48 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain +/// text, not a fielded token that matches nothing. +#[test] +fn free_text_with_a_colon_searches_the_text_fields() { + let mut c = Catalog::new(); + let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00"); + let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.caption = "Meeting at 10:30 with Re: notes".into(); + m.iso = Some(1600); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("meeting"), vec![a]); + assert_eq!(q("10:30"), vec![a], "a free-text token with a colon"); + assert_eq!(q("re:"), vec![a]); + assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word"); + assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded"); + assert_eq!(q("iso:>2000"), Vec::::new()); + assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing"); +} + +/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary +/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value. +#[test] +fn search_tokens_match_f32_aperture_and_focal() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.aperture = Some(2.8); + m.focal_mm = Some(5.6); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("f:2.8"), vec![a], "f:2.8"); + assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8"); + assert_eq!(q("focal:5.6"), vec![a], "focal:5.6"); + assert!(q("f:2.9").is_empty(), "f:2.9"); + assert!(q("f:<2.8").is_empty(), "f:<2.8"); + assert!(q("f:>2.8").is_empty(), "f:>2.8"); + assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); + assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); +} + /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an /// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be /// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores @@ -794,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/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 8570bd077..26b4e902a 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() { let d = &loaded.photo(id).unwrap().develop; assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8)); } + +/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would +/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug. +#[test] +fn snapshot_with_seq_near_u64_max_is_corrupt() { + for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] { + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(), + ); + let e = Journal::open(Box::new(m)).err(); + assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}"); + } +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 1ae016bc8..589d285fa 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -55,3 +55,6 @@ jxl-oxide = { workspace = true } proptest = { workspace = true } # Lossless synthetic HEIC files for the `heif` feature's tests. lightcraft-heif = { workspace = true, features = ["testdata"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +dhat = { workspace = true } diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 6ea5866b7..5902243fc 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc` emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding. +Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity +become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths +preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1. + ## Dependencies and licences All pure Rust, verified with `cargo info` / `cargo tree`: diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..2cdf67381 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -44,11 +44,23 @@ impl Buf { match self { Buf::U8(v) => v[i] as f32 / 255.0, Buf::U16(v) => v[i] as f32 / 65535.0, - Buf::F32(v) => v[i], + Buf::F32(v) => normalize_float(v[i]), } } } +/// Clip non-finite device samples before colour conversion, preserving finite extended range. +#[inline] +fn normalize_float(value: f32) -> f32 { + if value == f32::INFINITY { + 1.0 + } else if value.is_finite() { + value + } else { + 0.0 + } +} + /// Interleaved device samples as produced by a format decoder. pub(crate) struct Raw { pub width: usize, @@ -87,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(_)); @@ -129,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) @@ -218,10 +233,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f { par_rows(&mut img.data, w, |y, row| { for (x, o) in row.iter_mut().enumerate() { let i = (y * w + x) * ch; - let s = |c: usize| { - let val = v[i + c]; - if val.is_finite() { val } else { 0.0 } - }; + let s = |c: usize| normalize_float(v[i + c]); *o = if gray { let l = if lin { s(0) } else { trc[1].to_linear(s(0)) }; [l, l, l] diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 26e144aed..adf59c0aa 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; -use jxl_oxide::{AuxBoxData, JxlImage}; +use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; const F: Format = Format::Jxl; @@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } +/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees. +/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517). +const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; + +/// The allocation budget never drops below this, so small images keep the decoder's fixed +/// overhead (group buffers, entropy tables) whatever `max_pixels` is. +const MIN_TRACKED_BYTES: u64 = 64 << 20; + +/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in). +const INPUT_CHUNK: usize = 4096; + +/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames +/// within a tracked allocation budget. +fn open(bytes: &[u8], opts: &DecodeOptions) -> Result { + let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES); + let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); + let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); + // Initialization can load already-buffered frame data, so small feeds limit work before + // dimension checking. + let mut rest = bytes; + let mut window = INPUT_CHUNK; + let mut img = loop { + let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?; + rest = rest.get(consumed..).unwrap_or_default(); + match uninit.try_init().map_err(err)? { + InitializeResult::Initialized(img) => break img, + InitializeResult::NeedMoreData(more) => uninit = more, + } + if consumed == 0 { + // the parser wants more than the window holds at once + if window >= rest.len() { + return Err(err("truncated JPEG XL header")); + } + window = window.saturating_mul(2); + } + }; + check_size(F, img.width() as u64, img.height() as u64, opts)?; + // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first + // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail + // after the last frame; feeding the remaining file at once bypasses the allocation budget. + while !img.is_loading_done() && !rest.is_empty() { + let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?; + if consumed == 0 { + break; + } + rest = rest.get(consumed..).unwrap_or_default(); + } + img.finalize().map_err(err)?; + Ok(img) +} + pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { - let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?; + let mut img = open(bytes, opts)?; let (w, h) = (img.width(), img.height()); - check_size(F, w as u64, h as u64, opts)?; let header = img.image_header(); let bits = header.metadata.bit_depth.bits_per_sample() as u8; let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); 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/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index b077c214a..1bbfa8e6c 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { fn limits(opts: &DecodeOptions) -> Limits { let mut limits = Limits::default(); limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; + // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a + // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap + // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows + // (JPEG headers, padding to the tile size). + limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size); limits.ifd_value_size = 32 << 20; limits } diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs new file mode 100644 index 000000000..fe1cd2411 --- /dev/null +++ b/crates/codecs/tests/float_tiff.rs @@ -0,0 +1,128 @@ +//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6). + +use lightcraft_codecs::{icc, *}; + +fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec { + encode_tiff( + &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)), + TiffCompression::None, + &EncodeMeta { icc: profile, ..Default::default() }, + ) + .unwrap() +} + +#[test] +fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() { + let samples = + [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]]; + let bytes = tiff(samples.as_flattened(), 3, None); + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(decoded.float); + assert_eq!(decoded.bit_depth, 32); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]); + let display = decoded.to_srgb8(); + assert_eq!(display.data[0], [255; 4]); + for pixel in &display.data[1..4] { + assert_eq!(*pixel, [0, 0, 0, 255]); + } + assert_eq!(display.data[6], [255, 188, 0, 255]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // A box-filtered pair must average clipped white with its ordinary neighbor. + let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None); + let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap(); + assert_eq!(small.image.data, [[0.625; 3]]); +} + +#[test] +fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() { + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25]; + let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25]; + let profiles = [ + None, + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)), + Some(icc::write_named(NamedSpace::Srgb)), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))), + Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()), + Some(b"invalid ICC profile".to_vec()), + ]; + for channels in [1, 3] { + let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + for profile in &profiles { + let profile = profile.as_deref(); + let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.grayscale, channels == 1); + assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space); + assert_eq!(decoded.to_srgb8(), reference.to_srgb8()); + assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve"); + assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve"); + } + } +} + +#[test] +fn float_tiff_nonfinite_alpha_is_finite_and_bounded() { + let samples = [ + [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY], + [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN], + [0.25, 0.5, 2.0, f32::NEG_INFINITY], + [2.0, 3.0, 4.0, 0.5], + ]; + let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // Associated alpha takes the same path before unpremultiplication. + let mut cursor = std::io::Cursor::new(Vec::new()); + { + use tiff::encoder::{TiffEncoder, colortype}; + let mut encoder = TiffEncoder::new(&mut cursor).unwrap(); + let mut image = encoder.new_image::(2, 1).unwrap(); + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap(); + } + let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]); + assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]); +} + +fn lut_profile() -> Vec { + use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd}; + let mut profile = ColorProfile::new_srgb(); + profile.cicp = None; + profile.red_colorant = Xyzd::default(); + profile.green_colorant = Xyzd::default(); + profile.blue_colorant = Xyzd::default(); + profile.red_trc = None; + profile.green_trc = None; + profile.blue_trc = None; + // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves. + profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) }, + b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + bias: Vector3d::default(), + })); + profile.encode().unwrap() +} + +#[test] +fn float_tiff_nonfinite_cms_samples_match_clipped_samples() { + let profile = lut_profile(); + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75]; + let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75]; + let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.space.origin, SpaceOrigin::IccCms); + assert_eq!(decoded.image, reference.image); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); +} diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs new file mode 100644 index 000000000..298034656 --- /dev/null +++ b/crates/codecs/tests/jxl_streaming.rs @@ -0,0 +1,98 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +fn codestream(bytes: &[u8]) -> Vec { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); +} + +#[test] +fn metadata_in_final_feed_matches_builder() { + let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let raw = codestream(&encoded); + let exif = minimal_exif(1); + let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field + exif_box.extend_from_slice(&exif); + // Split the codestream across two feeds, with Exif and XMP following its last frame. + let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec(); + box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl "); + let split = raw.len() / 2; + let mut part = 0u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[..split]); + box_bytes(&mut bytes, b"jxlp", &part); + let padding = vec![0; 4096 - bytes.len() - 8]; + box_bytes(&mut bytes, b"free", &padding); + let mut part = 0x8000_0001u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[split..]); + box_bytes(&mut bytes, b"jxlp", &part); + box_bytes(&mut bytes, b"Exif", &exif_box); + box_bytes(&mut bytes, b"xml ", b""); + assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk"); + let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap(); + let old_exif = match old.aux_boxes().first_exif().unwrap() { + jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(), + _ => panic!("builder must preserve Exif in its final feed"), + }; + let old_xml = match old.aux_boxes().first_xml() { + jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(), + _ => panic!("builder must preserve XMP in its final feed"), + }; + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice())); + assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap())); + assert_eq!(decoded.to_srgb8(), pixels); +} + +#[test] +fn progressive_truncation_matches_builder() { + let pixels = Rgba8::from_fn(96, 64, |x, y| { + let mut seed = (y * 96 + x + 1) as u32; + let mut rgb = [0; 4]; + for channel in &mut rgb[..3] { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *channel = seed as u8; + } + rgb[3] = 255; + rgb + }); + for lossless in [false, true] { + let encoded = + encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default()) + .unwrap(); + let raw = codestream(&encoded); + assert!(raw.len() > 4096, "fixture must exercise post-header feeds"); + for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] { + let bytes = &raw[..cut]; + // `finalize` permits partial images: rendering determines whether frame 0 is usable. + let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok()); + assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len()); + } + } +} diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs new file mode 100644 index 000000000..f2200e862 --- /dev/null +++ b/crates/codecs/tests/jxl_tail_heap.rs @@ -0,0 +1,43 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn codestream(bytes: &[u8]) -> Vec<u8> { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +#[test] +fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() { + let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let mut raw = codestream(&encoded); + let opts = DecodeOptions { max_pixels: 64, ..Default::default() }; + decode(&raw, opts).unwrap(); + raw.resize(raw.len() + (1 << 20), 0); + let _profiler = dhat::Profiler::builder().testing().build(); + let decoded = decode(&raw, opts).unwrap(); + assert_eq!((decoded.width, decoded.height), (8, 8)); + let peak = dhat::HeapStats::get().max_bytes; + eprintln!("peak heap bytes: {peak}"); + assert!(peak < (128 << 10), "tiny image used {peak} heap bytes"); +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index af689ae5d..d802366c3 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() { assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); } +/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877, +/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so +/// the thumbnail (and the full decode) failed with "decoder limits exceeded". +#[test] +fn tiff_single_strip_larger_than_128_mib() { + use tiff::encoder::{TiffEncoder, colortype}; + let (w, h) = (3923u32, 5877u32); + let mut cur = std::io::Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::<colortype::RGB16>(w, h).unwrap(); + im.rows_per_strip(h).unwrap(); + im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap(); + } + let bytes = cur.into_inner(); + assert!(bytes.len() > 128 << 20); + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!((t.source_width, t.source_height), (w, h)); + let px = t.image.get(10, 10); + assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}"); +} + +/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`. +fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec<u8> { + let mut b = b"II*\0".to_vec(); + b.extend(8u32.to_le_bytes()); + b.extend((entries.len() as u16).to_le_bytes()); + for &(tag, ty, value) in entries { + b.extend(tag.to_le_bytes()); + b.extend(ty.to_le_bytes()); + b.extend(1u32.to_le_bytes()); + b.extend(value.to_le_bytes()); + } + b.extend(0u32.to_le_bytes()); + b.extend_from_slice(data); + b +} + +/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so +/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB +/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at +/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989. +#[test] +fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() { + let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels }; + + // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget. + let mut jpeg = Vec::new(); + jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap(); + let at = 8 + 2 + 10 * 12 + 4; + let entries = [ + (256, 4, 1), + (257, 4, 1), + (258, 3, 8), + (259, 3, 7), + (262, 3, 1), + (273, 4, at), + (277, 3, 1), + (278, 4, 1), + (279, 4, jpeg.len() as u32), + (284, 3, 1), + ]; + let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap(); + assert_eq!((d.image.width, d.image.height), (1, 1)); + + // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels. + let at = 8 + 2 + 11 * 12 + 4; + let entries = [ + (256, 4, 16), + (257, 4, 16), + (258, 3, 8), + (259, 3, 1), + (262, 3, 1), + (277, 3, 1), + (284, 3, 1), + (322, 4, 256), + (323, 4, 256), + (324, 4, at), + (325, 4, 65536), + ]; + let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap(); + assert_eq!((d.image.width, d.image.height), (16, 16)); +} + /// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and /// already packed into `strip`, one padded row after another. fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> { @@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() { assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image)); } +/// #1517: the pixel limit rejects an oversized image; selected truncations never panic. +#[test] +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] +fn jxl_size_limit_and_truncation() { + let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}"); + let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (97, 61)); + for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] { + let _ = decode(&b[..cut], DecodeOptions::default()); + } + assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err()); +} + #[test] #[cfg(feature = "jxl")] fn jxl_lossy_quality_and_effort() { diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 855c3d049..89fe93c9f 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8); impl Rgb8 { pub fn from_hex(s: &str) -> Option<Rgb8> { - let s = s.trim_start_matches('#'); - if s.len() != 6 { + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let v = u32::from_str_radix(s, 16).ok()?; @@ -196,6 +196,15 @@ impl Rgb8 { mod tests { use super::*; + #[test] + fn rgb8_from_hex_rejects_signs_and_extra_hashes() { + assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15))); + assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0))); + for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] { + assert_eq!(Rgb8::from_hex(bad), None, "{bad}"); + } + } + #[test] fn srgb_matrix_matches_standard() { let m = SRGB.to_xyz(); diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 987a859b9..4314ed8c3 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -14,7 +14,7 @@ use crate::manifest::Gain; use crate::tiles::{starts, weight}; /// Most pixels one picture may have (guards the allocations that follow from its size). -pub const MAX_PIXELS: usize = 100_000_000; +pub const MAX_PIXELS: usize = 110_000_000; /// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from. const MIN_SIGNAL: f32 = 0.003; @@ -642,4 +642,19 @@ mod tests { assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_)))); assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_)))); } + + #[test] + fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() { + let layout = Layout { dx: 0, dy: 0 }; + let model = Mock { tile: 32, scale: 1.0 }; + let p = params(32, 8, Gain::None); + assert!(matches!( + denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the mosaic does not have width × height samples" + )); + assert!(matches!( + denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the picture has no size, or is too large" + )); + } } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index f376b3c67..4631e5e49 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -41,7 +41,7 @@ impl DevelopSettings { /// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that /// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a /// partial from before the scale existed, or one that never said, must not become Kelvin by landing on - /// Kelvin settings. + /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]). pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> { let mut v = self.to_json(); presets::deep_merge(&mut v, partial); @@ -52,9 +52,34 @@ impl DevelopSettings { { out.wb.scale = WbScale::Legacy; } + out.keep_in_range(self); Ok(out) } + /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`, + /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a + /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop + /// may reach past 0..1 and is left to the crop tools, which know the photo's size. + fn keep_in_range(&mut self, before: &DevelopSettings) { + for c in controls::CONTROLS { + if let Some(v) = controls::get(self, c.id) { + controls::set(self, c.id, v); + } + } + let c = &mut self.curve; + for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() { + *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + } + let g = &mut self.crop.geometry; + if g.angle == 0.0 { + g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT); + } + if g.rect.is_empty() { + let old = before.crop.geometry.rect; + g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old }; + } + } + /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. pub fn hash64(&self) -> u64 { let s = serde_json::to_string(self).unwrap_or_default(); @@ -255,6 +280,30 @@ mod tests { assert_eq!(m.light.exposure, 0.0); } + #[test] + fn merged_settings_are_kept_in_range() { + // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said + let mut base = DevelopSettings::default(); + base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9); + let m = base + .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}})) + .unwrap(); + assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0)); + // point curves stay in 0..1, as an imported XMP curve does + let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap(); + assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]); + // a crop with no area is left out, as XMP import leaves out one whose edges are inverted + for rect in + [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})] + { + let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}"); + } + // unrotated, the crop stays inside the photo + let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75)); + } + #[test] fn unedited_and_reset() { let mut s = DevelopSettings::default(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 85f27d321..b3edc801c 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { } /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped -/// through the control specs. +/// through the control specs, as every merge does ([`DevelopSettings::merged`]). pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) - let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); - for c in crate::controls::CONTROLS { - if let Some(v) = crate::controls::get(&out, c.id) { - crate::controls::set(&mut out, c.id, v); - } - } - out + s.merged(&patch).unwrap_or_else(|_| s.clone()) } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ed20cd605..9caa8cdb0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job workspace = true [dependencies] +# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share. +craft-docpath = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } @@ -44,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<String, String> { 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<CameraLook>) { - 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<CameraLook>, bool), monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>, + dark: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>, ) -> (Xy, CameraTransform, Option<CameraLook>) { 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<HsvTable>)>) -> Option<CameraLook> { + 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<HsvTable>)>) -> Option<CameraLook> { +fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>, dro: bool) -> Option<CameraLook> { 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<CameraTone> { /// 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<CameraLook> { // 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<Rgb32f> { 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<Pair>; fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, 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<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> { - 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<HsvTable>)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> { +fn search_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option<HsvTable>)>, + 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<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> { +fn fit_attempt( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option<HsvTable>)>, + attempt: Attempt, + bright: &HighlightCheck, +) -> Option<CameraLook> { 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<f32>, tone_fit: ToneFit, dark: bool, + highlights: &HighlightCheck, ) -> Option<CameraLook> { // 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<CameraLook> { - [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<CameraLook> { + [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<f32>) -> Option<CameraLook> { +fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>, highlights: &HighlightCheck) -> Option<CameraLook> { // 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<f32>) 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<CameraLook> { 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<HsvTable>)>) -> Option<CameraLook> { - [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<HsvTable>)>, + highlights: &HighlightCheck, +) -> Option<CameraLook> { + [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<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> { +/// [`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<HsvTable>)>) -> Option<CameraLook> { + 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<HsvTable>)>, + edge_limit: Option<f32>, + highlights: &HighlightCheck, +) -> Option<CameraLook> { // 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<CameraLook> { + 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<HsvTable>)>, + edge_limit: Option<f32>, + pairs: &Pairs, + dark: bool, + highlights: &HighlightCheck, +) -> Option<CameraLook> { + 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<CameraTone> { 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<CameraTone> { 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::<f64>() + }; + 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>) -> 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::<f64>() / 3.0 + }) + .sum::<f64>() + / 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::<f64>() / 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<Arc<CameraProfile>> { /// keys, so thumbnails rendered before a profile existed are not reused after. pub fn cache_key() -> u64 { static KEY: OnceLock<u64> = 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<PhotoId> { 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<Value> { - 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::<Vec<_>>()); - 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::<Vec<_>>()})) + 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 (`<name>-copy`, never overwriting) and add it with the @@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> { "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 676519d29..142accd5a 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -16,11 +16,14 @@ const SAME: f32 = 0.93; fn secs(iso: &str) -> Option<i64> { let d = lightcraft_meta::DateTime::parse_iso(iso)?; - Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64) + Some(d.unix_seconds()) } fn analyze(s: &mut Session, p: &Value) -> Result<Value> { let ids: Vec<PhotoId> = 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<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect(); let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) @@ -87,7 +90,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> { /// content), most similar first; the view is filtered to them. fn find_similar(s: &mut Session, p: &Value) -> Result<Value> { 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<PhotoId> = 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<Value> { Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()})) } -fn ids_param(p: &Value) -> Option<Vec<PhotoId>> { - 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<PhotoId>) -> 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<Value> { 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<Value> { } fn cancel(s: &mut Session, p: &Value) -> Result<Value> { - 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<Value> { 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<CommandSpec> { } refit_crop_angle(angle, before, d, dims); }; - if let Some(ids) = p.get("ids").and_then(Value::as_array) { - let ids: Vec<PhotoId> = 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<CommandSpec> { 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<f64> = 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<CommandSpec> { (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<CommandSpec> { "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<CommandSpec> { 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<CommandSpec> { |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<Value, String> { + 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<Value, String> { 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<Value> { 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, &params)?; + // 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(&params, Default::default())); + crate::export::ExportOptions::validate_as(ID, &params, 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<Value> { /// makes the face yours: a later "Detect Faces" run no longer replaces it. fn set_name(s: &mut Session, p: &Value) -> Result<Value> { 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<PhotoId> = - 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<PhotoId> = 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<Value> { 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<Value> { } 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<CommandSpec> { "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 7b0e1e562..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<String> { 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<Vec<PhotoId>> { - 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<PhotoId>) -> (Vec<PhotoId>, usize) { @@ -623,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> { "{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<CommandSpec> { "{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<CommandSpec> { "{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<CommandSpec> { |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<CommandSpec> { 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<CommandSpec> { "{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<CommandSpec> { _ => 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<CommandSpec> { 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| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"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::<Vec<_>>(),"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/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs index 1a1b8b23c..8af63cd9b 100644 --- a/crates/engine/src/cmd/library/tests_relative_ratings.rs +++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs @@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() { std::fs::create_dir_all(&dir).unwrap(); // Metadata-only fixtures: no import, image decoding or model/service dependency. for id in 1..=4 { + std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap(); s.catalog .apply(Op::SetFile { id: PhotoId(id), 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<Value> { 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/manage.rs b/crates/engine/src/cmd/manage.rs index 66ddfe9fb..339b20b90 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -9,8 +9,13 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); - let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p)?, template, start)) + let start = p.get("start").and_then(Value::as_u64).unwrap_or(1); + let ids = s.targets(p)?; + // every number of the sequence `start..start + n` must fit + let last = start.checked_add((ids.len() as u64).saturating_sub(1)); + let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok()); + let start = start.ok_or_else(|| bad(c, "start out of range"))?; + Ok((ids, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). 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<usize> { /// The selected mask's adjustments on the active photo. fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> { 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<Value> { // the masks: `ids`, `all`, else `id` or the selected one let ids: Vec<u32> = 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::<Result<_>>()?, + 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<Br out } -fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> { - 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<u32> { + 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<Option<u32>> { + 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<u32>, c: &str) -> Result<u32> { + 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<Option<usize>> { + 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<usize> { @@ -307,7 +327,7 @@ pub fn specs() -> Vec<CommandSpec> { 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<CommandSpec> { 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<CommandSpec> { 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<CommandSpec> { 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<CommandSpec> { 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<CommandSpec> { } ), 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<CommandSpec> { |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<MaskOp> = 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<CommandSpec> { ), 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<usize> { - 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<Value> { 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<Option<Vec<lightcraft_catalog::PhotoId>>> { + 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::<Result<Vec<_>>>() + .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<Option<lightcraft_catalog::PhotoId>> { + 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<String>) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> { 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<usize> { + 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<lightcraft_geom::Point> { 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<CommandSpec> { 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<CommandSpec> { "{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<StackId> = 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<Value> { } /// 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<crate::media::SourceRef>, 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<Value>, } -/// `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<crate::media::SourceRef> { 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<Value> { } 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<Value> { ) } -/// 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<std::thread::JoinHandle<()>> { let dir = s.media.smart_dir.clone()?; @@ -375,9 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa return None; } let max_edge = crate::media::SourceLevel::Preview.max_edge(); - let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); - let jobs: Vec<SmartJob> = - 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<SmartJob> = 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<SmartJob>) { let stale: Vec<SmartJob> = 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/print.rs b/crates/engine/src/cmd/print.rs index 05a851893..26f5d9bc4 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", always, get), cmd!(query "print.set", "Set Print Settings", [], None, - "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", always, set), cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), cmd!(query "print.cell.add", "Add Print Cell", [], None, 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<PhotoId> { - 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<CommandSpec> { .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<Value> = 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<PhotoId, u64>, +} + +/// 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<Vec<Item>> { + 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<FileProbe> { + 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<std::result::Result<ProbeInfo, String>> { + let paths: Vec<String> = 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<Item>, results: Vec<std::result::Result<ProbeInfo, String>>) -> Result<Vec<PhotoId>> { + 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::<Vec<_>>()}) +} 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<CommandSpec> { "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<String> = 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#"<crs:Look rdf:parseType="Resource"><crs:Name>Vivid</crs:Name></crs:Look>"#; + assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::<String>::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/crs_masks.rs b/crates/engine/src/crs_masks.rs index f5384e23d..848251c65 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value { Value::Object(a) } +/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set: +/// reported, never dropped silently. +fn unmapped_local(c: &XmpValue) -> Vec<String> { + let XmpValue::Struct(m) = c else { return Vec::new() }; + m.keys() + .filter_map(|k| { + let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?; + // 100 = "curve saturation unchanged", written with every correction + let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 }; + let set = num(c, k).is_some_and(|v| v != neutral); + (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string()) + }) + .collect() +} + /// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log /// luminance on −8..+4 EV mapped to 0..1. fn lightness_to_range(l: f64) -> f64 { @@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String Some("2") => { let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); let [a, b, c, d] = q[..] else { return Err("luminance range".into()) }; + // full strength at white (1, the top of their scale) takes in everything brighter: ours + // goes on above scene white, so that end stays open instead of cutting off at 0 EV + let open_top = c >= 1.0; let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d)); - json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)}) + let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) }; + json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather}) } Some("3") => { let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); @@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) { if parts.is_empty() { continue; } + skipped.extend(unmapped_local(c)); // brush dabs of one correction are one brush component let mut merged: Vec<Value> = Vec::new(); for p in parts { @@ -506,6 +526,28 @@ mod tests { assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } + #[test] + fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"> + <crs:MaskGroupBasedCorrections><rdf:Seq> + <rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100"> + <crs:CorrectionMasks><rdf:Seq> + <rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li> + </rdf:Seq></crs:CorrectionMasks> + </rdf:Description></rdf:li> + </rdf:Seq></crs:MaskGroupBasedCorrections> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); + } + #[test] fn lrtemplate_corrections() { let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 000000000..c2b0e9f69 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,63 @@ +//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the +//! originals a Lightroom catalog lists. +//! +//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the +//! document's folder, and only local files, or files on the network share the document itself is +//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing +//! file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is on another computer (other than the document's share), names a device, is a + /// URL, or is not a usable path. + Refused(Refused), + /// The path is not an absolute path on this computer, such as a Windows drive path read on + /// macOS or Linux. + NotHere, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NotHere => f.write_str("the path is not a path on this computer"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`, +/// or why it is not followed. A relative `document` is taken from the working directory, where it +/// was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> { + 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); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() { + let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat"); + assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg"))); + assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere)); + 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))); + } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index d294ed4d9..bfeade59c 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) @@ -721,6 +726,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "height", "megapixels", "percent", + "fit", "dontEnlarge", "ppi", "limitKb", @@ -816,8 +822,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())); @@ -828,13 +843,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> { - 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<Self> { + 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 @@ -842,6 +888,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<ExportFormat>) -> 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())) }; @@ -894,6 +946,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")) { @@ -949,6 +1002,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<u8> = 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<String> = 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(()) } @@ -1075,8 +1140,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) => { @@ -1310,7 +1379,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let e = EncodeImage::rgba8(&img); + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; let r = match o.format { ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). @@ -2013,6 +2090,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) @@ -2030,13 +2110,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<Exported, String> { @@ -2281,6 +2368,43 @@ mod tests { img } + /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. + #[test] + fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } + } + + #[test] + fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } + } + #[test] fn formats_have_magic() { let img = test_image(); @@ -2768,6 +2892,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))); @@ -2782,6 +2913,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 @@ -2853,6 +2986,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), ] { @@ -3149,6 +3283,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")); @@ -3167,6 +3302,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() }; @@ -3203,6 +3339,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] @@ -3429,6 +3610,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/faces_index.rs b/crates/engine/src/faces_index.rs index bf10e3502..17bffbc19 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -167,6 +167,9 @@ impl Index { self.entries.insert((photo, key), (emb.into(), view)); } } + // a half-written last record (a crash while appending): written fresh at the next save, so later records are + // not appended after it and shifted + self.rewrite = !rest.is_empty(); } fn header(&self) -> Vec<u8> { @@ -201,12 +204,11 @@ impl Index { return Ok(()); } if self.file_matches && !self.rewrite { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let mut buf = Vec::new(); for (k, e) in &self.unsaved { buf.extend(self.record(k, e)); } - f.write_all(&buf)?; + append(&path, &buf)?; } else { let mut buf = self.header(); for (k, e) in &self.entries { @@ -229,6 +231,22 @@ impl Drop for Index { } } +/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so +/// the part that got in does not shift what is appended later. +fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new().append(true).open(path)?; + let old_len = f.metadata()?.len(); + let written = f.write_all(buf); + if written.is_err() { + drop(f); + // an append-only handle may not truncate (Windows): cut through a writable one + if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) { + let _ = w.set_len(old_len); + } + } + written +} + const SCAN_MAGIC: &[u8; 4] = b"LCFS"; const SCAN_VERSION: u8 = 1; /// The detector a search was made with: a record made by another one is ignored (a better detector may find more). @@ -325,8 +343,11 @@ impl Scanned { let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; - // a half-written last id (a crash while appending) is ignored - self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c))); + // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save + // so later ids are not appended after it and shifted + let (ids, torn) = rest.as_chunks::<8>(); + self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c))); + self.rewrite = !torn.is_empty(); } /// Write what was added since the last save: appended when the file is this detector's, else the whole record @@ -341,9 +362,8 @@ impl Scanned { return Ok(()); } if self.file_matches && !self.rewrite { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); - f.write_all(&buf)?; + append(&path, &buf)?; } else { let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); @@ -487,6 +507,51 @@ mod tests { assert!(!t.contains(1) && t.contains(2) && t.contains(3)); let _ = std::fs::remove_dir_all(&dir); } + + /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it + /// (issue #2129). + #[test] + fn records_appended_after_a_torn_tail_stay_aligned() { + let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let junk = |p: &Path| { + let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap(); + f.write_all(&[0x3f; 5]).unwrap(); + }; + let mut a = Index::default(); + a.reset("m@abc", 3, Some(emb.clone())); + a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]); + a.save().unwrap(); + drop(a); + junk(&emb); + let mut b = Index::default(); + b.reset("m@abc", 3, Some(emb.clone())); + b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]); + b.save().unwrap(); + drop(b); + let mut c = Index::default(); + c.reset("m@abc", 3, Some(emb)); + assert_eq!(c.len(), 2); + assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5])); + assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125])); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(42); + s.save().unwrap(); + drop(s); + junk(&scan); + let mut t = Scanned::default(); + t.reset(Some(scan.clone())); + t.insert(43); + t.save().unwrap(); + drop(t); + let mut u = Scanned::default(); + u.reset(Some(scan)); + assert!(u.contains(42) && u.contains(43) && u.len() == 2); + let _ = std::fs::remove_dir_all(&dir); + } } /// What the recognition commands keep between calls: the loaded model and the embeddings made with it. diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..0670fb763 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<String>) { 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<usi /// instead of each one waking the pool from outside and waiting for it (which costs more than the /// loops themselves when the machine is busy). pub fn load_bytes(bytes: &[u8], max_edge: usize) -> 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<u8>, 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<u8>, 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<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, 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<Rgb32f>, SourceInfo), String> { + allow_reduced: bool, +) -> Result<(Rgb32f, Option<Rgb32f>, 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 @@ -707,8 +739,9 @@ pub(crate) fn decode_raw_preview( covering: Option<usize>, ) -> Option<lightcraft_codecs::Decoded> { let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader - let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_)); + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { Some(space) => { let space = match space { @@ -1059,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); @@ -1081,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<T>(path: &str, max_edge: usize, load: impl FnOnce(Vec<u8>) -> Result<T, String>) -> Result<T, String> { + 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<String> { @@ -1095,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()); @@ -1182,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 @@ -1658,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 @@ -1830,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<u8> = 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 { @@ -2089,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::<f64>() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / 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(&copy) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(&copy), 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. @@ -2392,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<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); 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<String>, 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<lightcraft_catalog::Catalog> { + 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<String>, 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<Result<ProbeInfo, String>> = 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<EmbeddedLens> { 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("<model>Testcam 18-55mm f/3.5-5.6</model>", "<model>Testcam 18-55mm f/3.5-5.6</model><model>18-55mm f/3.5-5.6</model>"), + ) + .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 8a282ce3e..323ed90ef 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod demo; pub mod denoise; pub mod devices; pub mod display; +pub mod doc_path; mod errors; pub mod export; pub mod face_download; @@ -56,6 +57,7 @@ pub mod media; pub mod memory; pub mod merge; mod model_download; +pub mod open_path; pub mod originals; mod pdf; pub mod preset_import; @@ -184,6 +186,10 @@ pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc<DevelopSettings>, + /// 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. @@ -238,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<PhotoId>, @@ -298,6 +306,10 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option<serde_json::Value>, + /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`, + /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses + /// them. Persisted in prefs.json. + pub last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec<export::ExportPreset>, /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives @@ -428,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, @@ -459,6 +472,7 @@ impl Session { library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, + last_merge: Default::default(), export_presets: Vec::new(), print: Default::default(), print_templates: Vec::new(), @@ -874,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<Op> { if !self.auto_sync || self.active() != Some(id) @@ -900,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); @@ -915,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() @@ -937,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(()) } @@ -948,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<()> { @@ -1242,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; @@ -1268,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 51655aea5..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<String>, + /// 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<String>, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option<web_time::Instant>, /// What forgetting untouched Local records did when the library opened. @@ -207,6 +210,8 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option<serde_json::Value>, + /// Options of the last Photo Merge, per command. + last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets. export_presets: Vec<crate::export::ExportPreset>, /// Print templates. @@ -492,6 +497,7 @@ impl Session { let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.last_merge = prefs.last_merge; self.export_presets = prefs.export_presets; self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; @@ -552,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); @@ -574,6 +583,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -648,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(()) @@ -661,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); @@ -669,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. @@ -746,6 +767,7 @@ impl Session { let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone(), + last_merge: self.last_merge.clone(), export_presets: self.export_presets.clone(), print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 0dceb421d..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(), } } @@ -538,6 +539,7 @@ mod tests { keywords: vec!["Trips|Goa".into()], history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])], snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])], + original: crate::lightroom_catalog::Original::Local, }; CatalogImport { source: "source.lrcat".into(), @@ -546,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(), } } @@ -621,6 +624,7 @@ mod tests { keywords: Vec::new(), history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])], snapshots: Vec::new(), + original: crate::lightroom_catalog::Original::Local, }) .collect(); assert_eq!(store_best_effort(&path, &input).unwrap(), None); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 777206bcb..797a96708 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -2,13 +2,13 @@ //! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. //! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::{ Arc, 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}; @@ -146,6 +146,31 @@ pub struct CatalogPhoto { pub keywords: Vec<String>, pub history: Vec<Row>, pub snapshots: Vec<Row>, + /// Whether `path` may be looked at (derived when the catalog is read; not archived). + #[serde(skip)] + pub original: Original, +} + +/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog +/// as the document ([`crate::doc_path::resolve`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Original { + /// `path` is a local file path, or one on the network share the catalog is on. + Local, + /// `path` is not a path on this computer (a catalog made on another system): the photo is + /// kept as an offline record for later relinking, and the path is not looked at. + #[default] + NotHere, + /// `path` is refused (why): it is on another computer or a device, or its folder or file name + /// is an absolute path. The photo is reported missing and not added to the library. + Refused(String), +} + +impl CatalogPhoto { + /// Whether the original is a file here; only a [`Original::Local`] path is looked at. + pub fn original_is_file(&self) -> bool { + self.original == Original::Local && Path::new(&self.path).is_file() + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -156,6 +181,34 @@ pub struct CatalogImport { pub members: Vec<Row>, pub collection_content: Vec<Row>, pub warnings: Vec<String>, + /// 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<SkippedImage>, +} + +/// 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)] @@ -343,25 +396,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> { /// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from /// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the -/// catalog's volume, resolved against the catalog's folder, if that is a folder. +/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are +/// looked at only when [`crate::doc_path::resolve`] follows them. fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> { - if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() { + if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) { return None; } - let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative); - let mut out = PathBuf::new(); - for part in dir.components() { - match part { - Component::ParentDir => { - if !out.pop() { - return None; - } - } - Component::CurDir => {} - part => out.push(part), - } + crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir()) +} + +/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in +/// place of the root folder. +fn is_absolute_part(part: &str) -> bool { + let bytes = part.as_bytes(); + part.starts_with(['/', '\\']) + || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':')) + || Path::new(part).is_absolute() +} + +/// The path of an original under `root` and whether it may be looked at, resolved with the +/// catalog at `catalog` as the document. +fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) { + let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string(); + if is_absolute_part(folder) || is_absolute_part(name) { + return (stored, Original::Refused("its folder or file name is an absolute path".into())); + } + match crate::doc_path::resolve(&stored, Some(catalog)) { + Ok(found) => (found.to_string_lossy().to_string(), Original::Local), + Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere), + Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())), } - out.is_dir().then_some(out) } /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. @@ -372,6 +436,9 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> { 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<CatalogImport, String> { // 13 catalog tables plus one validation stage; ImportJob adds one unit per original path. total.store(14, Ordering::Relaxed); @@ -380,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 { @@ -457,6 +524,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out }; let mut root_paths = HashMap::new(); + let mut refused_roots = HashSet::new(); let mut root_ids: Vec<_> = roots.keys().copied().collect(); root_ids.sort_unstable(); for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) { @@ -469,6 +537,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } None => absolute.to_string(), }; + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) { + warnings + .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name"))); + refused_roots.insert(id); + } root_paths.insert(id, resolved); } let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); @@ -480,20 +553,43 @@ 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 root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str); + 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 + && !refused_roots.contains(&root_id) + { + out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added")); } - let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string(); let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) { Ok(s) => s, Err(e) => { @@ -504,19 +600,89 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out.photos.push(CatalogPhoto { source_id: id, uuid: text(&image, "id_global").into(), - path, + path: photo_path, image, settings: develops.get(&id).cloned().unwrap_or_default(), xmp: packet, keywords: keywords.remove(&id).unwrap_or_default(), history: history.remove(&id).unwrap_or_default(), snapshots: snapshots.remove(&id).unwrap_or_default(), + 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<i64>) { + 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. +/// +/// The catalog decides these paths, and a record it leaves behind is one click from the system +/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`, +/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run +/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not +/// openable. A virtual copy of a refused original goes with it — it has no original of its own. +/// +/// Called where the catalog is read and again where its photos become records, so neither route +/// can keep one; the second pass finds nothing left to say. +pub(crate) fn refuse_non_media(data: &mut CatalogImport) { + // masters first: a virtual copy may come before its master in the table + let refused: HashSet<i64> = data + .photos + .iter() + .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path))) + .map(|p| p.source_id) + .collect(); + if refused.is_empty() { + return; + } + let mut warnings = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not + // take every master of the catalog with it + if master != 0 { + if !refused.contains(&master) { + return true; + } + warnings.push(format!("image {}: not imported; its original was not imported", p.source_id)); + return false; + } + if !refused.contains(&p.source_id) { + return true; + } + warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path)); + false + }); + data.warnings.extend(warnings); +} + fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } @@ -665,11 +831,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> let paths: Vec<_> = data .photos .iter() - .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file()) + .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file()) .map(|p| p.path.clone()) .collect(); let missing_sources: HashSet<String> = - data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect(); + data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect(); let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?; let applied = apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?; @@ -692,8 +858,12 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } -pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> { +/// 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<AppliedImport> { 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) + refuse_non_media(&mut data); validate(&data).map_err(error)?; let mut warnings = data.warnings.clone(); let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect(); @@ -713,12 +883,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); + let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect(); let mut planned: HashMap<PhotoId, Photo> = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); if master == 0 && missing_sources.contains(&key(&src.path)) { missing.push(src.path.clone()); } + // A refused path must not become a library record the app would look at later; its + // virtual copies go with it. + if refused.contains(&src.source_id) || refused.contains(&master) { + continue; + } let indexed = index .photos .get(&identity(&data, src)) @@ -917,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } 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(), }) } @@ -940,6 +1116,7 @@ mod tests { keywords: vec!["Travel|Goa".into()], history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], snapshots: Vec::new(), + original: Original::Local, }; let mut copy = photo.clone(); copy.source_id = 2; @@ -960,8 +1137,51 @@ 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 + /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual + /// copies go with it; the catalog's real photos still import. + #[test] + fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); + } + #[test] fn offline_local_master_and_copy_join_library_with_one_undo_step() { let mut s = crate::Session::new(); @@ -1377,6 +1597,16 @@ mod tests { } /// A one-row-per-table SQLite file: `(name, columns, row)` each. fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> { + 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<SqlValue>)], last_page: Option<i64>) -> Vec<u8> { + 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<Vec<SqlValue>>)], last_page: Option<i64>) -> Vec<u8> { 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"); @@ -1390,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 @@ -1432,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<Vec<SqlValue>>)], 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<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> { + 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())); @@ -1508,11 +1831,57 @@ mod tests { // a relative path that leads nowhere keeps the recorded one std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); let catalog = read(&path).unwrap(); - assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn originals_on_other_computers_are_never_looked_at_or_added() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + let source = dir.join("source.lrcat"); + let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; + if cfg!(unix) { + // `//<local folder>/` is a local folder to Linux, but a network path to the resolver. + roots.push(format!("/{}/", dir.display())); + } + for root in &roots { + std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); + assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a refused original must not be probed".into()) + })); + let report = import(&mut s, &source, false).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); + assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); + assert!(s.catalog.is_empty(), "{root}"); + } + // An absolute folder path would replace the root. + let mut tables = photo_tables((dir.to_str().unwrap(), "")); + tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); + // A root beside the catalog is followed. + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert_eq!(catalog.photos[0].original, Original::Local); + assert!(catalog.photos[0].original_is_file()); + // A root that moved is found through its path relative to the catalog. + std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn oversized_optional_table_warns_and_is_skipped() { let mut main = vec![0u8; 2 * 512]; diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 8de1cc806..762c8588d 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V if !seen.insert(key.clone()) { continue; } - if Path::new(&photo.path).is_file() { + if photo.original_is_file() { paths.push(photo.path.clone()); } else { missing.insert(key); @@ -355,6 +355,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } @@ -393,11 +394,13 @@ mod tests { keywords: Vec::new(), history: Vec::new(), snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, }], collections: Vec::new(), members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), }, normal: Prepared::default(), index: ImportIndex::default(), @@ -531,6 +534,7 @@ mod tests { serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))), )])], snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, } }) .collect(); 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<File>, + path: PathBuf, + before: Metadata, + header: Vec<u8>, +} + +impl FileSource { + pub fn open(path: &Path) -> Result<Self, String> { + 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<Vec<u8>, 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<u8>, + file: Option<FileSource>, overlay: HashMap<u32, Vec<u8>>, 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<u8>, committed_wal: &[u8]) -> Result<Self, String> { + Self::open(main, None, committed_wal) + } + + pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result<Self, String> { + 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<u8>, file: Option<FileSource>, committed_wal: &[u8]) -> Result<Self, String> { 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<Cow<'_, [u8]>, 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<usize>, 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<u32, String> { 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<u8> { 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<dyn Fn(&str, usize) -> 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<dyn Fn(&str, usize) -> 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<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>; @@ -183,11 +188,17 @@ pub struct DecodedSource { pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option<Arc<Twin>>, + /// 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<Rgb32f>, info: Option<SourceInfo>) -> 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<std::path::PathBuf>, /// 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<ReducedLoader>, }, /// 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<FileLoader>, + /// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`]. + pub reduced_loader: Option<ReducedLoader>, pub file_probe: Option<FileProbe>, pub preview_loader: Option<PreviewLoader>, /// 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<crate::face_tiles::PreviewCut>, + /// 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<RenderJob> { 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<RenderJob, String> { 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<MergeJob> { 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/open_path.rs b/crates/engine/src/open_path.rs new file mode 100644 index 000000000..d80039729 --- /dev/null +++ b/crates/engine/src/open_path.rs @@ -0,0 +1,117 @@ +//! Whether a path the library holds may be handed to the operating system. +//! +//! A photo's `Source::File` path is not always something a user typed: it can come from an +//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder. +//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever +//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta` +//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135). +//! +//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular +//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which +//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must +//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the +//! absolute path to hand over; what comes back on refusal is the sentence to show the user. +//! +//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may +//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a +//! refused original never becomes a library record in the first place. + +use std::path::Path; + +/// The absolute path to hand to an opener or external editor, or the reason it is refused. +/// +/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses +/// the path's own file name. +pub fn openable(path: &str, label: &str) -> Result<String, String> { + let p = Path::new(path); + let name = match label.trim() { + "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()), + l => l.to_string(), + }; + if !is_media(p) { + return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application")); + } + match std::fs::metadata(p) { + Ok(m) if m.is_file() => {} + Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")), + Err(e) => return Err(format!("the original is missing: {path} ({e})")), + } + // a link may name a photo and point at a program; what runs is what it points at + if let Ok(target) = p.canonicalize() + && !is_media(&target) + { + return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display())); + } + let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?; + Ok(absolute.to_string_lossy().into_owned()) +} + +/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included). +pub fn is_media(path: &Path) -> bool { + crate::import::is_supported(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, b"x").unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn only_file_types_lightcraft_reads_are_handed_over() { + let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] { + let path = write(&dir, good); + assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads"); + } + for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] { + let path = write(&dir, bad); + let e = openable(&path, "").unwrap_err(); + assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}"); + } + // a bundle (macOS `.app`) and any other folder are not files + let bundle = dir.join("Evil.app"); + std::fs::create_dir_all(&bundle).unwrap(); + let e = openable(&bundle.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}"); + let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err(); + assert!(e.contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A relative path is made absolute: the opener runs with its own working directory. + #[test] + fn the_path_handed_over_is_absolute() { + let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = write(&dir, "a.jpg"); + let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg"); + let out = openable(&dotted.to_string_lossy(), "").unwrap(); + assert!(Path::new(&out).is_absolute(), "{out}"); + assert!(out.ends_with("a.jpg"), "{out}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A link named like a photo that points at a program is refused: what the opener runs is + /// the target, not the name. + #[cfg(unix)] + #[test] + fn a_link_to_a_program_is_refused() { + let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = write(&dir, "payload.command"); + let link = dir.join("holiday.jpg"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let e = openable(&link.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("points at") && e.contains("payload.command"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index 91e5db69e..f4e50703b 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -1,5 +1,6 @@ //! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), -//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s. +//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop +//! marks, page number, page info) and returns [`PageRaster`]s. //! //! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one //! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a @@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0; pub const MAX_STROKE_PT: f64 = 36.0; /// Renders kept for cells that print the same photo at the same size. const RENDER_CACHE: usize = 4; +/// A crop mark's length and its gap from the photo's corner, inches. +pub const CROP_MARK_IN: f64 = 0.25; +pub const CROP_MARK_GAP_IN: f64 = 0.0625; +/// A crop mark's weight, points. +pub const CROP_MARK_PT: f64 = 0.5; +/// Size of the page number and page info text, points. +pub const PAGE_TEXT_PT: f64 = 9.0; /// Lightroom's Image Settings panel. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -85,11 +93,18 @@ impl ImageSettings { pub struct PageStyle { /// Page background colour, sRGB. pub background: [u8; 3], + /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them): + /// the page number at the bottom right of each page. + pub page_numbers: bool, + /// The print settings (sharpening, profile, resolution) at the bottom left of each page. + pub page_info: bool, + /// Marks at the corners of each photo to cut along. + pub crop_marks: bool, } impl Default for PageStyle { fn default() -> Self { - Self { background: [255; 3] } + Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false } } } @@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) } +/// Whether a layout style prints the Page Options (page numbers, page info, crop marks): +/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead. +pub fn has_page_options(style: Style) -> bool { + !style.is_package() +} + +/// The colour of crop marks and page text on `background`: black, or white on a dark page. +pub fn mark_ink(background: [u8; 3]) -> [u8; 3] { + let [r, g, b] = background.map(f64::from); + if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] } +} + +/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer +/// where the resolution is; this print goes to a PDF). +pub fn page_info_text(settings: &PrintSettings) -> String { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let c = &settings.job.color; + let media = match c.media { + PrintMedia::Matte => "Matte", + PrintMedia::Glossy => "Glossy", + }; + let sharpen = match c.sharpen { + PrintSharpen::Off => "Off".to_string(), + PrintSharpen::Low => format!("Low ({media})"), + PrintSharpen::Standard => format!("Standard ({media})"), + PrintSharpen::High => format!("High ({media})"), + }; + let profile = match &c.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi()) +} + // ---- placement ------------------------------------------------------------------------------ /// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. @@ -355,8 +405,8 @@ impl CellStyle { } /// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when -/// asked), then the inner stroke on top of it. -pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> { +/// asked), then the inner stroke on top of it. Returns where the photo landed. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> { // The border may not eat the cell: at least one pixel of photo area stays. let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); let (ax, ay) = (cell.x + border, cell.y + border); @@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); } - Ok(()) + Ok(PxRect { x: dx, y: dy, w, h }) +} + +/// What the Page Options draw on every page, in page pixels. +struct Marks { + /// Crop mark length, gap from the corner and weight. + crop: Option<(usize, usize, usize)>, + numbers: bool, + info: Option<String>, + ink: [u16; 3], + text_px: f32, + /// The printable area: the text sits in the margin below it, aligned with its sides. + area: Option<PxRect>, +} + +impl Marks { + fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self { + let on = has_page_options(settings.layout.style); + let page = &settings.page; + let px = |inches: f64| (inches * ppi).round() as usize; + Marks { + crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))), + numbers: on && page.page_numbers, + info: (on && page.page_info).then(|| page_info_text(settings)), + ink: rec2020_from_srgb8(mark_ink(page.background)), + text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32, + area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)), + } + } + + /// Draws the marks of page `number` (one-based) around `photos`, the photos on it. + fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) { + if let Some((len, gap, weight)) = self.crop { + for r in photos { + crop_marks(page, r, len, gap, weight, photos, self.ink); + } + } + let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h }); + let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32); + // centred in the bottom margin, and on the paper when the margin is thinner than the text + let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0)); + if self.numbers + && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS) + { + let x = ((area.x + area.w) as f32 - t.width).max(0.0); + draw_text(page, &t, x, top(t.height), self.ink); + } + if let Some(info) = &self.info + && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS) + { + draw_text(page, &t, area.x as f32, top(t.height), self.ink); + } + } +} + +/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts +/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo. +fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) { + let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64); + let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64); + let segments = [ + (x0 - g - l, y0, l, t), + (x1 + g, y0, l, t), + (x0 - g - l, y1 - t, l, t), + (x1 + g, y1 - t, l, t), + (x0, y0 - g - l, t, l), + (x0, y1 + g, t, l), + (x1 - t, y0 - g - l, t, l), + (x1 - t, y1 + g, t, l), + ]; + let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h); + for (sx, sy, sw, sh) in segments { + for y in sy.max(0)..(sy + sh).min(page.h as i64) { + for x in sx.max(0)..(sx + sw).min(page.w as i64) { + let (x, y) = (x as usize, y as usize); + if !on_photo(x, y) { + page.fill_rect(x, y, 1, 1, ink); + } + } + } + } +} + +/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`). +fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) { + let (w, h) = (page.w, page.h); + text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| { + let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return }; + if gx >= w || gy >= h { + return; + } + let i = (gy * w + gx) * 3; + if let Some(px) = page.rgb.get_mut(i..i + 3) { + let k = cov.clamp(0.0, 1.0); + for (p, c) in px.iter_mut().zip(ink) { + *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16; + } + } + }); } // ---- the job --------------------------------------------------------------------------------- @@ -402,6 +550,7 @@ pub struct Prepared { ppi: f32, background: [u16; 3], style: CellStyle, + marks: Marks, /// Print sharpening, applied to each photo once it's rendered at its print size. color: crate::print_color::PrintColorSettings, } @@ -430,6 +579,7 @@ impl Prepared { let mut out = Vec::with_capacity(self.pages.len()); for (index, &(w, h)) in self.pages.iter().enumerate() { let mut page = Image16::filled(w, h, self.background)?; + let mut placed = Vec::new(); while let Some(item) = items.next_if(|i| i.page == index) { if !progress(done, &item.name) { return Err("Print cancelled".into()); @@ -448,8 +598,9 @@ impl Prepared { } }; let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; - draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?; + placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?); } + self.marks.draw(&mut page, &placed, index + 1); out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); } if !progress(done, "") { @@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style, + marks: Marks::of(&settings, ppi, pw, ph), color: settings.job.color.clone(), }) } @@ -825,6 +977,94 @@ mod tests { assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); } + /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the + /// margins are bare background unless something draws there). + fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() + } + + fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + } + + /// The pixels of `x0..x1` x `y0..y1` that are not the white background. + fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() + } + + #[test] + fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); + } + + #[test] + fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } + } + + #[test] + fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); + } + #[test] fn caps_and_hostile_settings_are_refused() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 16003cd61..f0d725793 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -916,7 +916,8 @@ fn plan_rename_core( ) -> Vec<RenamePlan> { let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch let mut out = Vec::new(); - for (seq, p) in (start..).zip(photos) { + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); let want = expand(template, p, seq); let (stem, ext) = split_ext(&want); let (stem, ext) = (stem.to_string(), ext.to_string()); 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<Click>), @@ -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<f32> = (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<Option<SegMask>, 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<String>, +} + +/// 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<String> 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<lightcraft_segment::Error> 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<Job>, shared: &Shared, idle: Duration) { +fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration, mut placement: Placement) { let mut state = State::default(); let mut queue: VecDeque<Job> = VecDeque::new(); loop { @@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver<Job>, 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::<String>().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<Option<SegMask>, 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::<String>().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<Input, String> { } /// 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<Option<SegMask>, String> { +fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, 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<Option<S if !inside.iter().any(|c| c.positive) { return Ok(None); } - Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits) + Some(m.segment_clicks(&mut enc, &inside)?.logits) } Prompt::Text(text) => 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<Kind>) -> Vec<Outcome> { + 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<Outcome> = 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 ea06d69a7..d13b95125 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -4,6 +4,8 @@ //! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright, //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). +//! A sidecar is written only beside an original that is an existing file at an absolute path; +//! otherwise the save fails. //! //! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties //! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and @@ -15,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 `<file>.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 `<file>.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; @@ -69,6 +76,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> { 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 `<file>.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<PathBuf> { + 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`. @@ -87,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. @@ -498,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<String> { + 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 } @@ -526,6 +609,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) { } } +/// 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<String, PhotoId>); @@ -536,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<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new(); + let mut best: HashMap<String, (Rank, PhotoId, usize)> = 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 { @@ -564,7 +657,7 @@ impl StemOwners { /// The owners of the shared stems in `wanted`. fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners { // per stem: (raw first, then file name, then id) of the best candidate; and how many - let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new(); + let mut best: HashMap<String, (Rank, PhotoId, usize)> = 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 }; @@ -572,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 { @@ -591,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 `<file>.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<String>, +} + +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<Item = (&'a str, MediaKind)>) -> ImportStems { + // per stem: the best of the library's files, and the best arriving file with its path + let mut best: HashMap<String, (Option<Rank>, 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<SidecarNaming> { @@ -632,6 +785,10 @@ impl Session { return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; + // A relative path would be taken from the working directory. + if Path::new(orig).is_relative() || !Path::new(orig).is_file() { + return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name))); + } let naming = owners.naming(p, self.xmp.naming); let preferred = sidecar_path(orig, naming); let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; @@ -786,6 +943,30 @@ impl Session { #[cfg(test)] mod tests { + #[test] + fn sidecars_are_written_only_beside_an_existing_original() { + let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap(); + let originals = [ + (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false), + (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false), + (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true), + ]; + for (original, written) in originals { + let _ = std::fs::remove_file(dir.join("IMG_1.xmp")); + let mut s = Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}"); + assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}"); + assert!(!dir.join("IMG_2.xmp").exists(), "{original}"); + assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`) /// gives the photo its keywords as paths. #[test] @@ -899,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<Vec<u8>>, + bytes_read: usize, + largest_read: usize, + seeks: usize, + chunk: usize, + read_error_at: Option<u64>, + seek_error: bool, + } + + impl CountedReader { + fn new(bytes: Vec<u8>) -> 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<usize> { + 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<u64> { + 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<u8> { + 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<u8> { + 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<u8> { + 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<PhotoId>) { let mut c = Catalog::new(); let mut ids = Vec::new(); @@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec<PhotoId>) { 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<usize> = (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/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index 409739335..a27f75d49 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: let my0 = (y - pad).floor().max(-pad); let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); - if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) { return; } let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index 8f615bf0c..dbee505af 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> { Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None), Value::String(s) => { let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(bad()); } let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad); @@ -747,6 +747,8 @@ mod tests { json!({"repeat": 1}), json!({"fadeColor": "#12345"}), json!({"fadeColor": "#ééé"}), + json!({"fadeColor": "#+f+f+f"}), + json!({"fadeColor": "-12345"}), json!({"fadeColor": [300, 0, 0]}), json!({"fadeColor": [1, 2]}), json!({"speed": 2}), 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<Vec<u8>, 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<lightcraft_develop::EmbeddedLens>, + camera_color: Option<lightcraft_pipeline::CameraColor>, + local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>, + /// The semantic mattes whose bytes follow the facts, in this order. + #[serde(default)] + mattes: Vec<StoredMatte>, +} + +/// 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<Vec<u8>, String> { // scale so all but the brightest 0.05 % fit into 0..1 let mut lum: Vec<f32> = 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<Vec<u8>, 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<u8> = 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<StoredMatte> = 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<CameraTone>), 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<CameraTone>, Option<SourceInfo>), 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::<CameraTone>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<crate::media::DecodedSource, String> { 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<Arc<Photo>> = 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<u64> = 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<u64> = 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<u64> = 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::<Vec<_>>(); + 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::<Vec<_>>(), "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_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..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<String> = written.iter().map(|p| p.to_string()).collect(); want.sort(); got.sort(); @@ -1024,21 +1062,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { let mut s = Session::with_demo(); let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); let mut written = Vec::new(); - let mut write = |p: &str, _: &[u8]| -> Result<(), String> { - written.push(p.to_string()); - Ok(()) + // the writer lives in a block of its own, so its borrow of `written` has ended by the time + // the assertion below reads it + let files = { + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap() }; - let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); - assert!(err.contains("no folder on disk"), "{err}"); - // without stopping: one error per photo, nothing written - let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); - let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap(); assert_eq!(files.len(), 2); for (f, id) in files.iter().zip(&ids) { assert_eq!(f["photo"], id.0, "{f}"); assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); } - drop(write); assert!(written.is_empty(), "{written:?}"); // a photo on disk whose folder is gone (moved, an unplugged drive) 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<Mutex<String>>) -> (Session, Arc<std::sync::atomic::AtomicUsize>) { + 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::<Vec<_>>(), ["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<PhotoId>, 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<f64> = 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::<Vec<_>>(), 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, &params).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::<Vec<_>>(), 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, &params).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::<Vec<_>>(); + 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", &params).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, &params).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, &params).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 3d6469deb..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<String> = 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<lightcraft_pipeline::CameraColor>, + local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>, + mattes: Option<Mattes>| 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,62 @@ 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] +fn assisted_culling_groups_bursts_across_month_end() { + let dir = temp_dir("cull-month-end"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + for (before, after) in [ + ("2026-01-31T23:59:59", "2026-02-01T00:00:00"), + ("2026-02-28T23:59:59", "2026-03-01T00:00:00"), + ("2026-04-30T23:59:59", "2026-05-01T00:00:00"), + ("2026-06-30T23:59:59", "2026-07-01T00:00:00"), + ("2026-09-30T23:59:59", "2026-10-01T00:00:00"), + ("2026-11-30T23:59:59", "2026-12-01T00:00:00"), + ("2026-12-31T23:59:59", "2027-01-01T00:00:00"), + ] { + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap(); + let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap(); + assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { @@ -1088,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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#, + ) + .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(); @@ -1348,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(); @@ -1360,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()); @@ -1452,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() { @@ -1748,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<u8> = (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<u8> = (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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"# + ) + }; + 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<_>>(), 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 8990acb4c..ea0414256 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -336,3 +336,47 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() { drop(reopened); let _ = std::fs::remove_dir_all(dir); } + +/// A huge `start` is a validation error, not an arithmetic overflow in the numbering. +#[test] +fn rename_huge_start_is_a_bad_param() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + for cmd in ["photo.renamePreview", "photo.rename"] { + let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err(); + assert!(e.to_string().contains("start out of range"), "{cmd}: {e}"); + } + let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + assert_eq!(names_before, names_after, "a rejected rename changes nothing"); + // the last start whose whole sequence fits is accepted, one more is not + let fits = usize::MAX as u64 - 2; + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids})); + assert!(r.is_ok(), "{r:?}"); + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); + assert!(r.is_err(), "{r:?}"); +} + +#[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_prefs.rs b/crates/engine/src/tests_prefs.rs index c69bb7da2..4c9d9582d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn last_merge_settings_persist_with_the_library() { + let lib = temp_dir("merge"); + let opts = json!({"align": false, "deghost": "high"}); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.last_merge.insert("merge.hdr".into(), opts.clone()); + s.save_prefs().unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts)); + assert!(!s.last_merge.contains_key("merge.panorama")); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn default_copyright_and_creator_fill_gaps_on_import() { let dir = temp_dir("copyright"); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 5b890a7f0..898faa889 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() { assert!(c.params.len() > 3, "{id}"); } } + +#[test] +fn page_options_are_print_settings_and_template_fields() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false})); + let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}})); + assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false))); + assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges"); + fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}})); + fails(&mut s, "print.set", json!({"page": {"pageNumber": true}})); + // a template keeps them + run(&mut s, "print.template.save", json!({"name": "Marked"})); + run(&mut s, "print.reset", json!({})); + assert!(!s.print.page.crop_marks); + run(&mut s, "print.template.apply", json!({"name": "Marked"})); + assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info); + let set = crate::find_command("print.set").unwrap(); + assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params); +} 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<serde_json::Value> { + 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, &params); + } + } +} + +#[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, &params); + } + } + 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", &params).unwrap(); + assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0); + params["name"] = json!("Existing"); + s.execute("mask.updateEffect", &params).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", &params).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, &params); + } + } +} + +#[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, &params); + } + } +} + +#[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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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<u8> { + 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 = "<a>".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 = "<a>".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/text.rs b/crates/engine/src/text.rs index 30adf0e66..0e2e39c1e 100644 --- a/crates/engine/src/text.rs +++ b/crates/engine/src/text.rs @@ -1,11 +1,12 @@ //! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter -//! SemiBold (bundled) first, then the craft-fonts non-Latin faces as fallbacks ([`faces`]); glyph outlines +//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines //! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out //! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor //! it before drawing; drawing hands out per-pixel coverage, and the caller blends. //! -//! Horizontal text has no contextual shaping or bidi reordering. Arabic glyphs remain isolated -//! and in logical order. Scripts no face covers draw as Inter's missing-glyph box. +//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use +//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters +//! no face covers draw as Inter's missing-glyph box. use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; @@ -16,15 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo pub const MAX_CHARS: usize = 4000; /// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face), then the UI's other non-Latin fallback faces. Drawn text -/// has no UI language, so every bundled script is offered. Without craft-fonts that is Inter -/// alone, and unsupported characters draw as Inter's missing-glyph box. +/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no +/// UI language, so every CJK script is offered. Other script faces stay out until their layout is +/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text. pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> { let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); cjk.sort_by_key(|f| !f.is_mincho()); - let others = - crate::fonts::ui_fallback(craft, "Latn", "Regular").into_iter().filter(|f| !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s))); - std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(others).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() + let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular") + .into_iter() + .filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s))); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() } /// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. @@ -249,8 +251,17 @@ mod tests { } #[test] - fn bundled_arabic_uses_its_glyphs() { - assert_script_glyphs("Arab", "مرحبا"); + fn bundled_arabic_stays_out_of_export_fallback() { + if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) { + lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts"); + return; + } + // UI names still use their existing Arabic face. Export needs joining and bidi first. + assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab"))); + for vertical in [false, true] { + let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap(); + assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0)); + } } #[test] 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/fetch/src/http.rs b/crates/fetch/src/http.rs index c2b5bee1e..c95478388 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -10,7 +10,7 @@ //! the downloading thread. No proxies (see docs/ai-masks.md). use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -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<Url, HttpError> { 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")); } @@ -88,6 +126,9 @@ impl Url { let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?; let host = v6.get(..end).unwrap_or_default(); let after = v6.get(end + 1..).unwrap_or_default(); + if !after.is_empty() && !after.starts_with(':') { + return Err(bad("unexpected text after IPv6 address")); + } (host.to_string(), after.strip_prefix(':')) } else { match authority.rsplit_once(':') { @@ -109,7 +150,7 @@ impl Url { /// `Location` of a redirect, resolved against this URL. pub fn join(&self, location: &str) -> Result<Url, HttpError> { 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())); @@ -132,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}")) } @@ -182,27 +231,68 @@ fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> { Ok(Arc::new(config)) } +/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no +/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too +/// long is given up on, and Cancel works during it (issue #1532). +fn lookup( + host: &str, + resolve: impl FnOnce() -> std::io::Result<Vec<SocketAddr>> + Send + 'static, + deadline: Instant, + limits: &Limits, +) -> Result<Vec<SocketAddr>, HttpError> { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("lc-fetch-lookup".into()) + .spawn(move || { + let _ = tx.send(resolve()); + }) + .map_err(|e| HttpError::Connect(format!("{host}: {e}")))?; + loop { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: the name lookup timed out"))); + } + match rx.recv_timeout(left.min(TICK)) { + Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))), + } + } +} + +/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole +/// connect timeout of its own, issue #1532). +fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result<TcpStream, HttpError> { + let mut last = format!("{host}: no address"); + for a in addrs { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: connecting timed out"))); + } + match TcpStream::connect_timeout(a, left) { + Ok(s) => return Ok(s), + Err(e) => last = format!("{host}: {e}"), + } + } + Err(HttpError::Connect(last)) +} + impl Stream { fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> { - let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect(); - if addrs.is_empty() { - return Err(HttpError::Connect(format!("{}: no address", url.host))); - } - let mut last = String::new(); - let mut sock = None; - for a in addrs { - if limits.cancel.load(Ordering::Relaxed) { - return Err(HttpError::Cancelled); - } - match TcpStream::connect_timeout(&a, limits.connect) { - Ok(s) => { - sock = Some(s); - break; - } - Err(e) => last = format!("{}: {e}", url.host), - } - } - let sock = sock.ok_or(HttpError::Connect(last))?; + // the lookup and the connection attempts share one connect timeout + let deadline = Instant::now() + limits.connect; + let (host, port) = (url.host.clone(), url.port); + let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?; + let sock = connect_any(&url.host, &addrs, deadline, limits)?; sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; let _ = sock.set_nodelay(true); @@ -512,16 +602,102 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res Ok(Response { status, headers, conn, body }) } +#[cfg(test)] +mod tests_connect { + use super::*; + + fn limits(cancel: &AtomicBool) -> Limits<'_> { + Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel } + } + + /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532). + #[test] + fn a_hung_name_lookup_times_out_and_can_be_cancelled() { + let cancel = AtomicBool::new(false); + let slow = || { + std::thread::sleep(Duration::from_secs(10)); + Ok(Vec::new()) + }; + let t = Instant::now(); + let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel)); + assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}"); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + // Cancel while the lookup is waiting on the resolver, not before it starts + let t = Instant::now(); + let r = std::thread::scope(|scope| { + scope.spawn(|| { + std::thread::sleep(Duration::from_millis(100)); + cancel.store(true, Ordering::Relaxed); + }); + lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)) + }); + assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}"); + assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + // and a lookup asked for after Cancel doesn't start + assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled))); + // a lookup that answers is used + let cancel = AtomicBool::new(false); + let local: SocketAddr = "127.0.0.1:9".parse().unwrap(); + assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]); + } + + /// Several addresses that don't answer share one deadline instead of each getting the full timeout. + #[test] + fn addresses_share_one_connect_deadline() { + let cancel = AtomicBool::new(false); + // non-routable addresses: each either hangs until the deadline or fails at once, never longer + let addrs: Vec<SocketAddr> = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect(); + let t = Instant::now(); + let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel)); + assert!(r.is_err()); + assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed()); + } +} + #[cfg(test)] mod tests { use super::*; + #[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(); @@ -537,6 +713,17 @@ mod tests { } } + #[test] + fn rejects_text_after_a_bracketed_ipv6_host() { + for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] { + assert!(matches!(Url::parse(bad), Err(HttpError::BadUrl(_))), "{bad}"); + } + let u = Url::parse("http://[::1]:8080/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 8080)); + let u = Url::parse("http://[::1]/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 80)); + } + #[test] fn resolves_redirects() { let u = Url::parse("https://a.example/x/y/file?q").unwrap(); diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs index 89fe86822..de0bea5c9 100644 --- a/crates/geocode/src/lib.rs +++ b/crates/geocode/src/lib.rs @@ -104,7 +104,8 @@ pub struct GeoNames { fn cell(p: LatLon) -> (i32, i32) { // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range - (p.lat.floor() as i32, p.lon.floor() as i32) + // longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes + (p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180) } fn short(s: &str) -> String { @@ -253,6 +254,14 @@ mod tests { assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); } + #[test] + fn city_on_the_antimeridian_is_found() { + let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None); + for lon in [179.9, -179.9, 180.0, -180.0] { + assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}"); + } + } + #[test] fn high_latitude_cities_two_cells_away() { let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None); 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<Vec2> { + 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<Rect> { + 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<Vec2> { + 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..0c3ab8f05 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; @@ -191,6 +191,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<bool> = const { std::cell::Cell::new(false) }; +} + thread_local! { static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } @@ -199,6 +205,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 +333,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 +456,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<wgpu::PollStatus, String> { + 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 +594,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 +771,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<String> { + 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<u32> = 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<wgpu::CommandBuffer> = (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<String> { - 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<String> { - 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<bool> = 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<Layer>) 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<Layer>) 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<f32> = (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<f32> = (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<uniform> p: P; @@ -122,6 +123,11 @@ fn main(@builtin(workgroup_id) wg: vec3<u32>, @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<u32>, @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<u32>, @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/heif/src/lib.rs b/crates/heif/src/lib.rs index 8292024b1..21b9cf647 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) }) } +/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles +/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. +const GRID_BUDGET: u64 = 2; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec<Frame>, @@ -297,6 +301,24 @@ impl<'a> Coded<'a> { let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?; let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? }; let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format); + // #1518: the decoder allocates each picture at its SPS's uncropped size, which a + // conformance window (or a slice picking another SPS) can make far larger than the + // declared one, and a grid holds all its tiles at once. Both are refused before decoding. + let mut total = 0u64; + for t in &ids { + let tp = ctx.props(*t).map_err(err)?; + let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; + let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?; + let mut largest = 0u64; + for (w, h) in sizes { + heic_rs::image::check_pixels(w, h, limit).map_err(err)?; + largest = largest.max(u64::from(w) * u64::from(h)); + } + total = total.saturating_add(largest); + } + if total > limit.saturating_mul(GRID_BUDGET) { + return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit"))); + } let decode_one = |t: &u32| -> Result<Frame, Error> { let tp = ctx.props(*t).map_err(err)?; let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index e229af926..3259dc96d 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() { assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); } +/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A +/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit. +#[test] +fn grid_tiles_are_held_to_the_pixel_budget_together() { + let bytes = build(&Spec::new(33, 33, &grey)); + let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); + let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (33, 33)); +} + #[test] fn truncated_and_corrupted_files_are_errors_not_panics() { let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) }); diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index d732f2863..226604303 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option<Signal> { parse_sps(&unescape(sps.get(2..)?)) } +/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among +/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the +/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an +/// SPS this reader can't follow. +pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option<Vec<(u32, u32)>> { + parameter_sets + .iter() + .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT)) + .map(|sps| { + let rbsp = unescape(sps.get(2..)?); + let r = &mut Bits { data: &rbsp, pos: 0 }; + sps_size(r).map(|(w, h, _)| (w, h)) + }) + .collect() +} + +/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and +/// `sps_max_sub_layers_minus1`. +fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> { + r.skip(4)?; // sps_video_parameter_set_id + let max_sub_layers_minus1 = r.u(3)?; + if max_sub_layers_minus1 > 6 { + return None; + } + r.skip(1)?; // sps_temporal_id_nesting_flag + profile_tier_level(r, max_sub_layers_minus1)?; + r.ue()?; // sps_seq_parameter_set_id + if r.ue()? == 3 { + r.skip(1)?; // separate_colour_plane_flag + } + let width = r.ue()?; // pic_width_in_luma_samples + let height = r.ue()?; // pic_height_in_luma_samples + Some((width, height, max_sub_layers_minus1)) +} + /// Removes emulation prevention bytes (`00 00 03` → `00 00`). fn unescape(nal: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(nal.len()); @@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3 fn parse_sps(rbsp: &[u8]) -> Option<Signal> { let r = &mut Bits { data: rbsp, pos: 0 }; - r.skip(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.u(3)?; - if max_sub_layers_minus1 > 6 { - return None; - } - r.skip(1)?; // sps_temporal_id_nesting_flag - profile_tier_level(r, max_sub_layers_minus1)?; - r.ue()?; // sps_seq_parameter_set_id - if r.ue()? == 3 { - r.skip(1)?; // separate_colour_plane_flag - } - r.ue()?; // pic_width_in_luma_samples - r.ue()?; // pic_height_in_luma_samples + let (_, _, max_sub_layers_minus1) = sps_size(r)?; if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset 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<T: Copy>(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#"<lens><maker>{maker}</maker>{models}<mount>Test Mount</mount><cropfactor>1</cropfactor> + <calibration><distortion model="poly3" focal="18" k1="-0.01" /></calibration></lens>"# + ) + }; + let xml = format!( + r#"<lensdatabase version="2">{}{}</lensdatabase>"#, + lens("Otherco", "<model>Otherco 18-55mm f/3.5-5.6</model>"), + lens("Testcam", "<model>Testcam 18-55mm f/3.5-5.6 VR</model><model>18-55mm f/3.5-5.6</model>"), + ); + 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<Value, String>)) { + 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<Value, String> { 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<Value> = 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<Value> { 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<Value> { 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<Value> { "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<Value> { 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/e2e.rs index 8b0396490..66cb93460 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/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/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<f32> = - 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<f32> = 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<Point> = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect(); let b: Vec<Point> = 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<bool>) { let (w, ht) = (img.width, img.height); if *h == Homography::IDENTITY { @@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) { 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<Frame>, 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/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<f64> { - 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<bool> { + 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<Gps> { 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<u8> { (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<Tracklog, GpxError> { 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#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><ele>{ele}</ele><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>"# + ); + 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<usize> { + 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<Mrw<'_>> { + 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<u8> { + 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<T: ToString>(v: &[T]) -> String { @@ -370,6 +377,16 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> { 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 c27fe4ae2..1fb789164 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -185,11 +185,22 @@ fn entity(name: &str) -> Option<char> { Some(h) => u32::from_str_radix(h, 16).ok()?, None => n.parse().ok()?, }; - char::from_u32(v)? + char::from_u32(v).filter(|&c| is_xml_char(c))? } }) } +/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`). +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}') +} + +/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the +/// whole document for. +fn xml_safe(s: &str) -> String { + s.chars().filter(|&c| is_xml_char(c)).collect() +} + /// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). fn unescape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -446,8 +457,12 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option<Regi fn fmt_gps_coord(v: f64, pos: char, neg: char) -> 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 }) } @@ -506,10 +521,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { label: first("xmp:Label"), ..Default::default() }; - m.iso = num("exif:ISOSpeedRatings").or_else(|| num("exifEX:PhotographicSensitivity")).filter(|v| *v > 0.0).map(|v| v as u32); - if let Some(v) = props.get("exif:ISOSpeedRatings").and_then(|v| v.first()).and_then(|s| parse_number(s)) { - m.iso = Some(v as u32); - } + m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32); if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { let v: Vec<f64> = spec.iter().filter_map(|s| parse_number(s)).collect(); if v.len() == 4 { @@ -536,6 +548,8 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { } if let (Some(lat), Some(lon)) = (first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s))) + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon) { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); @@ -623,12 +637,12 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } x.push_str(">\n"); for (k, v) in &simple { - x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str()))); + x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v)))); } for (k, v) in lc { if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { // element content: quotes needn't be escaped, which keeps the JSON readable - x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v))); + x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(xml_safe(v)))); } } let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { @@ -638,9 +652,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n")); for it in items { if lang { - x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it)))); } else { - x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it)))); } } x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n")); @@ -802,6 +816,24 @@ mod tests { assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); } + /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and + /// must not hide a valid `exifEX:PhotographicSensitivity`. + #[test] + fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:exifEX="http://cipa.jp/exif/1.0/" {attrs}/> + </rdf:RDF></x:xmpmeta>"# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); + } + #[test] fn malformed_inputs() { assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err()); @@ -838,6 +870,26 @@ 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°. + #[test] + fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); } /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how @@ -1039,4 +1091,24 @@ mod tests { a.overlay_user_fields(&b); assert_eq!(a.regions, vec![with_region]); } + + #[test] + fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec<char> = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); + } + + #[test] + fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); + } } 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<Self> { + 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<Item = (MatteKind, &Image<u8>)> { + self.mattes.iter().map(|(k, m)| (*k, m)) + } + fn of(&self, kinds: &[MatteKind]) -> Vec<&Image<u8>> { self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect() } @@ -484,6 +517,11 @@ pub fn push_pressures(pressure: &mut Vec<f32>, 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<Point>, prev: Option<Point>, 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<Point>, emitted: &mut usize, prev: Option<Point>, 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<Point>, prev: Option<Point>, 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/preview_save.rs b/crates/pipeline/tests/preview_save.rs new file mode 100644 index 000000000..b9f83a69d --- /dev/null +++ b/crates/pipeline/tests/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<u8> = 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::<u64>(); + 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<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`, //! `Plane = Image<f32>`). -//! - 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<Vec<[f32; 6]>> = 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::<f32>() - 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<RawImage> { 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<Option<RawImage>> { + 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<RawImage> { + 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<R // DNG 1.7 interleaved storage: the raster is a permutation of the mosaic, so undo it before // anything reads a sample as a pixel. if mode == Mode::Full { - let factor = |tag| raw.u16(tag).map(usize::from).unwrap_or(1); + let factor = |tag| pixels.u16(tag).map(usize::from).unwrap_or(1); deinterleave(&mut data, w, h, cpp, factor(t::ROW_INTERLEAVE_FACTOR), factor(t::COLUMN_INTERLEAVE_FACTOR))?; } // linearization table (integer data only) let mut linearized = false; - if let (RawData::U16(v), Some(table)) = (&mut data, raw.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) { + if let (RawData::U16(v), Some(table)) = (&mut data, pixels.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) { let table: Vec<u16> = 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<f32> = 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<f32> = + 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 } } - let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default(); + let dim = levels(t::BLACK_LEVEL).u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default(); let (br, bc) = match dim.as_slice() { [r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*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<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); + let mut values: Vec<f32> = 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<R let black = BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) }; + // geometry in the main image's pixels (a reduced copy's is scaled below) + let (full_w, full_h) = if std::ptr::eq(pixels, raw) { + (w, h) + } else { + (raw.u64(t::IMAGE_WIDTH).unwrap_or(0) as usize, raw.u64(t::IMAGE_LENGTH).unwrap_or(0) as usize) + }; + if full_w == 0 || full_h == 0 { + return Err(RawError::Corrupt("raw image IFD without a size".into())); + } let active_area = match raw.u64s(t::ACTIVE_AREA).as_deref() { - Some([top, left, bottom, right]) if bottom > 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<R } _ => 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<R let opcodes = crate::OpcodeLists { list1: opcode_list(t::OPCODE_LIST_1), list2: opcode_list(t::OPCODE_LIST_2), list3: opcode_list(t::OPCODE_LIST_3) }; - let mut metadata = lightcraft_meta::from_tiff(&tiff); - metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + let mut metadata = lightcraft_meta::from_tiff(tiff); + metadata.width = Some(developed.0 as u32); + metadata.height = Some(developed.1 as u32); let img = RawImage { format, width: w, diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 5bfbb22de..209f8f4de 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -11,11 +11,18 @@ //! //! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, //! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear -//! sRGB primaries; the codestream's declared primaries are then restored with a plain 3×3 matrix. We -//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to -//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and -//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the -//! codec's loss, and can exceed 1.0 or dip below 0. +//! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a +//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a +//! lossless one with the same header, so a declared transfer curve is part of the stored values, not a +//! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store +//! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic +//! `MapPolynomial` per plane in `OpcodeList2`. Measured against Lightroom's own preview of two such +//! files, the polynomial applied to the samples in the declared encoding tracks Lightroom's rendering +//! (midtone log-log slope 0.98 and 1.38, about 1 EV apart); applied to the decoder's linear output it is +//! 3.6 EV too dark. We never let jxl-oxide convert to the declared encoding itself: +//! that step gamut-maps and clips to [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of +//! 32768 puts them far above) and desaturates. The result is the sample space the DNG's `WhiteLevel` +//! refers to, up to the codec's loss, and can exceed 1.0 or dip below 0. //! //! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets //! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then @@ -79,20 +86,49 @@ fn rgb_to_xyz(p: [[f64; 2]; 3]) -> 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<Output> { 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<KelvinPresets> { + 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<KelvinPresets> { - 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<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> { + 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<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> { + 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 202983331..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<u64> { + 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,13 +114,13 @@ 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, /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): - /// Hasselblad 3FR and FFF. Read by the DNG reader. + /// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds). CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, @@ -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<RawImage> { 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<Option<RawImage>> { + 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 @@ -397,9 +419,10 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), - RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), + RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), + RawFormat::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<kelvin_presets::KelvinPresets>, @@ -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<u8> { + 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<u8> = 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 1de7ab9e6..95f9bdcaa 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } } -/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with -/// `lightcraft_codecs::decode`. +/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which +/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`. pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> { preview_covering(bytes, None) } @@ -220,6 +220,8 @@ fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> { .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) + // Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF + .or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten()) } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, @@ -711,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/sparse.rs b/crates/raw/src/sparse.rs index 53efa0567..d67a92b40 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -75,6 +75,21 @@ impl Ranges { self.0 = out; } + /// Add every `[start, end)` of `spans` in one sort and merge (empty ones add nothing). + pub fn extend(&mut self, spans: impl IntoIterator<Item = (u64, u64)>) { + let mut all: Vec<(u64, u64)> = std::mem::take(&mut self.0); + all.extend(spans.into_iter().filter(|&(a, b)| a < b)); + all.sort_unstable(); + let mut out: Vec<(u64, u64)> = Vec::with_capacity(all.len()); + for (a, b) in all { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + self.0 = out; + } + /// Whether all of `[start, end)` is covered (an empty range always is). pub fn contains(&self, start: u64, end: u64) -> bool { start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) @@ -232,6 +247,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; let mut holes = Ranges::new(); + let mut spans = Vec::new(); for ifd in tiff.all_ifds() { let chunks: Vec<(u64, u64)> = match ifd.image() { // a preview's strips are read when the preview is wanted: only the raw image is skipped @@ -249,11 +265,12 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { if total < BULK { continue; } - for (i, &(offset, size)) in chunks.iter().enumerate() { + spans.extend(chunks.iter().enumerate().map(|(i, &(offset, size))| { let start = if i == 0 { offset.saturating_add(HEAD) } else { offset }; - holes.insert(start, offset.saturating_add(size).min(len)); - } + (start, offset.saturating_add(size).min(len)) + })); } + holes.extend(spans); // A Panasonic RW2 keeps its raw data from `RawDataOffset` to the end, outside any TIFF image. if holes.is_empty() && bytes.starts_with(b"IIU\0") @@ -567,4 +584,40 @@ mod tests { assert!(crate::probe_info(&file).is_ok()); assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles")); } + + /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is + /// built in one sort and merge, not one pass over all strips (or all holes) per strip. + #[test] + fn many_strips_without_byte_counts_plan_fast() { + let n = 1_000_000u32; + let (offsets_at, counts_at) = (8 + 2 + 5 * 12 + 4, 8 + 2 + 5 * 12 + 4 + 4 * n); + let data = counts_at + 4 * n; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&5u16.to_le_bytes()); + let entry = |tiff: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&typ.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + }; + entry(&mut tiff, 256, 4, 1, 1); + entry(&mut tiff, 257, 4, 1, n); + entry(&mut tiff, 273, 4, n, offsets_at); + entry(&mut tiff, 278, 4, 1, 1); + entry(&mut tiff, 279, 4, n, counts_at); + tiff.extend_from_slice(&0u32.to_le_bytes()); + for i in (0..n).rev() { + tiff.extend_from_slice(&(data + i).to_le_bytes()); + } + tiff.resize(data as usize + n as usize, 0); + let len = tiff.len() as u64; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut loaded = Ranges::new(); + loaded.insert(0, len); + let _ = tx.send(header_plan(&tiff, &loaded)); + }); + let plan = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s"); + assert!(!matches!(plan, HeaderPlan::Need(_)), "{plan:?}"); + } } diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index cfa99ff5d..b80442c02 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -4,7 +4,8 @@ //! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG //! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral //! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the -//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number +//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other +//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number //! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice //! (`docs/camera-preview-colour.md`). use crate::ColorData; @@ -40,6 +41,19 @@ impl Camera { } } +/// `(make, model as sold, table model)`: bodies sharing a table camera's sensor that rawtoaces-data's alias list +/// doesn't name, kept here because the table is generated. Fujifilm's 26.1 MP X-Trans CMOS 4 bodies use the X-T3's +/// sensor (X-T30 issue: a black-and-white Acros camera JPEG gives no colour to fit, and without matrices the photo +/// opened in the muted neutral fallback). +const SAME_SENSOR: &[(&str, &str, &str)] = &[ + ("Fujifilm", "X-T30", "X-T3"), + ("Fujifilm", "X-T30 II", "X-T3"), + ("Fujifilm", "X-Pro3", "X-T3"), + ("Fujifilm", "X100V", "X-T3"), + ("Fujifilm", "X-S10", "X-T3"), + ("Fujifilm", "X-E4", "X-T3"), +]; + /// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL` /// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D"). fn key(brand: &str, model: &str) -> String { @@ -70,7 +84,7 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { let wanted = key(&row_brand, model); make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted }; - match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) { + match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) { Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target), None => table::CAMERAS.iter().find(|c| same(c.make, c.model)), } @@ -142,10 +156,14 @@ mod tests { ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), ("Canon", "Canon EOS Kiss X5", "EOS 600D"), ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("Canon", "Canon EOS R6 Mark II", "EOS R6m2"), ("NIKON CORPORATION", "NIKON D800", "D800E"), ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), ("FUJIFILM", "GFX100S", "GFX 100"), + ("FUJIFILM", "X-T30", "X-T3"), + ("FUJIFILM", "X-T30 II", "X-T3"), + ("FUJIFILM", "X100V", "X-T3"), ] { assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); } @@ -171,7 +189,7 @@ mod tests { assert_eq!(find_camera(&joined), Some(c), "{joined}"); assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}"); } - for (make, alias, _) in table::ALIASES { + for (make, alias, _) in table::ALIASES.iter().chain(SAME_SENSOR) { let joined = format!("{make} {alias}"); assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}"); assert!(find_camera(&joined).is_some(), "{joined}"); diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs index f116e2335..e8703e471 100644 --- a/crates/raw/src/spectral_table.rs +++ b/crates/raw/src/spectral_table.rs @@ -335,6 +335,7 @@ pub(super) const ALIASES: &[(&str, &str, &str)] = &[ ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS R6 Mark II", "EOS R6m2"), // same model, alternative name ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor 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<RawImage> { 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<RawImage> { // 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<RawImage> { 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<RawImage> { 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<Opcode> { + 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::<f64>(); + 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::<Vec<_>>(), + ); + } + 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<crate::kelvin_presets::KelvinPresets> { + 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<RawImage> { 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<RawImage> { 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<RawImage> { 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<RawImage> { 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<u64> { + 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<u64> = (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<u8> = 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..13f63a4a3 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/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<Vec<i32>> { + 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<Vec<i32>> { 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/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs new file mode 100644 index 000000000..5f3342f9f --- /dev/null +++ b/crates/raw/src/vendor/hasselblad.rs @@ -0,0 +1,295 @@ +//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]). +//! +//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box +//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number +//! below was measured on it. +//! +//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution +//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`. +//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about. +//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor +//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter. +//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each +//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at +//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847 +//! with red, and the two green sites' diagonal differences are half those of the other pair. +//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in +//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag +//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a +//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). +//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer +//! file's, which is the one the converter last saved. +//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the +//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB +//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used. +//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF. +use crate::dng; +use crate::{ColorData, Mode, RawFormat, RawImage, Result}; +use lightcraft_tiff::image::{Layout, chunk_bytes}; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t}; + +/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. +pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; + +/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header. +const MAX_HEADER: usize = 1 << 20; + +/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]). +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?; + if !crate::color::has_matrix(&img.color) + && let Some(header) = original_header_color(bytes) + { + adopt_matrices(&mut img.color, &header); + } + Ok(img) +} + +/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix. +fn original_header_color(bytes: &[u8]) -> Option<ColorData> { + let outer = Tiff::parse(bytes).ok()?; + let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?; + if block.len() > MAX_HEADER { + return None; + } + let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() }; + let header = Tiff::parse_with(block, &opts).ok()?; + let ifd0 = header.ifds.first()?; + let raw = dng::raw_ifd(&header).unwrap_or(ifd0); + let color = dng::color_data(ifd0, raw, header.order); + crate::color::has_matrix(&color).then_some(color) +} + +/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen. +const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3; + +/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding +/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer +/// applies the raw file's, which is the one that stands over this image in IFD0. +pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option<Vec<u8>> { + let info = tiff + .all_ifds() + .into_iter() + .filter_map(|i| i.image().ok()) + .filter(|i| { + i.photometric == t::photometric::RGB + && i.compression == t::compression::NONE + && i.samples_per_pixel == 3 + && i.bits_per_sample.iter().all(|&b| b == 8) + && i.planar == 1 + && i.sample_format == 1 + && matches!(i.layout, Layout::Strips { .. }) + && i.width > 0 + && i.height > 0 + }) + .max_by_key(|i| u64::from(i.width) * u64::from(i.height))?; + let (w, h) = (info.width as usize, info.height as usize); + let size = w.checked_mul(h)?.checked_mul(3)?; + if size > MAX_PREVIEW_BYTES { + return None; + } + let mut pixels = Vec::with_capacity(size); + for chunk in info.chunks(bytes.len() as u64) { + pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?); + if pixels.len() >= size { + break; + } + } + // a short last strip is a truncated file: no preview rather than a picture with a black band + if pixels.len() < size { + return None; + } + pixels.truncate(size); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok() +} + +/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. +fn adopt_matrices(color: &mut ColorData, from: &ColorData) { + color.illuminant = from.illuminant; + color.color_matrix = from.color_matrix; + color.forward_matrix = from.forward_matrix; + color.camera_calibration = from.camera_calibration; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawData, RawError, decode as decode_file, probe, probe_info}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + const MATRIX: [(i32, i32); 9] = + [(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)]; + + fn rgb_ifd() -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] }); + ifd + } + + /// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix. + fn original_header(matrix: bool) -> Vec<u8> { + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into())); + if matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec())); + } + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, + /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. + fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> { + fff_with(header, own_matrix, vec![]) + } + + /// [`fff`] with more `SubIFDs` children of IFD0 (other preview images). + fn fff_with(header: Option<Vec<u8>>, own_matrix: bool, extra: Vec<IfdBuilder>) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] }); + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)])); + if own_matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + } + if let Some(h) = header { + ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); + } + ifd0.add_sub_ifd(raw); + for e in extra { + ifd0.add_sub_ifd(e); + } + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> { + c.color_matrix[0].map(|m| m.0[0]) + } + + /// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it + /// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback. + #[test] + fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() { + let bytes = fff(Some(original_header(true)), false); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode_file(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032])); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance"); + assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback); + // the import probe says the same + assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032])); + } + + /// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the + /// file still decodes, with the fallback colour. + #[test] + fn own_matrix_wins_and_a_missing_one_is_not_an_error() { + let own = decode_file(&fff(Some(original_header(true)), true)).unwrap(); + assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0])); + for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] { + let r = decode_file(&fff(header, false)).unwrap(); + assert!(!crate::color::has_matrix(&r.color)); + } + } + + /// A truncated or damaged file never panics: an error, or a decode. + #[test] + fn damaged_files_do_not_panic() { + let bytes = fff(Some(original_header(true)), false); + for n in (0..bytes.len()).step_by(7) { + let _ = decode_file(&bytes[..n]); + let _ = probe_info(&bytes[..n]); + let _ = crate::embedded_preview(&bytes[..n]); + } + let mut header = original_header(true); + for i in (8..header.len()).step_by(3) { + header[i] ^= 0x5a; + match decode_file(&fff(Some(header.clone()), false)) { + Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {} + } + } + } + + /// `(width, height, pixels, has an Orientation tag)` of a wrapped preview. + fn wrapped(tiff: &[u8]) -> (u32, u32, Vec<u8>, bool) { + let t = Tiff::parse(tiff).unwrap(); + let ifd = &t.ifds[0]; + let info = ifd.image().unwrap(); + let chunks = info.chunks(tiff.len() as u64); + let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect(); + (info.width, info.height, pixels, ifd.contains(t::ORIENTATION)) + } + + /// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an + /// orientation of its own (the raw file's applies). + #[test] + fn rgb_ifd0_is_the_embedded_preview() { + let bytes = fff(Some(original_header(true)), false); + let preview = crate::embedded_preview(&bytes).expect("a preview"); + assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false)); + assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview)); + } + + /// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not + /// used, and nor is a planar or compressed one. + #[test] + fn only_8_bit_chunky_uncompressed_rgb_is_taken() { + let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar])); + let n = (w * h * 3 * u32::from(bits / 8)) as usize; + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] }); + ifd + }; + let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3"); + let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false)); + } + + /// A preview strip cut short by a truncated file gives no preview, not a partial picture. + #[test] + fn short_preview_strip_gives_none() { + let mut ifd0 = rgb_ifd(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] }); + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None); + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 09c316d29..b0f65f49e 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,8 +12,10 @@ pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; pub mod erf; +pub(crate) mod hasselblad; pub mod kodak; pub mod mef; +pub mod 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<u16> { + b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) +} + +fn prd(b: &[u8]) -> Result<Prd> { + 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<RawImage> { + 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<u8> { + 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<u16> = (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<RawImage> { 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<RawImage> { 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<PackedLayout> { + 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<PackedLayout> { + 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<PackedLayout> { + 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<u16> { + 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<RawData> { 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<RawData> { + 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<u16> { + (0..n).map(|i| (((i * 2654435761usize) >> 7) as u32 % (1 << bits)) as u16).collect() + } + + fn pack_lsb(row: &[u16], bits: u32) -> Vec<u8> { + 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<u8> { + 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<u8>) -> Vec<u8> { + data.chunks(w).flat_map(|r| row(r).into_iter().chain(std::iter::repeat_n(0xA5, pad))).collect() + } + + fn decoded(strip: Vec<u8>, bits: u16, w: u32, h: u32) -> Result<Vec<u16>> { + 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<u16> = 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<u8> { + 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<u8>, keep: usize) -> Vec<u8> { + 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<Opcode> { 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::<f64>(); 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<Vec<u16>> { } } +/// The format-8 tile origins (tag `0x0045`): a count, then (x, y) pairs of `u16`. +fn origins(ifd: &Ifd, order: ByteOrder) -> Result<Vec<(usize, usize)>> { + 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<Vec<(usize, usize)>> = (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<Vec<u64>> { 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<Vec<u64>> { 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<Strip<'a>>, 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<usize> = 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<Vec<&mut [u16]>> = 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<usize> = (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<u8> { + 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<u8> { + 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<u16> = (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<u16> = (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<u16>| undefined(&v); let wide = |v: Vec<u64>| 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<usize> = 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<u8> = 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/corpus.rs b/crates/raw/tests/corpus.rs index 31e62f7dc..c9a48fb69 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1253,7 +1253,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 +1264,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 +1275,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 +1362,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 +1564,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::<f64>() / (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/crx_properties.rs b/crates/raw/tests/crx_properties.rs index 32c9f65d3..804970de4 100644 --- a/crates/raw/tests/crx_properties.rs +++ b/crates/raw/tests/crx_properties.rs @@ -250,6 +250,7 @@ fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec<u8> { // --- 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<u8> { 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/nrw_be16.rs b/crates/raw/tests/nrw_be16.rs new file mode 100644 index 000000000..73bf3dcc5 --- /dev/null +++ b/crates/raw/tests/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<PathBuf>) { + 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/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<f32>, } struct EncoderLayer { @@ -91,6 +94,7 @@ pub struct Detector { pixel_convs: Vec<Conv2d>, pixel_norms: Vec<GroupNorm>, instance_proj: Conv2d, + semantic_proj: Conv2d, mask_embedder: Vec<Linear>, } @@ -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::<Result<_>>()?, pixel_norms: (0..3).map(|i| Ok(candle_nn::group_norm(8, HIDDEN, 1e-5, p.pp(format!("norms.{i}")))?)).collect::<Result<_>>()?, 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::<Result<_>>()?, }) } @@ -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<f32>], 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<Prediction> { 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<Vec<f32>> = 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<Vec<f32>> = keep.iter().map(|(i, _)| out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())).collect::<Option<_>>()?; + 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> { + Self::load_on(dir, Device::Cpu) + } + pub fn load_on(dir: &Path, device: Device) -> Result<Self> { 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<Option<Vec<f32>>, String> { +pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> crate::Result<Option<Vec<f32>>> { let mut merged: Option<Vec<f32>> = 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/reference.rs b/crates/segment/tests/reference.rs index 315d36cd1..a2a110053 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/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/slideshow/src/settings.rs b/crates/slideshow/src/settings.rs index 6fb004bf6..fd3b327ef 100644 --- a/crates/slideshow/src/settings.rs +++ b/crates/slideshow/src/settings.rs @@ -293,7 +293,7 @@ pub fn parse_color(v: &Value) -> Option<[u8; 3]> { match v { Value::String(s) => { let h = s.trim().strip_prefix('#').unwrap_or(s.trim()); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); @@ -356,6 +356,9 @@ mod tests { json!({"zoomToFill": 1}), json!({"strokeColor": "#12345"}), json!({"strokeColor": "#ééééé"}), + json!({"strokeColor": "#+f+f+f"}), + json!({"backdropColor": "+12345"}), + json!({"washColor": "#-00000"}), json!({"washColor": [256, 0, 0]}), json!({"washColor": [-1, 0, 0]}), json!({"backdropColor": [1, 2]}), 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/image.rs b/crates/tiff/src/image.rs index 2188ab502..ca3ff8b69 100644 --- a/crates/tiff/src/image.rs +++ b/crates/tiff/src/image.rs @@ -127,6 +127,13 @@ impl ImageInfo { let (cw, ch) = self.chunk_size(); let n = self.offsets.len().min(per_plane.saturating_mul(planes)); let mut out = Vec::with_capacity(n); + // the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk) + let mut sorted: Vec<u64> = Vec::new(); + if (0..n).any(|i| self.byte_counts.get(i).is_none_or(|&l| l == 0)) { + sorted = self.offsets.clone(); + sorted.sort_unstable(); + sorted.dedup(); + } for i in 0..n { let plane = i / per_plane.max(1); let j = i % per_plane.max(1); @@ -142,7 +149,7 @@ impl ImageInfo { Some(&l) if l > 0 => l, _ => { // infer: up to the next larger offset, else end of data - let next = self.offsets.iter().copied().filter(|&o| o > offset).min().unwrap_or(data_len); + let next = sorted.get(sorted.partition_point(|&o| o <= offset)).copied().unwrap_or(data_len); next.saturating_sub(offset) } }; @@ -162,3 +169,48 @@ pub fn chunk_bytes<'a>(data: &'a [u8], c: &Chunk) -> Option<&'a [u8]> { let end = (c.offset.saturating_add(c.len)).min(data.len() as u64) as usize; Some(&data[start..end]) } + +#[cfg(test)] +mod tests { + use super::*; + + fn strips(offsets: Vec<u64>, byte_counts: Vec<u64>) -> ImageInfo { + ImageInfo { + width: 1, + height: offsets.len() as u32, + bits_per_sample: vec![8], + samples_per_pixel: 1, + compression: 1, + photometric: 1, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Strips { rows_per_strip: 1 }, + offsets, + byte_counts, + } + } + + #[test] + fn missing_byte_counts_run_to_the_next_larger_offset() { + let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]); + let lens: Vec<u64> = info.chunks(100).iter().map(|c| c.len).collect(); + assert_eq!(lens, vec![20, 20, 5, 20, 30]); + } + + /// A million strips without byte counts (offsets in descending order) are measured in one sort, not one + /// scan of all offsets per strip. + #[test] + fn many_strips_without_byte_counts_are_fast() { + let n = 1_000_000u64; + let info = strips((0..n).rev().map(|i| 1000 + i * 2).collect(), Vec::new()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let chunks = info.chunks(1000 + n * 2); + let _ = tx.send(chunks.iter().map(|c| c.len).collect::<Vec<u64>>()); + }); + let lens = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("chunks() took longer than 20 s"); + assert!(lens.iter().all(|&l| l == 2)); + } +} 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<u64>, /// 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<u64>, 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<u64> { @@ -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/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 <folder>` takes HEIC": "`--import <folder>` nimmt HEIC", + "`lightcraft-cli run --import <folder>` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW": "`lightcraft-cli run --import <folder>` (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..a638c1482 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,88 @@ "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", + "Dimensions": "Dimensioni", + "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 bafd7827c..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)", @@ -2575,7 +2576,7 @@ "on the CPU, are not waited for).": "CPU-ზე, არ ელოდება).", "on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "DLL-ების საძიებო გზაზეა მისი `dxil.dll`-ის გარეშე (ხარვეზი #471): DirectX 12 შეიდერები ახლა ყოველთვის კომპილირდება", "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "ბარათზე იმპორტის გადახედვის შემდეგ, აღირიცხება იმის ნაცვლად, რომ მოძველებული მონაცემებით იმპორტირდეს. ექსპორტის მისამართის შემოწმება", - "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; ✕ აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", + "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; × აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", "once you zoom out or move on.": "როცა დააპატარავებთ ან სხვაზე გადახვალთ.", "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "სათითაოდ — მათი ხელახლა ექსპორტი ყოველთვის შეიძლება, ხოლო USB დისკზე ან NAS-ზე თითოეული ფაილის სინქრონიზაცია დიდ", "only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "მხოლოდ კამერის მამრავლებსა და კელვინებს შორის გარდაქმნის; ფერის იერი ისეთივეა, როგორიც იყო.", @@ -2615,7 +2616,7 @@ "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "რენდერები და ეკრანის ანაბეჭდები კვლავ დროებით ფაილში იწერება და ადგილზე გადაირქმევა, მაგრამ დისკზე იძულებით აღარ ჩაიწერება", "reports each row's `label`.": "თითოეული სტრიქონის `label`-ს აბრუნებს.", "restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "ფოტოს ასევე აღადგენს და ამბობს „ახლახან წაშლილიდან აღდგა 1 ფოტო“. წაშლილი ფოტო, რომლის ფაილიც გადაიტანეს,", - "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (✕ აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", + "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (× აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", "save goes through.": "შენახვა შესრულდება.", "saved in": "შენახულია:", "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).", 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 85a0ae039..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", @@ -2180,5 +2182,11 @@ "Checking the GPU…": "Verificando a GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.", - "Checking the GPU for faces": "Verificando a GPU para rostos" + "Checking the GPU for faces": "Verificando a GPU para rostos", + "Include selected photos": "Incluir fotos selecionadas", + "Color Overlay": "Sobreposição de cor", + "Color Overlay on B&W": "Sobreposição de cor em P&B", + "Image on Black": "Imagem sobre preto", + "Image on White": "Imagem sobre branco", + "White on Black": "Branco sobre preto" } 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 `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "som `<name>.corrupt-<time>` och du får veta det; en som inte kan läsas (t.ex. låst av ett annat program) lämnas orörd", + "As Shot": "Som tagen", + "As shot": "Som tagen", + "Ascending": "Stigande", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "ombedd. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (eller wgpus `WGPU_BACKEND`, som GPU-renderingen", + "Aspect": "Bildförhållande", + "Aspect preview": "Förhandsgranskning av bildförhållande", + "Aspect Ratio": "Bildförhållande", + "Assisted Culling": "Assisterad gallring", + "Assisted Culling…": "Assisterad gallring…", + "Auto": "Auto", + "Auto Advance": "Gå vidare automatiskt", + "Auto Advance after rating or flagging": "Gå vidare automatiskt efter betyg eller flaggning", + "Auto Advance Off": "Gå vidare automatiskt: av", + "Auto Advance On": "Gå vidare automatiskt: på", + "Auto Advance: move to the next photo after rating or flagging": "Gå vidare automatiskt: gå till nästa foto efter betyg eller flaggning", + "Auto Align": "Automatisk justering", + "Auto B&W Mix": "Automatisk svartvit mix", + "Auto Crop": "Beskär automatiskt", + "Auto Import": "Automatisk import", + "Auto Import Now": "Kör automatisk import nu", + "Auto Import Settings": "Inställningar för automatisk import", + "Auto Mask": "Automatisk mask", + "Auto Select": "Markera automatiskt", + "Auto Settings": "Automatiska inställningar", + "Auto settings applied": "Automatiska inställningar har tillämpats", + "Auto Straighten": "Räta upp automatiskt", + "Auto Sync": "Automatisk synkronisering", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatisk synkronisering: ändringar tillämpas på alla markerade foton. Automatisk svartvit mix. Automatiska versioner.", + "Auto-Stack by Capture Time": "Stapla automatiskt efter tagningstid", + "Auto-Stack by Capture Time…": "Stapla automatiskt efter tagningstid…", + "Auto-Tag from Tracklog": "Tagga automatiskt från spårlogg", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Tagga automatiskt från spårlogg: GPS-platser för dina foton från en GPX-spårlogg, matchade efter tagningstid.", + "Auto-Tag from Tracklog…": "Tagga automatiskt från spårlogg…", + "Auto-Tag Photos from Tracklog": "Tagga foton automatiskt från spårlogg", + "Automatic": "Automatisk", + "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.": "Automatisk renderar detaljvyn i den storlek den visas, upp till fotots egna pixlar. Välj en storlek för att begränsa den: mindre är snabbare.", + "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.": "Automatisk använder grafikkortet när det är snabbare än processorn: båda tidmäts på den här datorn första gången ett foto skapas.", + "Automatically show full screen": "Visa automatiskt i helskärm", + "Automatically Write Changes into XMP": "Skriv ändringar till XMP automatiskt", + "Automatically write changes into XMP sidecars": "Skriv ändringar till XMP-sidofiler automatiskt", + "Autumn Gold": "Höstguld", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-video där varje bild visas under sin längd med sina toningar. Inget ljud: musik exporteras inte än.", + "B&W": "S/V", + "B&W Mix": "Svartvit mix", + "B&W Mixer": "Svartvit mixer", + "Back": "Tillbaka", + "Back to Edit": "Tillbaka till redigering", + "Back to Edit (E)": "Tillbaka till redigering (E)", + "Back to Grid": "Tillbaka till rutnät", + "Back to this person": "Tillbaka till den här personen", + "Back Up Library…": "Säkerhetskopiera bibliotek…", + "Backdrop": "Fond", + "Background": "Bakgrund", + "Background colour": "Bakgrundsfärg", + "Background image": "Bakgrundsbild", + "Background Tasks": "Bakgrundsuppgifter", + "Balance": "Balans", + "Basic": "Grundläggande", + "Before": "Före", + "Before the last template": "Före den sista mallen", + "Before/After (Y)": "Före/efter (Y)", + "Before/After Settings": "Inställningar för före/efter", + "Before/After Split": "Före/efter delad", + "Before/After Split Top/Bottom": "Före/efter delad överst/nederst", + "Before/After Top/Bottom": "Före/efter överst/nederst", + "Begin Interaction": "Påbörja interaktion", + "Best of Similar Shots": "Bästa av likartade bilder", + "Binary assets added": "Binära resurser tillagda", + "Binary assets removed": "Binära resurser borttagna", + "Bit depth": "Bitdjup", + "Black & White": "Svartvitt", + "Blacks": "Svarta", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Blekt", + "Blending": "Blandning", + "Blue": "Blå", + "blue": "blå", + "Blue channel": "Blå kanal", + "Blue Hour Boost": "Förstärkt blå timme", + "Blue Hue": "Blå nyans", + "Blue label": "Blå etikett", + "Blue Luminance": "Blå luminans", + "Blue Primary": "Blå primärfärg", + "Blue Saturation": "Blå mättnad", + "Blue/Yellow Fringe": "Blå/gul färgkant", + "Blurry photos below the score are flagged as rejects.": "Suddiga foton under poängen flaggas som refuserade.", + "Bottom": "Nederkant", + "Boundary Warp": "Gränsförvrängning", + "Bright & Airy": "Ljust och luftigt", + "Bring All to Front": "Lägg alla överst", + "Bring the library up to date with this folder and the folders inside it:": "Uppdatera biblioteket med den här mappen och mapparna i den:", + "Browse Folder": "Bläddra i mapp", + "Browse Folder…": "Bläddra i mapp…", + "Browse Profiles": "Bläddra bland profiler", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Webbläsarversion (experimentell), skydda ett bibliotek (ärende #107): Arkiv → Säkerhetskopiera bibliotek… hämtar katalogen", + "Browse…": "Bläddra…", + "Brush": "Pensel", + "Brush Settings": "Penselinställningar", + "Brush Stroke": "Penseldrag", + "Build Previews": "Bygg förhandsgranskningar", + "Build Smart Previews": "Bygg smarta förhandsgranskningar", + "Building previews": "Bygger förhandsgranskningar", + "Building smart previews": "Bygger smarta förhandsgranskningar", + "Built-in": "Inbyggd", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "paket med vilka som helst av dessa – hela mappar på en gång, grupperade efter paket. Masker i förinställningar följer med.", + "By Date": "Efter datum", + "By day (YYYY/YYYY-MM-DD)": "Per dag (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "Per månad (YYYY/YYYY-MM)", + "Cache": "Cache", + "cached picture": "cachad bild", + "cached pictures": "cachade bilder", + "Calibration": "Kalibrering", + "Camera": "Kamera", + "Camera Info": "Kamerainfo", + "Camera local tone mapping": "Kamerans lokala tonmappning", + "Camera make and model": "Kameratillverkare och modell", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "kameran bakas inte längre in. Foton som redan importerats som \"endast förhandsgranskning\" växlar över vid Läs in igen. (Filer som", + "Cameras and Cards": "Kameror och kort", + "can't be tested that way": "kan inte testas på det sättet", + "can't match any rating from 0 to 5": "kan inte matcha något betyg från 0 till 5", + "can't test a folder; choose an album in it": "kan inte testa en mapp; välj ett album i den", + "Cancel": "Avbryt", + "Cancel AI Mask Model Download": "Avbryt hämtning av AI-maskmodell", + "Cancel Background Task": "Avbryt bakgrundsuppgift", + "Cancel Denoise": "Avbryt brusreducering", + "Cancel Denoise Model Download": "Avbryt hämtning av brusreduceringsmodell", + "Cancel Download": "Avbryt hämtning", + "Cancel Face Model Download": "Avbryt hämtning av ansiktsmodell", + "Cancel Interaction": "Avbryt interaktion", + "Cancel pending action": "Avbryt väntande åtgärd", + "Cancel Pending Photo Action": "Avbryt väntande fotoåtgärd", + "Cancel Preview Build": "Avbryt bygge av förhandsgranskningar", + "Candidate": "Kandidat", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Canon CR2-foton från EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II och 1D Mark IV (och andra", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon CR3-filer visar sin inbäddade JPEG i full storlek (t.ex. 6960 × 4640 på en EOS R6 Mark III) i stället för", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Canon EOS R5 Mark II C-RAW-filer som öppnades som \"endast förhandsgranskning\" (mer än hälften av dem, beroende på", + "Caption": "Bildtext", + "Capture Date": "Tagningsdatum", + "Capture date and time in your own format (directives below)": "Tagningsdatum och tid i ditt eget format (direktiv nedan)", + "Capture date as YYYYMMDD": "Tagningsdatum som YYYYMMDD", + "Captured": "Tagen", + "Captured from": "Tagen från", + "Captured until": "Tagen till", + "Cast shadow": "Slagskugga", + "Catalog Statistics": "Katalogstatistik", + "Catchlight": "Ögonreflex", + "Change": "Ändra", + "Change Component Mode": "Ändra komponentläge", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "ändring. Kataloger som redan innehåller ett sådant fragment läses in helt. Att avsluta medan katalogloggen inte kan skrivas", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "ändringar inte kunde sparas försöker en gång till och frågar sedan: Försök spara igen, Avsluta ändå eller Avbryt.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "kanal eller MCP, en sökväg för sammanfogad förhandsgranskning eller `lightcraft-cli render IMG.jpg -o IMG.jpg` avvisas med ett tydligt", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Tecken som inte är tillåtna i filnamn (/ \\ : * ? \" < > |) blir -; ett befintligt namn får -1, -2….", + "Charcoal": "Kol", + "Check All": "Kryssa i alla", + "Check Output Path": "Kontrollera utdatasökväg", + "Check the files of this type": "Kryssa i filerna av den här typen", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "kontrolleras mot innehållet som lästs från kortet. En kopia som misslyckas eller avviker tas bort och rapporteras som en misslyckad", + "Checking and installing the model…": "Kontrollerar och installerar modellen…", + "Checking names…": "Kontrollerar namn…", + "Checking raw files": "Kontrollerar raw-filer", + "Checking…": "Kontrollerar…", + "Choose a group": "Välj en grupp", + "Choose a model below (Use) to start.": "Välj en modell nedan (Använd) för att börja.", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Välj ett mål för PDF:en när du har bekräftat. Skriv ut PDF:en från din visare.", + "Choose a photo in the grid or filmstrip": "Välj ett foto i rutnätet eller filmremsan", + "Choose an album…": "Välj ett album…", + "Choose Another Library…": "Välj ett annat bibliotek…", + "Choose at least one setting to include": "Välj minst en inställning att ta med", + "Choose Denoise Model": "Välj brusreduceringsmodell", + "Choose Edit Settings to Copy": "Välj redigeringsinställningar att kopiera", + "Choose Edit Settings to Copy…": "Välj redigeringsinställningar att kopiera…", + "Choose Face Recognition Model": "Välj modell för ansiktsigenkänning", + "Choose Folder…": "Välj mapp…", + "Choose Layout Image…": "Välj layoutbild…", + "Choose Profile…": "Välj profil…", + "Choose…": "Välj…", + "Cinema Wide": "Bio bredbild", + "Cinematic": "Filmisk", + "City": "Ort", + "Clarity": "Klarhet", + "Clean Studio": "Ren studio", + "Clear": "Rensa", + "Clear all": "Rensa alla", + "Clear Cache": "Rensa cache", + "Clear cache": "Rensa cache", + "Clear Denoise Cache": "Rensa brusreduceringscache", + "Clear Filters": "Rensa filter", + "Clear form": "Rensa formulär", + "Clear Guides": "Rensa stödlinjer", + "Clear History": "Rensa historiken", + "Clear Preview Cache": "Rensa förhandsgranskningscache", + "Clear Quick Collection": "Rensa snabbsamling", + "Clear rating": "Rensa betyg", + "Clear rating and advance": "Rensa betyg och gå vidare", + "Clear Recent Libraries": "Rensa senaste bibliotek", + "Clear Reference": "Rensa referens", + "Cleared History": "Rensade historiken", + "Click a shortcut, then press the new keys (Esc cancels).": "Klicka på ett kortkommando och tryck sedan på de nya tangenterna (Esc avbryter).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicka på en tagg för att infoga den. Exemplen gäller IMG_0042.CR3 tagen 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.": "Klicka på en mall för att använda den; peka på en för att förhandsgranska den på det markerade fotot. Högerklicka på dina egna för att uppdatera, byta namn på, exportera eller radera dem.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klicka på foton i rutnätet för att växla nyckelordet; Esc avslutar", + "Click the object to select it · ⌥-click leaves a part out": "Klicka på objektet för att markera det · ⌥-klicka utesluter en del", + "Click the photo to pick a colour · ⇧-click adds more": "Klicka på fotot för att välja en färg · ⇧-klicka lägger till fler", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Klicka på fotot för att välja en färg; ⇧-klicka lägger till fler (upp till 5)", + "Click to clear these filters": "Klicka för att rensa dessa filter", + "Click to name this face": "Klicka för att namnge det här ansiktet", + "Click to restore · double-click to rename": "Klicka för att återställa · dubbelklicka för att byta namn", + "Click to type a value": "Klicka för att skriva ett värde", + "Click Zoom Ratio": "Zoomnivå vid klick", + "Clone": "Klona", + "Close": "Stäng", + "Close all": "Stäng alla", + "Close file": "Stäng fil", + "Close Panel": "Stäng panel", + "Close panel": "Stäng panel", + "Close Window": "Stäng fönster", + "Cloudy": "Molnigt", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, behåller fotot och rensar dess redigeringar.)", + "Collapse": "Komprimera", + "Collapse All Stacks": "Komprimera alla staplar", + "Collapse Stack": "Komprimera stapel", + "Color": "Färg", + "Color Grading": "Färggradering", + "Color Label": "Färgetikett", + "Color label cleared": "Färgetiketten rensades", + "Color Label Names": "Namn på färgetiketter", + "Color Label Sets": "Uppsättningar av färgetiketter", + "Color Mixer": "Färgmixer", + "Color Noise Reduction": "Brusreducering för färg", + "Color Priority": "Färgprioritet", + "Color Range": "Färgomfång", + "Color space": "Färgrymd", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Färgrymd: Rec. 2020 HLG (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Färgrymd: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Färgrymd: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Färgrymd: Rec. 2020 PQ, 16 bitar (HDR PNG)", + "Color space: sRGB (AVIF)": "Färgrymd: sRGB (AVIF)", + "Color wheel": "Färghjul", + "Colors": "Färger", + "Colour": "Färg", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "färg- och tonlook anpassad efter kamerans egen JPEG, som för raw-filer från Nikon och Sony, beskuren i det bildförhållande som angetts i", + "Colour only": "Endast färg", + "Colour wash": "Färgslöja", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Färgetikettfilter med flera etiketter samtidigt; expanderbart mappträd i Lokalt.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Masker för färgomfång: klicka på fotot för att ta prov. Luminansomfång: omfångsfält, mjukhet, luminanskarta.", + "Combine files…": "Kombinera filer…", + "Combine with the existing person": "Kombinera med den befintliga personen", + "comma, separated": "komma, separerade", + "Command Journal": "Kommandologg", + "Comments panel": "Kommentarspanel", + "commercial use allowed": "kommersiell användning tillåten", + "commercial use subject to GPL conditions": "kommersiell användning enligt GPL-villkoren", + "Commits": "Commits", + "Company": "Företag", + "Compare": "Jämför", + "Compare (Shift+C)": "Jämför (Shift+C)", + "Compare Before and After": "Jämför före och efter", + "Compare needs at least two photos": "Jämförelse kräver minst två foton", + "Component options": "Komponentalternativ", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimerade Nikon-NEF (förlustfritt och förstörande komprimerade, 12 och 14 bitar – t.ex. D3200, D5100, D7000, D750, D850, Z 50)", + "Compression": "Komprimering", + "Concrete Cool": "Sval betong", + "Confirm before deleting": "Bekräfta före radering", + "Confirm before moving photos to Recently Deleted": "Bekräfta innan foton flyttas till Nyligen raderade", + "Constrain Crop": "Begränsa beskärning", + "Contact Sheet PDF…": "Kontaktark som PDF…", + "Container": "Behållare", + "contains": "innehåller", + "Content-Aware": "Innehållsmedveten", + "Continue": "Fortsätt", + "Continue Without Saving": "Fortsätt utan att spara", + "Contrast": "Kontrast", + "Contributors": "Bidragsgivare", + "Convert to DNG": "Konvertera till DNG", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Konvertera till DNG, Kopiera som DNG, Fotosammanfogning och smarta förhandsgranskningar skriver inte längre direkt till den slutliga filen (ärende", + "Cool Fade": "Kall toning", + "Cool Morning": "Kall morgon", + "Copy": "Kopiera", + "Copy After's Settings to Before": "Kopiera inställningar från Efter till Före", + "Copy as DNG": "Kopiera som DNG", + "Copy Before to After": "Kopiera Före till Efter", + "Copy Before's Settings to After": "Kopiera inställningar från Före till Efter", + "Copy Edit Settings": "Kopiera redigeringsinställningar", + "Copy History Step Settings to Before": "Kopiera inställningar från historiksteg till Före", + "Copy into the library (else use the files where they are)": "Kopiera till biblioteket (annars används filerna där de ligger)", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopian är verifierad och finns i biblioteket. Dubbletter och filer som misslyckas ligger kvar där de var.", + "Copy Metadata": "Kopiera metadata", + "Copy Name": "Kopiera namn", + "Copy pages": "Kopiera sidor", + "Copy Settings…": "Kopiera inställningar…", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopiera filerna (till bibliotekets Originals/ eller en mapp du väljer)", + "Copy to": "Kopiera till", + "Copy to Clipboard": "Kopiera till Urklipp", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopiera: filerna kopieras till mappen nedan och biblioteket använder kopiorna; originalen lämnas orörda.", + "Copyright": "Upphovsrätt", + "Copyright Info URL": "URL för upphovsrättsinformation", + "Copyright Status": "Upphovsrättsstatus", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Upphovsrättsstatus, användningsvillkor och URL för upphovsrättsinformation i Info, metadataförinställningar och exporter.", + "Copyrighted": "Upphovsrättsskyddad", + "copyrighted": "upphovsrättsskyddad", + "Country": "Land", + "CPU threads": "CPU-trådar", + "Create": "Skapa", + "Create Album": "Skapa album", + "Create Album…": "Skapa album…", + "Create Folder": "Skapa mapp", + "Create Folder…": "Skapa mapp…", + "Create Keyword": "Skapa nyckelord", + "Create Keyword Tag": "Skapa nyckelordstagg", + "Create New Mask": "Skapa ny mask", + "Create PDF from clipboard": "Skapa PDF från Urklipp", + "Create PDF from file…": "Skapa PDF från fil…", + "Create PDF from images…": "Skapa PDF från bilder…", + "Create Preset": "Skapa förinställning", + "Create Preset…": "Skapa förinställning…", + "Create Proof Copy": "Skapa korrekturkopia", + "Create Smart Album": "Skapa smart album", + "Create Smart Album from Filter…": "Skapa smart album från filter…", + "Create Smart Album…": "Skapa smart album…", + "Create Stack": "Skapa stapel", + "Create Version": "Skapa version", + "Create Version from Step": "Skapa version från steg", + "Create Virtual Copy": "Skapa virtuell kopia", + "Creator": "Skapare", + "Creator metadata": "Metadata om skapare", + "Crisp Landscape": "Skarpt landskap", + "Crisp Vista": "Skarp vy", + "Crop": "Beskär", + "Crop & Rotate": "Beskär och rotera", + "Crop & Rotate (C)": "Beskär och rotera (C)", + "Crop Aspect": "Bildförhållande för beskärning", + "Crop, Rotate, Geometry": "Beskärning, rotering, geometri", + "Cropped": "Beskuren", + "Cross Process": "Korsframkallning", + "Culling": "Gallring", + "Curve": "Kurva", + "Curve channel": "Kurvkanal", + "Custom": "Anpassad", + "Custom template…": "Anpassad mall…", + "Custom…": "Anpassad…", + "Cut": "Klipp ut", + "Cut pages": "Klipp ut sidor", + "Cycle Crop Overlay": "Växla beskärningsöverlägg", + "Cycle Crop Overlay Orientation": "Växla beskärningsöverläggets orientering", + "Cycle Info Overlay": "Växla infoöverlägg", + "Cycle Mask Overlay Color": "Växla maskens överläggsfärg", + "Cycle Mask Overlay Mode": "Växla maskens överläggsläge", + "Cylindrical": "Cylindrisk", + "Dark & Moody": "Mörkt och stämningsfullt", + "Dark Mode": "Mörkt läge", + "Dark Theme": "Mörkt tema", + "Darken": "Mörkare", + "Darks": "Mörka", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "data avkodas inte än, så de visas bara som förhandsgranskning. För CR3-filer som importerats tidigare, Foto → Läs in igen från disk (nu i", + "Date": "Datum", + "Date Groups": "Datumgrupper", + "Day": "Dag", + "day 01–31": "dag 01–31", + "Daylight": "Dagsljus", + "days": "dagar", + "Decrease Brush Feather": "Minska penselns fjädring", + "Decrease Brush Size": "Minska penselstorlek", + "Decrease Rating": "Sänk betyg", + "Deep Sky": "Djup himmel", + "Defringe": "Ta bort kantfärg", + "Deghost Amount": "Styrka för spökborttagning", + "Dehaze": "Ta bort dis", + "Delete": "Radera", + "Delete (⌫)": "Radera (⌫)", + "Delete Album": "Radera album", + "Delete all": "Radera alla", + "Delete All Masks": "Radera alla masker", + "Delete all spots": "Radera alla fläckar", + "Delete Color Label Set": "Radera färgetikettuppsättning", + "Delete Component": "Radera komponent", + "Delete Export Preset": "Radera exportförinställning", + "Delete Filter Preset": "Radera filterförinställning", + "Delete Imported Profile": "Radera importerad profil", + "Delete Keyword": "Radera nyckelord", + "Delete Keyword Set": "Radera nyckelordsuppsättning", + "Delete Keyword…": "Radera nyckelord…", + "Delete Mask": "Radera mask", + "Delete mask": "Radera mask", + "Delete Metadata Preset": "Radera metadataförinställning", + "Delete Permanently": "Radera permanent", + "Delete Photo": "Radera foto", + "Delete Photos": "Radera foton", + "Delete Point Color": "Radera punktfärg", + "Delete Point Color Sample": "Radera punktfärgsprov", + "Delete Point Curve Preset": "Radera förinställning för punktkurva", + "Delete Preset": "Radera förinställning", + "Delete Red Eye": "Radera röda ögon", + "Delete Red Eye Correction": "Radera korrigering av röda ögon", + "Delete Spot": "Radera fläck", + "Delete them": "Radera dem", + "Delete Version": "Radera version", + "Delete…": "Radera…", + "Denoise all photos with an Amount": "Brusreducera alla foton med en styrka", + "Denoise Model Downloads": "Nedladdningar av brusreduceringsmodeller", + "Denoise Models": "Brusreduceringsmodeller", + "Denoise Photos": "Brusreducera foton", + "Denoise Pump": "Brusreduceringspump", + "Denoise Settings": "Inställningar för brusreducering", + "Denoise Status": "Status för brusreducering", + "denoiseModel.accept": "denoiseModel.accept", + "Density": "Täthet", + "Depth Range": "Djupomfång", + "Descending": "Fallande", + "Describe": "Beskriv", + "Describe what to keep": "Beskriv vad som ska behållas", + "Describe what to leave out": "Beskriv vad som ska uteslutas", + "Describe what to select": "Beskriv vad som ska markeras", + "Describe…": "Beskriv…", + "Description": "Beskrivning", + "Deselect": "Avmarkera", + "Deselect All": "Avmarkera alla", + "Desert Heat": "Ökenhetta", + "Desert Warmth": "Ökenvärme", + "Desktop": "Skrivbord", + "Destination library": "Målbibliotek", + "Detail": "Detalj", + "Detail (D)": "Detalj (D)", + "Details": "Detaljer", + "Detect Faces": "Identifiera ansikten", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Identifiera ansikten (menyn Foto) behöver den: en liten engångsnedladdning.", + "Develop": "Framkalla", + "Diagonal": "Diagonal", + "Discard Preview Cache": "Släng förhandsgranskningscache", + "Discarding smart previews": "Slänger smarta förhandsgranskningar", + "discarding smart previews": "slänger smarta förhandsgranskningar", + "Display": "Visning", + "Display name": "Visningsnamn", + "Display P3": "Display P3", + "Display Profile": "Bildskärmsprofil", + "Display theme": "Visningstema", + "Distortion": "Distorsion", + "Document properties…": "Dokumentegenskaper…", + "doesn't contain": "innehåller inte", + "Don't enlarge": "Förstora inte", + "Done": "Klart", + "Download": "Hämta", + "Download AI Mask Model": "Hämta AI-maskmodell", + "Download Denoise Model": "Hämta brusreduceringsmodell", + "Download Face Model": "Hämta ansiktsmodell", + "Download Place Names (GeoNames)": "Hämta ortnamn (GeoNames)", + "Download the SAM 3 Model?": "Vill du hämta SAM 3-modellen?", + "Downloaded and checked. Installing…": "Hämtad och kontrollerad. Installerar…", + "Downloading model": "Hämtar modell", + "Downloading place names": "Hämtar ortnamn", + "Downloads": "Hämtade filer", + "Drag along the horizon; double-click for Auto": "Dra längs horisonten; dubbelklicka för Auto", + "Drag Crop Handle": "Dra beskärningshandtag", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Dra över ett öga på fotot; pupillen inuti hittas automatiskt.", + "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.": "Dra för att ange nyans och mättnad. Håll ned Shift för att behålla nyansen, Cmd/Ctrl för att behålla mättnaden och Alt/Option för att finjustera. Dubbelklicka för att återställa.", + "Draw Guides": "Rita stödlinjer", + "Drop here for the top level": "Släpp här för översta nivån", + "Duplicate": "Duplicera", + "Duplicate and Invert Mask": "Duplicera och invertera mask", + "Duplicate Component": "Duplicera komponent", + "Duplicate Mask": "Duplicera mask", + "Duration": "Längd", + "e.g. sky · the red car · car, road": "t.ex. sky · the red car · car, road", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Varje / påbörjar en mappnivå; taggarna fylls i per foto (en nivå där metadata saknas blir \"unknown\").", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Varje foto så stort det får plats, på svart, med en kort korstoning. Inget ritas över det.", + "Edge": "Kant", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "kanten beskärs bort, och filer i \"12-bit uncompressed\" klipps inte längre. RX100-serien återges mycket närmare", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "Kant-inställningen och får en skarpare inzoomad omgång i bakgrunden. Modellen körs inuti LightCraft i ren Rust", + "Edge to Edge": "Kant till kant", + "Edit": "Redigera", + "Edit (E)": "Redigera (E)", + "Edit Capture Time": "Redigera tagningstid", + "Edit Capture Time…": "Redigera tagningstid…", + "Edit Color Label Names": "Redigera namn på färgetiketter", + "Edit Color Label Names…": "Redigera namn på färgetiketter…", + "Edit Copy for External Editor": "Redigera kopia i extern redigerare", + "Edit Date": "Redigera datum", + "Edit in External Editor": "Redigera i extern redigerare", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Redigera i extern redigerare (⇧⌘E): en 16-bitars TIFF-kopia, staplad, som uppdateras när du kommer tillbaka.", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Redigera i HDR: högdagrar över SDR-vitt, för HDR-exporter och HDR-skärmar", + "Edit in Other Editor": "Redigera i annat program", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Redigera i annat program (⌥⌘E) öppnar kopian i det här programmet (ett appnamn på macOS, en programsökväg på andra system).", + "Edit in PhotoCraft": "Redigera i PhotoCraft", + "Edit Info": "Redigera info", + "Edit Keyword": "Redigera nyckelord", + "Edit Keyword Set": "Redigera nyckelordsuppsättning", + "Edit Keyword Tag": "Redigera nyckelordstagg", + "Edit Keyword Tag…": "Redigera nyckelordstagg…", + "Edit Label Names": "Redigera etikettnamn", + "Edit Label Names…": "Redigera etikettnamn…", + "Edit Mask": "Redigera mask", + "Edit Mask Component": "Redigera maskkomponent", + "Edit Sections": "Redigera avsnitt", + "Edit settings copied": "Redigeringsinställningar kopierade", + "Edit Set…": "Redigera uppsättning…", + "Edit Smart Album": "Redigera smart album", + "Edit Smart Album…": "Redigera smart album…", + "Edit Spot": "Redigera fläck", + "Edited": "Ändrat", + "Edited or not": "Ändrat eller inte", + "Editing": "Redigering", + "Effect": "Effekt", + "Effects": "Effekter", + "Empty Recently Deleted": "Töm Nyligen raderade", + "Enable Lens Corrections": "Aktivera linskorrigeringar", + "Enable Profile Corrections": "Aktivera profilkorrigeringar", + "End": "Slut", + "End Interaction": "Avsluta interaktion", + "Ending": "Avslutning", + "Ending screen": "Slutskärm", + "ends with": "slutar med", + "English": "Engelska", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Ange ett datum som ÅÅÅÅ-MM-DD TT:MM:SS", + "Enter Full Screen": "Visa i helskärm", + "Enter to confirm, Esc to cancel": "Enter för att bekräfta, Esc för att avbryta", + "Erase": "Radera", + "Evaluate Face Recognition": "Utvärdera ansiktsigenkänning", + "Every EXIF, GPS and XMP field in the file": "Alla EXIF-, GPS- och XMP-fält i filen", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "alla format som kamerorna skriver. De flesta kamerahus öppnades tidigare bara som förhandsgranskning: GH1–GH5, G, GX, GF, GM, FZ, LX och", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Varje foto beskärs så att det fyller hela skärmen, med långsamma korstoningar.", + "Existing previews": "Befintliga förhandsgranskningar", + "Exit": "Avsluta", + "Expand": "Expandera", + "Expand All Stacks": "Expandera alla staplar", + "Expand Stack": "Expandera stapel", + "Expand/Collapse Stack": "Expandera/komprimera stapel", + "Export": "Exportera", + "Export Album with Preset": "Exportera album med förinställning", + "Export Album…": "Exportera album…", + "Export As…": "Exportera som…", + "Export Contact Sheet PDF": "Exportera kontaktark som PDF", + "Export Containing Keywords": "Exportera överordnade nyckelord", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportfilnamn använder samma koder som Byt namn på foton ({title}, {seq:2}, {date:%Y-%m-%d}…), plus de nya {num}, {folder}, {lens}, {iso}, {rating} och {creator}.", + "Export Group…": "Exportera grupp…", + "Export JPEG…": "Exportera JPEG…", + "Export Keywords": "Exportera nyckelord", + "Export Keywords…": "Exportera nyckelord…", + "Export Now": "Exportera nu", + "Export PDF…": "Exportera PDF…", + "Export Point Curve Presets": "Exportera punktkurvförinställningar", + "Export Point Curve Presets…": "Exportera punktkurvförinställningar…", + "Export Presets": "Exportera förinställningar", + "Export Presets…": "Exportera förinställningar…", + "Export Slideshow": "Exportera bildspel", + "Export Slideshow Template": "Exportera bildspelsmall", + "Export Slideshow: folder for the JPEG files": "Exportera bildspel: mapp för JPEG-filerna", + "Export Slideshow…": "Exportera bildspel…", + "Export stopped unexpectedly": "Exporten avbröts oväntat", + "Export Synonyms": "Exportera synonymer", + "Export to": "Exportera till", + "Export User Presets…": "Exportera användarförinställningar…", + "Export Video…": "Exportera video…", + "Export with Preset": "Exportera med förinställning", + "Export with Previous": "Exportera med föregående", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "export. Katalogen, XMP-sidofiler, inställningar, DNG-filer, sammanfogningar, smarta förhandsgranskningar och kopiorna som en import skapar är fortfarande", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exporterade filer skrivs först till en temporär fil och flyttas sedan på plats, så en full disk eller en urkopplad enhet", + "Exporting": "Exporterar", + "Exporting contact sheet": "Exporterar kontaktark", + "Exporting slideshow": "Exporterar bildspel", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Exporter och renderingar skriver aldrig över ett fotos original (ärende #93): export till fotots egen mapp med", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Exporter blir aldrig svarta på grund av GPU:n (ärende #78): en GPU-rendering som får slut på enhetens", + "Export…": "Exportera…", + "Exposure": "Exponering", + "Exposure (shutter · aperture · ISO)": "Exponering (slutartid · bländare · ISO)", + "Extended Description": "Utökad beskrivning", + "External Editing": "Extern redigering", + "External Editor": "Extern redigerare", + "Face Boxes": "Ansiktsrutor", + "Face boxes": "Ansiktsrutor", + "Face detector": "Ansiktsdetektor", + "Face Model Downloads": "Nedladdning av ansiktsmodeller", + "Face Models": "Ansiktsmodeller", + "Face names written to XMP by Lightroom and other apps show up here.": "Ansiktsnamn som Lightroom och andra appar har skrivit till XMP visas här.", + "Face recognition": "Ansiktsigenkänning", + "Face Recognition On/Off": "Ansiktsigenkänning på/av", + "Faces": "Ansikten", + "Faces of a Person": "Ansikten för en person", + "Fade": "Toning", + "Fade duration": "Toningslängd", + "Faded Matte": "Blekt och matt", + "Faded Slide": "Blekt dia", + "Faded Urban": "Blekt urban", + "Failed": "Misslyckades", + "Failed its last test": "Misslyckades i senaste testet", + "false": "falskt", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Snabbare exporter och kortimporter, med samma skydd för det som inte kan återskapas (ärende #134): exporter,", + "Favorite": "Favorit", + "Favorite Preset": "Favoritförinställning", + "Favorite Profile": "Favoritprofil", + "Favorites": "Favoriter", + "Feather": "Fjädring", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "fälten och behåller resten – t.ex. det programmets framkallningsinställningar och redigeringshistorik – byte för byte. En", + "File": "Fil", + "File & date": "Fil och datum", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "filen (Sony lagrar dem bara i krypterade tillverkardata i dessa kamerahus), de få kolumnerna med utfyllnad till höger", + "File Extension": "Filändelse", + "File Format": "Filformat", + "File Name": "Filnamn", + "File name": "Filnamn", + "File Path": "Sökväg", + "File Size": "Filstorlek", + "File Type": "Filtyp", + "File types:": "Filtyper:", + "Filename": "Filnamn", + "Files with the same content as another file in this import: only the first is imported.": "Filer med samma innehåll som en annan fil i den här importen: bara den första importeras.", + "Fill": "Fyllning", + "Fill Edges": "Fyll kanter", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Fyll i tomma Ort, Region och Land från GPS (GeoNames-ortnamn offline)", + "Fill Location": "Fyll i plats", + "Fill Location from GPS": "Fyll i plats från GPS", + "Film": "Film", + "Filmstrip": "Filmremsa", + "Filmstrip (/)": "Filmremsa (/)", + "Filter": "Filter", + "Filter Bar": "Filterfält", + "Filter bar": "Filterfält", + "Filter fields": "Filtrera fält", + "Filter Keywords": "Filtrera nyckelord", + "Filter Presets": "Filtrera förinställningar", + "Filters": "Filter", + "Find Blemishes": "Sök efter fläckar", + "Find Dust Spots": "Sök efter dammfläckar", + "Find Missing Photos": "Sök efter saknade foton", + "Find Missing Photos…": "Sök efter saknade foton…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Sök efter foton som lagts till i eller saknas i den här mappen på disken, och XMP-sidofiler som ändrats av andra appar", + "Find Similar Photos": "Sök efter likartade foton", + "Find tools and commands…": "Sök verktyg och kommandon…", + "Finding faces": "Söker efter ansikten", + "Find…": "Sök…", + "First": "Första", + "First commit": "Första commit", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "första gången du använder en AI-mask frågar LightCraft innan den laddas ned, visar förloppet, kan avbryta och återuppta,", + "Fit": "Anpassa", + "Fit (F)": "Anpassa (F)", + "Fit on Screen": "Anpassa till skärmen", + "Fit to width": "Anpassa till bredd", + "Fit visible": "Anpassa synligt", + "Five stars": "Fem stjärnor", + "Fix the marked rules to save this album.": "Rätta de markerade reglerna för att spara albumet.", + "Fixed keys": "Fasta tangenter", + "Flag": "Flagga", + "Flag as Pick": "Flagga som vald", + "Flag as Reject": "Flagga som avvisad", + "Flagged as Pick": "Flaggad som vald", + "Flagged as Reject": "Flaggad som avvisad", + "Flash": "Blixt", + "Flip H": "Vänd vågr.", + "Flip Horizontal": "Vänd vågrätt", + "Flip Overlay (⇧O)": "Vänd överlägg (⇧O)", + "Flip V": "Vänd lodr.", + "Flip Vertical": "Vänd lodrätt", + "Float panel": "Gör panelen flytande", + "Flow": "Flöde", + "Fluorescent": "Lysrör", + "Focal Length": "Brännvidd", + "Focal length": "Brännvidd", + "Focus (assisted culling)": "Fokus (assisterad gallring)", + "Folder": "Mapp", + "Folder name (renamed on disk; its photos follow)": "Mappnamn (byter namn på disken; fotona följer med)", + "Folders": "Mappar", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Mappar och {date} använder tagningstiden; ett foto utan tagningstid får dagens datum.", + "Food": "Mat", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "för sessionen i stället för att skrivas över med standardvärden. Appinställningarna (`ui.json`, som kommer ihåg dina", + "Forget Unchanged Local Photos": "Glöm oförändrade lokala foton", + "Forget unchanged photos": "Glöm oförändrade foton", + "Form fields panel": "Panelen Formulärfält", + "Format": "Format", + "Forward": "Spola framåt", + "Found automatically": "Hittas automatiskt", + "Fr": "fr", + "Frame rate": "Bildfrekvens", + "Frame time": "Bildtid", + "Frame update": "Bilduppdatering", + "Free": "Fri", + "Fresh & Bright": "Fräsch och ljus", + "Friday": "Fredag", + "Full": "Full", + "Full screen mode": "Helskärmsläge", + "Full Screen Preview": "Förhandsgranskning i helskärm", + "Full size": "Full storlek", + "Gallery Wall": "Gallerivägg", + "General": "Allmänt", + "Generated demo photo": "Genererat demofoto", + "Geometry": "Geometri", + "Get Develop Settings": "Hämta framkallningsinställningar", + "gets -1, -2… instead of being replaced.": "får -1, -2… i stället för att ersättas.", + "Global Hue": "Global nyans", + "Global Luminance": "Global luminans", + "Global Saturation": "Global mättnad", + "Glossy": "Glansig", + "Go back to listing them by name": "Gå tillbaka till att lista dem efter namn", + "Go to History Step": "Gå till historiksteg", + "Golden": "Gyllene", + "Golden Glow": "Gyllene glöd", + "Golden Hour": "Gyllene timmen", + "GPS Track Log": "GPS-spårlogg", + "GPU Rendering": "GPU-rendering", + "GPU rendering": "GPU-rendering", + "Grab bag": "Blandat", + "Grain": "Korn", + "Graphic": "Grafik", + "Graphics card": "Grafikkort", + "Green": "Grön", + "green": "grön", + "Green Amount": "Grön mängd", + "Green channel": "Grön kanal", + "Green Hue": "Grön nyans", + "Green Hue High": "Grön nyans, hög", + "Green Hue Low": "Grön nyans, låg", + "Green label": "Grön etikett", + "Green Luminance": "Grön luminans", + "Green Primary": "Grön primärfärg", + "Green Saturation": "Grön mättnad", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "grönaktig neutral återgivning (ärende #150); vitbalansen justeras i förhållande till utseendet vid fotograferingen. 12-bitars NEF-filer", + "Grid": "Rutnät", + "Grid Info": "Rutnätsinfo", + "Gritty Street": "Rå gatumiljö", + "Group": "Grupp", + "Group (optional)": "Grupp (valfritt)", + "Group by date": "Gruppera efter datum", + "Group by Date: Automatic": "Gruppera efter datum: automatiskt", + "Group by Day": "Gruppera per dag", + "Group by Month": "Gruppera per månad", + "Group by Year": "Gruppera per år", + "Group into Stack": "Gruppera i stapel", + "Group name": "Gruppnamn", + "Grouped by parent": "Grupperade efter överordnad", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Grupperat: nyckelord inom samma nyckelord delar en rad som namnger det, och långa listor och förslagen förkortas.", + "Guided": "Guidad", + "Guided Upright": "Guidad upprätning", + "Guided Upright Guides": "Stödlinjer för guidad upprätning", + "Has Edits": "Har redigeringar", + "Has GPS": "Har GPS", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "har öppet – på den här datorn eller en annan som delar mappen – anger vem som har det (\"redan öppet i LightCraft (process", + "has the first date after the second": "har det första datumet efter det andra", + "HDR": "HDR", + "HDR for Each Bracket Set": "HDR för varje exponeringsserie", + "HDR Headroom": "HDR-marginal", + "HDR Merge": "HDR-sammanfogning", + "HDR Merge Preview": "Förhandsgranskning av HDR-sammanfogning", + "HDR panorama": "HDR-panorama", + "HDR Panorama Merge": "HDR-panoramasammanfogning", + "HDR Panorama Merge Preview": "Förhandsgranskning av HDR-panoramasammanfogning", + "HDR Panorama with Last Settings": "HDR-panorama med senaste inställningar", + "HDR Panoramas": "HDR-panoramor", + "HDR panoramas": "HDR-panoramor", + "HDR Panorama…": "HDR-panorama…", + "HDR TIFF needs 32-bit float.": "HDR TIFF kräver 32-bitars flyttal.", + "HDR with Last Settings": "HDR med senaste inställningar", + "HDR…": "HDR…", + "Heal": "Läk", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Läk de små, mjuka mörka fläckar som sensordamm lämnar på jämna ytor", + "Height": "Höjd", + "Height (px)": "Höjd (px)", + "Help": "Hjälp", + "Hide LightCraft": "Göm LightCraft", + "Hide Mask": "Dölj mask", + "Hide Others": "Göm övriga", + "Hide This Section": "Dölj det här avsnittet", + "Hides this shortcut only; the folder and its photos stay as they are": "Döljer bara den här genvägen; mappen och dess foton förblir som de är", + "High": "Hög", + "High Contrast B&W": "Svartvitt med hög kontrast", + "Highlight": "Högdager", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Markerat: reglage som den här masken ändrar. Reglage som utelämnas förblir som de är när förinställningen används.", + "Highlights": "Högdagrar", + "Highlights Hue": "Nyans i högdagrar", + "Highlights Luminance": "Luminans i högdagrar", + "Highlights Saturation": "Mättnad i högdagrar", + "Highlights split": "Delning av högdagrar", + "Histogram": "Histogram", + "History": "Historik", + "History & Activity (Y)": "Historik och aktivitet (Y)", + "Hold View Until Refreshed": "Behåll vyn tills den uppdaterats", + "Home": "Start", + "Horizontal": "Horisontell", + "Horizontal inset (% of short edge)": "Horisontell infällning (% av kortsidan)", + "Horizontal text": "Horisontell text", + "hour 00–23": "timme 00–23", + "Hours": "Timmar", + "hours": "timmar", + "How to install the model": "Så installerar du modellen", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "hur du återställer den eller raderar den permanent och importerar den på nytt. (För att börja om med ett foto, Återställ redigeringar,", + "Hue": "Nyans", + "Hue Range": "Nyansintervall", + "I have read these terms and accept them for my own use": "Jag har läst villkoren och godkänner dem för eget bruk", + "Identity plate": "Identitetsplåt", + "If file exists": "Om filen finns", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Om ditt bibliotek inte kan öppnas vid start (öppet i ett annat program, oläsbart, på en enhet som inte är ansluten,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ignorerades tidigare) väljer grafikbakänden; `off` renderar på CPU:n. GPU:n startar nu efter att fönstret", + "Image": "Bild", + "image": "bild", + "immutable, and HOSTING.md describes the actual build.": "oföränderlig, och HOSTING.md beskriver det faktiska bygget.", + "Import": "Importera", + "Import as new": "Importera som ny", + "Import Date": "Importdatum", + "Import from Device": "Importera från enhet", + "Import from Folder…": "Importera från mapp…", + "Import Keywords": "Importera nyckelord", + "Import Keywords…": "Importera nyckelord…", + "Import Lightroom Catalog": "Importera Lightroom-katalog", + "Import Lightroom Catalog…": "Importera Lightroom-katalog…", + "Import new photos": "Importera nya foton", + "Import Photos": "Importera foton", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importera foton med Arkiv → Importera foton… (Cmd+Shift+I) eller släpp dem här", + "Import Photos…": "Importera foton…", + "Import Point Curve Presets": "Importera förinställningar för punktkurva", + "Import Point Curve Presets…": "Importera förinställningar för punktkurva…", + "Import Presets": "Importera förinställningar", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importera förinställningar från andra program: XMP-förinställningar, klassiska `.lrtemplate`-filer, \"DNG-förinställningar\" från mobilappar och `.zip`", + "Import Presets…": "Importera förinställningar…", + "Import Profiles": "Importera profiler", + "Import Profiles & Presets…": "Importera profiler och förinställningar…", + "Import Slideshow Templates": "Importera bildspelsmallar", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importen – så \"importen klar\" betyder att kopiorna är felfria innan du formaterar kortet – och ett namn som är upptaget", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importera → Kopiera verifierar varje kopia, precis som Flytta (ärende #96): varje fil skrivs som en ny fil, synkas till disken och", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importera → Flytta: fotona hamnar i målet (med samma mappar och namnbyten som Kopiera, t.ex.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiera till valfri mapp, per dag / per månad / en mapp, byt namn vid import, metadataförinställning, Kopiera som DNG.", + "Imported": "Importerad", + "imported as \"preview only\" switch over on Reload.": "importerade som \"endast förhandsgranskning\" går över vid Läs in igen.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "importerade tidigare går över med Foto → Läs in från disk igen.", + "Imported Photos": "Importerade foton", + "Importing": "Importerar", + "Importing Lightroom Catalog": "Importerar Lightroom-katalog", + "Importing Lightroom catalog": "Importerar Lightroom-katalog", + "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).": "Vid import söks källan igenom efter foton; mappen läggs inte till i Lokalt (använd Lokalt → Bläddra i mapp… för att arbeta i en mapp utan att importera).", + "Import…": "Importera…", + "In a Stack": "I en stapel", + "In Color": "I färg", + "In library": "I biblioteket", + "In memory — nothing is saved": "I minnet – ingenting sparas", + "In Photo Grid and Square Grid: expand or collapse the stack": "I Fotorutnät och Kvadratiskt rutnät: expandera eller komprimera stapeln", + "In Photo Grid and Square Grid: Flag as Pick and advance": "I Fotorutnät och Kvadratiskt rutnät: flagga som vald och gå vidare", + "In Recently Deleted": "I Nyligen raderade", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "i korta delar så att långsamma integrerade GPU:er inte återställs av sin watchdog. `ui.inspect` → `perf`", + "In Use": "Används", + "In use": "Används", + "in use": "används", + "In-memory session: these settings last until LightCraft quits.": "Session i minnet: de här inställningarna gäller tills LightCraft avslutas.", + "Include Amount": "Ta med antal", + "Include brush size, feather and flow": "Ta med penselstorlek, fjädring och flöde", + "Include on Export": "Ta med vid export", + "Include selected photos": "Ta med markerade foton", + "Include subfolders": "Inkludera undermappar", + "includes the folders inside it": "inkluderar mapparna i den", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "ofullständigt görs om på CPU:n – filen blir samma bild i båda fallen. Arbete skickas till GPU:n", + "Increase Brush Feather": "Öka penselfjädring", + "Increase Brush Size": "Öka penselstorlek", + "Increase Rating": "Höj betyg", + "Index Faces": "Indexera ansikten", + "Index Faces in the Background": "Indexera ansikten i bakgrunden", + "Info": "Info", + "Info (I)": "Info (I)", + "Info overlay": "Infoöverlägg", + "Info Overlay Off": "Infoöverlägg av", + "Info Overlay: Exposure": "Infoöverlägg: Exponering", + "Info Overlay: File": "Infoöverlägg: Fil", + "Insert at the cursor": "Infoga vid textmarkören", + "Inspect Denoise Model": "Inspektera brusreduceringsmodell", + "Inspect Face Model File": "Inspektera ansiktsmodellfil", + "Inspect Lightroom Catalog": "Inspektera Lightroom-katalog", + "Inspect Photo": "Inspektera foto", + "Inspecting Lightroom Catalog": "Inspekterar Lightroom-katalog", + "Install a denoise model to enable AI Denoise on this photo": "Installera en brusreduceringsmodell för att aktivera AI-brusreducering för det här fotot", + "Install Denoise Model": "Installera brusreduceringsmodell", + "Install Face Model": "Installera ansiktsmodell", + "Install from file…": "Installera från fil…", + "Install the SAM 3 Model": "Installera SAM 3-modellen", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installera din egen ONNX-modell med denoise-model.json bredvid. Den här versionen erbjuder inga modellnedladdningar.", + "installed": "installerad", + "Instant ’70s": "Instant 70-tal", + "Intent:": "Återgivningsmetod:", + "Interface": "Gränssnitt", + "Interface language": "Gränssnittsspråk", + "Intersect": "Skärning", + "Intersect with": "Skär med", + "Into one folder": "I en mapp", + "Intro": "Intro", + "Intro screen": "Introskärm", + "Invert": "Invertera", + "Invert Component": "Invertera komponent", + "Invert Mask": "Invertera mask", + "is": "är", + "is <": "är <", + "is >": "är >", + "is after": "är efter", + "is before": "är före", + "is between": "är mellan", + "is empty": "är tomt", + "is in the last": "är inom de senaste", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "behålls). En misslyckad sparning (fullt lagringsutrymme) visar nu varningen om osparade ändringar och görs om, ett foto som inte kan lagras", + "is refused and nothing is overwritten.": "avvisas och ingenting skrivs över.", + "is unknown": "är okänt", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "är igång, och bara när Inställningar → Prestanda → Använd GPU:n för rendering är på; om LightCraft någon gång kraschar medan", + "is yes or no": "är ja eller nej", + "is ≤": "är ≤", + "is ≥": "är ≥", + "isn't": "är inte", + "isn't a field rules know": "är inte ett fält som regler känner till", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "läggs inte till, en andra flik visar ett meddelande i stället för att skriva över den första, `?reset` frågar först, och sidan säger", + "isn't empty": "är inte tomt", + "isn't in the last": "är inte inom de senaste", + "isn't one of the choices": "är inte ett av alternativen", + "ISO Speed": "ISO-känslighet", + "ISO speed": "ISO-känslighet", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "den från den andra (ärende #92). Hitta saknade foton hittar också omdöpta filer via innehållet, föredrar en innehållsträff", + "It tests this album, so testing it back would loop": "Det testar det här albumet, så att testa det tillbaka skulle ge en loop", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Den brusreducerade bilden har inte skapats än, och bilder skapas inte automatiskt (Inställningar, AI-brusreducering).", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "dess foton och XMP-sidofiler, och fotona pekar på den igen. Om något nu upptar den gamla platsen, så", + "Join the ArtCraft community on Discord": "Gå med i ArtCrafts community på Discord", + "Join the ArtCraft Discord": "Gå med i ArtCraft på Discord", + "Join the ArtCraft Discord…": "Gå med i ArtCraft på Discord…", + "JPEG (Large)": "JPEG (stor)", + "JPEG (Small)": "JPEG (liten)", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG med samma namn (`IMG_0001.CR3` + `IMG_0001.JPG`) delar inte längre en sidofil: raw-filen behåller `IMG_0001.xmp`,", + "Keep in Local": "Behåll i Lokalt", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Placera den på en enhet med utrymme: smarta förhandsgranskningar tar ungefär 1 MB per foto. Miniatyrcachen ligger kvar i biblioteket.", + "keep names — or e.g. {date}_{seq:3}": "behåll namnen – eller t.ex. {date}_{seq:3}", + "Keyboard Shortcuts": "Kortkommandon", + "Keyboard shortcuts": "Kortkommandon", + "Keyword": "Nyckelord", + "Keyword Count": "Antal nyckelord", + "Keyword Info": "Nyckelordsinfo", + "Keyword List": "Nyckelordslista", + "Keyword Lists": "Nyckelordslistor", + "Keyword Name": "Nyckelordsnamn", + "Keyword Painter": "Nyckelordspensel", + "Keyword Set": "Nyckelordsuppsättning", + "Keyword Sets": "Nyckelordsuppsättningar", + "Keyword Suggestions": "Nyckelordsförslag", + "Keyword Tag Options": "Alternativ för nyckelordstaggar", + "Keyword to paint": "Nyckelord att måla", + "Keywords": "Nyckelord", + "Keywords & People": "Nyckelord och människor", + "Keywords (K)": "Nyckelord (K)", + "Keywords (⌥1–⌥9)": "Nyckelord (⌥1–⌥9)", + "Keywords of the selection": "Markeringens nyckelord", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Nyckelord du lägger till visas här; ⌥1–⌥9 tillämpar dem.", + "Label": "Etikett", + "Label Set": "Etikettuppsättning", + "Landscape": "Liggande", + "Landscape (wide)": "Liggande (bred)", + "Language": "Språk", + "Last": "Senaste", + "Last commit": "Senaste commit", + "Last GPU fallback": "Senaste GPU-reserv", + "Last loupe render": "Senaste lupprendering", + "Last view": "Senaste vy", + "Lateral Chromatic Aberration": "Lateral kromatisk aberration", + "latitude, longitude": "latitud, longitud", + "Layer": "Lager", + "Layers": "Lager", + "Layout": "Layout", + "layout is read from each file instead of assumed.": "layouten läses från varje fil i stället för att antas.", + "Leave them": "Låt dem vara", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "lämnar ingen DNG och behåller rawfilen. Smarta förhandsgranskningar skrivs på samma sätt; en skadad (avbruten av en krasch eller", + "Left": "Vänster", + "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.": "Vänster: standardomfånget (SDR) upp till SDR-vitt. Höger: HDR-omfånget, ett band per steg över SDR-vitt (den orange linjen: HDR-marginalen). När Visualisera HDR-omfång är på får banden dess färger.", + "Lens": "Lins", + "Lens Blur": "Linsoskärpa", + "Lens Corrections": "Linskorrigeringar", + "Level": "Nivå", + "Library": "Bibliotek", + "Library Folders": "Biblioteksmappar", + "Library Info": "Biblioteksinfo", + "library locked: the lock is the operating system's and goes away with the program.": "biblioteket låst: låset hålls av operativsystemet och försvinner med programmet.", + "Library name": "Bibliotekets namn", + "Library Originals": "Bibliotekets original", + "Library Preferences": "Biblioteksinställningar", + "Library saved": "Biblioteket har sparats", + "Library State": "Bibliotekets status", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteket) skrivs atomärt och sparas så snart du öppnar ett annat bibliotek, inte bara vid avslut. Att avsluta medan", + "Licence:": "Licens:", + "Light": "Ljus", + "Light Mode": "Ljust läge", + "Light Theme": "Ljust tema", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 och äldre vägrar öppna det (\"unsupported format … v2\").", + "LightCraft could not start": "LightCraft kunde inte starta", + "LightCraft couldn't find where to keep your library.": "LightCraft hittade ingen plats att spara ditt bibliotek på.", + "LightCraft couldn't open the library at": "LightCraft kunde inte öppna biblioteket på", + "LightCraft Default": "LightCraft-standard", + "LightCraft does not know this model, so it assumed:": "LightCraft känner inte till den här modellen och antog därför:", + "LightCraft Help": "LightCraft-hjälp", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft kraschar inte längre vid start på Windows-datorer med trasig Vulkan-drivrutin (ärende #136, t.ex. vissa Intel UHD", + "LightCraft on GitHub": "LightCraft på GitHub", + "LightCraft Preset": "LightCraft-förinställning", + "LightCraft Presets": "LightCraft-förinställningar", + "LightCraft stopped": "LightCraft har stoppats", + "LightCraft Website": "LightCrafts webbplats", + "LightCraft website": "LightCrafts webbplats", + "LightCraft — Second Window": "LightCraft — Andra fönstret", + "Lightroom Catalog Import": "Import av Lightroom-katalog", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-kataloger importeras till det öppna biblioteket; ingen separat katalogmapp skapas.", + "Lightroom Classic Catalog": "Lightroom Classic-katalog", + "Lightroom import cancelled": "Lightroom-importen avbröts", + "Lightroom inspection cancelled": "Lightroom-granskningen avbröts", + "Lights": "Ljusa", + "Lights Out": "Nedsläckt", + "Limit": "Gränsvärde", + "Limit file size (KB, 0 = off)": "Begränsa filstorlek (KB, 0 = av)", + "Line delta": "Raddifferens", + "Linear": "Linjär", + "Linear Gradient": "Linjär gradient", + "Lines +/−": "Rader +/−", + "Lines added": "Tillagda rader", + "Lines deleted": "Borttagna rader", + "Link all margins": "Länka alla marginaler", + "List Albums": "Lista album", + "List Develop Controls": "Lista framkallningskontroller", + "List History": "Lista historik", + "List Presets": "Lista förinställningar", + "List Profiles": "Lista profiler", + "List this folder in Local from now on": "Visa den här mappen under Lokalt från och med nu", + "Loading preview…": "Läser in förhandsgranskning…", + "Local": "Lokalt", + "Local folders": "Lokala mappar", + "Local library — no cloud account needed": "Lokalt bibliotek – inget molnkonto behövs", + "Locate Missing Files…": "Leta upp saknade filer…", + "Locate Missing File…": "Leta upp saknad fil…", + "Locate Photo": "Leta upp foto", + "Location": "Plats", + "Lock": "Lås", + "Lock Crop Aspect": "Lås beskärningsförhållande", + "Locked": "Låst", + "Long Edge": "Långsida", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "säger inte längre bara \"dubblett överhoppad\": den öppnar sidopanelen på Nyligen raderade med fotot markerat och säger", + "Looking for changes": "Söker efter ändringar", + "Looking for photos…": "Söker efter foton…", + "Looking up places": "Slår upp platser", + "Lossless": "Förlustfri", + "Lossy DNG files and Smart Previews open as raw photos.": "DNG-filer med förstörande komprimering och smarta förhandsgranskningar öppnas som rawfoton.", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "förlorade allt vid avslut; en tillfällig session visar en banderoll hela tiden och skriver aldrig till ditt bibliotek.", + "Low": "Låg", + "Luminance": "Luminans", + "Luminance Range": "Luminansomfång", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-looker: `.lmp`-filer och `.mplumpack`-samlingar importeras som förinställningar (grupperade efter samling); reglagen", + "Lush Greens": "Frodig grönska", + "LUT-based": "LUT-baserad", + "Magenta": "Magenta", + "Magenta Hue": "Magenta nyans", + "Magenta Luminance": "Magenta luminans", + "Magenta Saturation": "Magenta mättnad", + "Make Candidate the Select": "Gör kandidaten till vald", + "Make it now": "Skapa nu", + "Make the denoised picture of the photos I am looking at": "Skapa den brusreducerade bilden av fotona jag tittar på", + "makes AI Denoise available": "gör AI-brusreducering tillgänglig", + "Manage Presets…": "Hantera förinställningar…", + "Manual": "Manuell", + "Margins": "Marginaler", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Marginalerna är en andel av bildens kortare sida, så bilderna ser likadana ut på alla skärmar.", + "Marquee zoom": "Zooma med ram", + "Mask": "Mask", + "Mask Adjustment": "Maskjustering", + "Masking": "Maskering", + "Masking (M)": "Maskering (M)", + "Masks": "Masker", + "Masks keep their sliders. Export presets first to keep a copy.": "Masker behåller sina reglage. Exportera förinställningarna först om du vill spara en kopia.", + "Match": "Matcha", + "Match Total Exposures": "Matcha total exponering", + "Matrix/TRC": "Matris/TRC", + "Matte": "Matte", + "Matte Soft": "Matte mjuk", + "Med": "Medel", + "Medium Contrast": "Medelkontrast", + "Megapixels": "Megapixel", + "Memory Budget": "Minnesbudget", + "Memory budget": "Minnesbudget", + "Memory for caches": "Minne för cacheminnen", + "memory only": "endast minne", + "Memory Usage": "Minnesanvändning", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "minne, överskrider GPU:ns buffertgränser, råkar ut för ett drivrutinsfel eller en återställning, eller kommer tillbaka", + "Menu": "Meny", + "Merge": "Sammanfoga", + "Merge into…": "Sammanfoga med…", + "Merge Keywords": "Sammanfoga nyckelord", + "Merged": "Sammanfogad", + "Merged PRs": "Sammanslagna PR:er", + "Merging": "Sammanfogar", + "Merging…": "Sammanfogar…", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "meddelande, och originalet lämnas orört byte för byte. Vanliga tidigare exporter skrivs fortfarande över när du ber om det.", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Metas villkor, inte LightCrafts. Genom att ladda ned den godkänner du dem.", + "Metadata": "Metadata", + "Metadata Preset": "Metadataförinställning", + "Metadata preset": "Metadataförinställning", + "Metadata Presets": "Metadataförinställningar", + "Metadata read": "Metadata läst", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "mitt i omgången: felet listar dem (gammalt → nytt) och biblioteket pekar på deras nya namn (ett delvis namnbyte som kan ångras),", + "Midnight": "Midnatt", + "Midpoint": "Mittpunkt", + "Midtones": "Mellantoner", + "Midtones Hue": "Mellantoner nyans", + "Midtones Luminance": "Mellantoner luminans", + "Midtones Saturation": "Mellantoner mättnad", + "Midtones split": "Delning av mellantoner", + "Minimize": "Minimera", + "minute": "minut", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Saknad metadata ({camera}, {title}…) lämnar ett tomt mellanrum; ett namn som blir tomt behåller originalnamnet.", + "Missing Photos": "Saknade foton", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Saknade foton flyttas till Nyligen raderade och kan återställas; inga filer på disken påverkas.", + "Misty Morning": "Dimmig morgon", + "Mo": "må", + "Mode": "Läge", + "Model": "Modell", + "Models": "Modeller", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modeller vars sensor börjar på en grön-blå rad) blir inte längre magentafärgade (ärende #85): färgfiltret", + "Modified Date": "Ändringsdatum", + "Monday": "måndag", + "Monitor profile": "Bildskärmsprofil", + "Mono Red Filter": "Mono rött filter", + "Mono Rich": "Mono fyllig", + "Mono Soft": "Mono mjuk", + "Monochrome": "Monokrom", + "Month": "Månad", + "month 01–12": "månad 01–12", + "months": "månader", + "Moody": "Stämningsfull", + "Moody Portrait": "Stämningsfullt porträtt", + "More": "Mer", + "More preset options": "Fler förinställningsalternativ", + "Move": "Flytta", + "Move Album": "Flytta album", + "Move Down": "Flytta ned", + "Move Down in Stack": "Flytta ned i stapeln", + "Move Folder": "Flytta mapp", + "Move Folder To…": "Flytta mappen till…", + "Move in Stack": "Flytta i stapeln", + "Move Keyword": "Flytta nyckelord", + "Move Mask": "Flytta mask", + "Move Preset to Group": "Flytta förinställning till grupp", + "Move Preset to New Group": "Flytta förinställning till ny grupp", + "Move Selected Photos Here": "Flytta markerade foton hit", + "Move Sidebar Section": "Flytta sidofältsavsnitt", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Flytta filerna (till bibliotekets Originals/ eller en mapp du väljer) och ta bort dem från källan", + "Move them": "Flytta dem", + "Move to": "Flytta till", + "Move to Folder": "Flytta till mapp", + "Move to Folder…": "Flytta till mapp…", + "Move to Group": "Flytta till grupp", + "Move to group": "Flytta till grupp", + "Move to Group…": "Flytta till grupp…", + "Move Up": "Flytta upp", + "Move Up in Stack": "Flytta upp i stapeln", + "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.": "Flytta: filerna flyttas till mappen nedan; varje original (och dess XMP-sidofil) tas bort från källan först när kopian har verifierats. Dubbletter och filer som misslyckas ligger kvar där de är.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Flyttar alla foton som importerats från den här disken till Nyligen raderade; inga filer påverkas", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Flyttar filerna och deras XMP-sidofiler till den här mappen; skriver aldrig över", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Flyttar fotona som importerats från den här mappen till Nyligen raderade; inga filer påverkas", + "Muted": "Dämpad", + "Muted Film": "Dämpad film", + "My Photos": "Mina foton", + "My Photos Panel": "Panelen Mina foton", + "Name": "Namn", + "Name (A–Z)": "Namn (A–Ö)", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "namn (ersätter aldrig en fil), och först då länkas fotot om eller tas råkopian bort – en misslyckad skrivning", + "Name Face": "Namnge ansikte", + "Name Faces": "Namnge ansikten", + "Name of the folder the original is in": "Namnet på mappen som originalet ligger i", + "Name the preset": "Ge förinställningen ett namn", + "Named": "Namngiven", + "Named People": "Namngivna personer", + "names an album that no longer exists": "anger ett album som inte längre finns", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Namnen visas i etikettmenyn, filterfältet och infopanelen och skrivs till XMP. Tomt = färgens namn.", + "Navigator": "Navigator", + "needs a date like 2026, 2026-08 or 2026-08-14": "behöver ett datum som 2026, 2026-08 eller 2026-08-14", + "needs a number": "behöver ett tal", + "needs a number more than 0": "behöver ett tal större än 0", + "needs a time like 1/250 or 2": "behöver en tid som 1/250 eller 2", + "needs an album": "behöver ett album", + "needs hours, days, weeks, months or years": "behöver timmar, dagar, veckor, månader eller år", + "needs something to look for (or use “are empty”)": "behöver något att söka efter (eller använd “are empty”)", + "needs something to look for (or use “aren't empty”)": "behöver något att söka efter (eller använd “aren't empty”)", + "needs something to look for (or use “is empty”)": "behöver något att söka efter (eller använd “is empty”)", + "needs something to look for (or use “isn't empty”)": "behöver något att söka efter (eller använd “isn't empty”)", + "needs text": "behöver text", + "needs two values": "behöver två värden", + "Needs Work": "Behöver bearbetas", + "Neon Dusk": "Neonskymning", + "Neon Night": "Neonnatt", + "Nested group": "Kapslad grupp", + "Neutral": "Neutral", + "Never": "Aldrig", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "lämnar aldrig kvar en avkortad fil; XMP-sidofilen för en ”Original”-export följer också valet ”Om filen finns”.", + "New": "Nytt", + "New Album": "Nytt album", + "New album": "Nytt album", + "New Album…": "Nytt album…", + "New album…": "Nytt album…", + "New blank PDF": "Ny tom PDF", + "New Folder on Disk": "Ny mapp på disken", + "New Folder on Disk…": "Ny mapp på disken…", + "New Folder…": "Ny mapp…", + "New group — Return": "Ny grupp — Return", + "New Group…": "Ny grupp…", + "new keyword": "nytt nyckelord", + "New Library": "Nytt bibliotek", + "New Library: Choose Where to Put It": "Nytt bibliotek: välj var det ska ligga", + "New Library…": "Nytt bibliotek…", + "New name": "Nytt namn", + "New Smart Album": "Nytt smart album", + "New Smart Album from Filter…": "Nytt smart album från filter…", + "New Smart Album…": "Nytt smart album…", + "New spot (Return adds it here)": "Ny plats (Return lägger till den här)", + "New template name": "Namn på ny mall", + "New time": "Ny tid", + "New…": "Ny…", + "Next Appearance Mode": "Nästa utseendeläge", + "Next page": "Nästa sida", + "Next Photo": "Nästa foto", + "Next up for AI Denoise…": "Näst i tur för AI-brusreducering…", + "Night Blue": "Nattblå", + "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★) använder komprimeringen intoPIX TicoRAW. LightCraft har för närvarande ingen licens för den här codecen, så bara kamerans inbäddade JPEG-förhandsgranskning är tillgänglig, inte sensorns rådata. Om du vill redigera rådata fotograferar du med förlustfritt komprimerad NEF eller konverterar befintliga HE-filer till DNG med en licensierad konverterare.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF-filer utgår från en färg- och tonlook anpassad efter kamerans egen JPEG, precis som Sony ARW-filer, i stället för en dämpad,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon delar upp i två olika komprimerade halvor använder tills vidare fortfarande förhandsgranskningen.)", + "No": "Nej", + "No albums match": "Inga album matchar", + "No albums yet": "Inga album än", + "No automatic versions.": "Inga automatiska versioner.", + "No camera": "Ingen kamera", + "No Camera or Card Found": "Ingen kamera eller minneskort hittades", + "No change": "Ingen ändring", + "No contributor data was built into this copy.": "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian.", + "No date": "Inget datum", + "No Date Groups": "Inga datumgrupper", + "No dust spots found": "Inga dammfläckar hittades", + "No edits yet.": "Inga redigeringar än.", + "No effect matches": "Inga effekter matchar", + "No effect presets yet": "Inga effektförinställningar än", + "No exposure information": "Ingen exponeringsinformation", + "No filter presets yet": "Inga filterförinställningar än", + "No folders to browse": "Inga mappar att bläddra i", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Ingen grafikprocessor hittades: LightCraft renderar på CPU:n, vilket är mycket långsammare. Mer information i Hjälp → Systeminfo.", + "No imported folders yet": "Inga importerade mappar än", + "No keywords are exported": "Inga nyckelord exporteras", + "No keywords match": "Inga nyckelord matchar", + "No keywords yet": "Inga nyckelord än", + "No Label": "Ingen etikett", + "No lens data embedded in this file (DNG lens opcodes only).": "Inga linsdata finns inbäddade i den här filen (endast DNG-linsopkoder).", + "No lens data embedded in this file.": "Inga linsdata finns inbäddade i den här filen.", + "No masks yet. Choose a mask type above.": "Inga masker än. Välj en masktyp ovan.", + "No matching commands": "Inga matchande kommandon", + "No matching photos": "Inga matchande foton", + "No Matte": "Ingen matte", + "No metadata found": "Inga metadata hittades", + "No model credits were built into this copy.": "Inga modellkrediteringar finns inbyggda i den här kopian.", + "No named people in these photos": "Inga namngivna personer i de här fotona", + "No named people yet": "Inga namngivna personer än", + "No new presets": "Inga nya förinställningar", + "No photo has it; the keywords below it go too.": "Inget foto har det; nyckelorden under det tas också bort.", + "No photo selected": "Inget foto markerat", + "No photos": "Inga foton", + "No photos found": "Inga foton hittades", + "No photos in this folder": "Inga foton i den här mappen", + "No photos match the active filters": "Inga foton matchar de aktiva filtren", + "No photos match the filters (View → Clear Filters)": "Inga foton matchar filtren (Visa → Rensa filter)", + "No photos yet": "Inga foton än", + "No raw photos yet: cameras appear here once their photos are in the library.": "Inga råfoton än: kameror visas här när deras foton finns i biblioteket.", + "No Recent Libraries": "Inga senaste bibliotek", + "No reference photo": "Inget referensfoto", + "No Stars": "Inga stjärnor", + "No Tool": "Inget verktyg", + "No usable GPU found: rendering on the CPU": "Ingen användbar GPU hittades: renderar på CPU:n", + "No versions yet. Create one to keep this look.": "Inga versioner än. Skapa en för att behålla den här looken.", + "Noise": "Brus", + "Noise Reduction": "Brusreducering", + "non-commercial use only": "endast för icke-kommersiell användning", + "Non-raw photos": "Foton som inte är raw", + "None": "Ingen", + "none": "ingen", + "none (CPU rendering)": "ingen (CPU-rendering)", + "None (the display is treated as sRGB)": "Ingen (skärmen behandlas som sRGB)", + "none of": "inget av", + "None yet: save the current settings as a template below.": "Inga än: spara de aktuella inställningarna som en mall nedan.", + "not added to Apple Photos (cancelled)": "inte tillagt i Apple Photos (avbrutet)", + "not available here": "inte tillgängligt här", + "Not Now": "Inte nu", + "Not saved": "Inte sparat", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Ingenting hämtas förrän du godkänner. Modellen sparas bara på den här datorn; LightCraft laddar aldrig upp eller delar den.", + "Nothing to compare": "Inget att jämföra", + "Nothing to survey": "Inget att granska", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "framkallas nu från rådata i stället för kamerans inbäddade JPEG, så en svartvit eller annan bildstil som ställts in i", + "Nudge Develop Value": "Knuffa framkallningsvärde", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Numrerade JPEG-filer (namn-001.jpg…) i en ny mapp som du namnger i nästa steg.", + "Object": "Objekt", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Masker av typen Objekt och Beskriv använder SAM 3, Metas segmenteringsmodell. Den ingår inte i LightCraft, och allt annat fungerar utan den.", + "Object Mask": "Objektmask", + "October 2026": "oktober 2026", + "of the following rules:": "av följande regler:", + "Off": "Av", + "Offset": "Förskjutning", + "Offset X": "Förskjutning X", + "Offset Y": "Förskjutning Y", + "OK": "OK", + "On NixOS, start it from `nix develop`.": "På NixOS startar du det från `nix develop`.", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "på kortet efter Granska import rapporteras i stället för att importeras med inaktuella uppgifter. Kontroll av en exportsökväg", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "en i taget – de kan alltid exporteras igen, och på en USB-enhet eller en NAS dominerade den synkroniseringen per fil en stor", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "En sida per diabild (långsidan i pixlar = punkter), med diabildens visningstid och toning för visarens presentationsläge.", + "Only": "Endast", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Skapas bara för foton med AI-brusreducering över 0. Vid export skapas den alltid om den saknas.", + "ONNX model": "ONNX-modell", + "Opacity": "Opacitet", + "Opacity (%)": "Opacitet (%)", + "Open in": "Öppna i", + "Open in Detail": "Öppna i Detalj", + "Open Library": "Öppna bibliotek", + "Open Library…": "Öppna bibliotek…", + "Open Log Folder": "Öppna loggmappen", + "Open page": "Öppna sida", + "Open Recent Library": "Öppna senaste bibliotek", + "Open Settings": "Öppna Inställningar", + "Open the model's page": "Öppna modellens sida", + "Open the place in OpenStreetMap": "Öppna platsen i OpenStreetMap", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Öppnar modellens egen sida i webbläsaren, så att du kan läsa om den eller hämta filen själv.", + "Open…": "Öppna…", + "Optics": "Optik", + "Optimize Library": "Optimera bibliotek", + "Optional name": "Valfritt namn", + "Options": "Alternativ", + "or": "eller", + "or drop a .onnx file on the window": "eller släpp en .onnx-fil i fönstret", + "Orange": "Orange", + "Orange Hue": "Orange nyans", + "Orange Luminance": "Orange luminans", + "Orange Saturation": "Orange mättnad", + "Orientation": "Orientering", + "Original": "Original", + "Original + Settings": "Original + inställningar", + "Original extension without the dot": "Ursprungligt filtillägg utan punkt", + "Original file name without its extension": "Ursprungligt filnamn utan filtillägg", + "Original files": "Originalfiler", + "Original offline · editing the smart preview": "Originalet offline · redigerar den smarta förhandsgranskningen", + "Original offline · no smart preview": "Originalet offline · ingen smart förhandsgranskning", + "Originals": "Original", + "Other editor": "Annat redigeringsprogram", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Andra bilder (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Annat…", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "framför en förväxlingsbar bild med samma namn och storlek, och hoppar över (och rapporterar) foton den inte kan skilja åt i stället för att gissa.", + "Overlay": "Överlägg", + "Overlay color": "Överläggsfärg", + "Overlays": "Överlägg", + "Override colour": "Åsidosätt färg", + "Overwrite": "Skriv över", + "Own duration (off: the slide duration)": "Egen visningstid (av: diabildens visningstid)", + "Pages": "Sidor", + "Paint": "Måla", + "Paint only areas like the one under the brush": "Måla bara områden som liknar det under penseln", + "Paint over a distraction on the photo to remove it.": "Måla över ett störande element i fotot för att ta bort det.", + "Paint overlay": "Målningsöverlägg", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Raw-filer från Panasonic och Leica (RW2, RWL och de äldre RAW-filerna, DMC-LX1 till DC-S1R II) framkallas från rådata i", + "Panels": "Paneler", + "Panorama": "Panorama", + "Panorama Merge": "Panoramasammanfogning", + "Panorama Merge Preview": "Förhandsgranskning av panoramasammanfogning", + "Panorama with Last Settings": "Panorama med senaste inställningar", + "Panoramas": "Panoramor", + "Panorama…": "Panorama…", + "Paper": "Papper", + "Parametric curve": "Parametrisk kurva", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "delen bort), eller skriv vad som ska markeras (\"sky\", \"the red car\", \"car, road\"); båda kan kombineras med andra masker, har en", + "Paste": "Klistra in", + "Paste Edit Settings": "Klistra in redigeringsinställningar", + "Paste from Previous": "Klistra in från föregående", + "Paste Metadata": "Klistra in metadata", + "Paste pages": "Klistra in sidor", + "Paste Selected Settings": "Klistra in markerade inställningar", + "Paste Selected Settings…": "Klistra in markerade inställningar…", + "Paste Settings": "Klistra in inställningar", + "Paste Settings from Previous": "Klistra in inställningar från föregående", + "Pastel Haze": "Pastelldis", + "Pen Pressure": "Penntryck", + "Pending photo action cancelled": "Väntande fotoåtgärd avbröts", + "Pending photo action cancelled because its library or photo changed": "Väntande fotoåtgärd avbröts eftersom dess bibliotek eller foto ändrades", + "People": "Människor", + "People Count": "Antal personer", + "Percent": "Procent", + "Percentage": "Procent", + "Perceptual": "Perceptuell", + "Performance": "Prestanda", + "Person": "Person", + "Perspective": "Perspektiv", + "Pet Eye": "Husdjursöga", + "Pet Eye Catchlight": "Ljusreflex i husdjursöga", + "Photo": "Foto", + "Photo Grid": "Fotorutnät", + "Photo Grid (G)": "Fotorutnät (G)", + "photo has its denoised picture.": "foto har sin brusreducerade bild.", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "Foto-menyn. Filmremsan har också fotots snabbmeny. Att lägga till en fil som finns i Nyligen raderade igen gör inte", + "Photo Merge": "Fotosammanfogning", + "photo waiting": "foto väntar", + "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 → Redigera i externt program (⇧⌘E) renderar en 16-bitars TIFF-kopia, staplar den med originalet och öppnar den här (ett appnamn på macOS, en programsökväg på andra system).", + "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 → Redigera i → Redigera i PhotoCraft (⇧⌘E, eller högerklicka på ett foto) renderar en kopia med dina ändringar, staplar den med originalet och öppnar den i PhotoCraft. Spara där så uppdateras kopian här. Som PSD sparar PhotoCraft direkt tillbaka till den. Lämna platsen tom om du inte använder en portabel kopia.", + "PhotoCraft": "PhotoCraft", + "Photos": "Foton", + "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.": "Foton visas genom bildskärmens ICC-profil, så att färgerna blir rätt på kalibrerade skärmar och skärmar med brett färgomfång, och vyn Detalj använder skärmens hela färgomfång. Histogrammet, förhandsgranskningscacharna och exporter påverkas inte.", + "photos have their denoised picture.": "foton har sin brusreducerade bild.", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Foton i Nyligen raderade kan återställas från appen: högerklicka → Återställ (eller Radera permanent), även i", + "Photos per bracket (0 = auto)": "Foton per gaffling (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.": "Foton som setts i Lokalt men aldrig lagts till eller ändrats tas bort ur katalogen när deras mapp inte har öppnats på så här länge (kontrolleras när biblioteket öppnas). Filerna ligger kvar på disken; bläddrar du i mappen visas de igen.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Fotona finns kvar i Alla foton. Om du raderar en mapp raderas även dess album. Du kan ångra detta.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Foton som hamnar i den här mappen (tethering, en skanner, en synkapp) läggs till så fort de är kompletta.", + "photos waiting": "foton väntar", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Foton); import (även med dra och släpp), Hitta saknade foton, Skapa/släng smarta förhandsgranskningar, förhandsgranskningen för Byt namn,", + "Pick": "Plocka", + "pick": "vald", + "Pick a colour on the photo with the eyedropper.": "Välj en färg i fotot med pipetten.", + "Pick a date": "Välj ett datum", + "Pick Flag": "Flagga som vald", + "Pick the sharpest photo of each burst": "Välj det skarpaste fotot i varje bildserie", + "Picked": "Vald", + "Picks": "Valda", + "Pictures": "Bilder", + "Pixels": "Pixlar", + "Place names download cancelled": "Hämtningen av ortnamn avbröts", + "Place names downloaded": "Ortnamn hämtade", + "Place Names Status": "Status för ortnamn", + "Plain Black": "Enkel svart", + "Playback": "Uppspelning", + "Point Color": "Punktfärg", + "Point Curve": "Punktkurva", + "Point curve": "Punktkurva", + "Point Curve Presets": "Förinställningar för punktkurva", + "Portrait": "Stående", + "Portrait (tall)": "Stående (hög)", + "Position": "Position", + "Preferences": "Inställningar", + "Preferences…": "Inställningar…", + "Prepare AI Masks": "Förbered AI-masker", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "behåller RAW-redigering och export i full upplösning i stället för att använda inbäddade JPEG-förhandsgranskningar.", + "Preset": "Förinställning", + "Preset name": "Namn på förinställning", + "Presets": "Förinställningar", + "Presets & Profiles": "Förinställningar och profiler", + "Presets (Shift+P)": "Förinställningar (Shift+P)", + "Presets and profiles": "Förinställningar och profiler", + "Presets ▾": "Förinställningar ⌄", + "Press keys…": "Tryck på tangenter…", + "Preview": "Förhandsgranskning", + "Preview Build Progress": "Förlopp för att skapa förhandsgranskningar", + "Preview only": "Endast förhandsgranskning", + "Preview only: editing the camera's embedded JPEG": "Endast förhandsgranskning: du redigerar kamerans inbäddade JPEG", + "Preview size": "Förhandsgranskningsstorlek", + "Preview unavailable": "Förhandsgranskning inte tillgänglig", + "Previews": "Förhandsgranskningar", + "previews": "förhandsgranskningar", + "Previous page": "Föregående sida", + "Previous Photo": "Föregående foto", + "Previous settings": "Föregående inställningar", + "Primaries (x, y)": "Primärfärger (x, y)", + "Print": "Skriv ut", + "Print…": "Skriv ut…", + "Processor": "Processor", + "Profile": "Profil", + "Profile Browser": "Profilbläddrare", + "Profile Distortion": "Profildistorsion", + "Profile Menu": "Profilmeny", + "Profile Vignetting": "Profilvinjettering", + "Profile:": "Profil:", + "Profiles": "Profiler", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Profiler som tilldelats i skrivbordets färginställningar finns oftast i ~/.local/share/icc.", + "Progressive": "Progressiv", + "Projection": "Projektion", + "Proof copy created": "Korrekturkopia skapad", + "Properties": "Egenskaper", + "Public Domain": "Allmän egendom", + "Public domain": "Allmän egendom", + "publicDomain": "publicDomain", + "Pupil Size": "Pupillstorlek", + "Purge Unused Keywords": "Rensa oanvända nyckelord", + "Purple": "Lila", + "purple": "lila", + "Purple Amount": "Lila mängd", + "Purple Hue": "Lila nyans", + "Purple Hue High": "Lila nyans hög", + "Purple Hue Low": "Lila nyans låg", + "Purple Luminance": "Lila luminans", + "Purple Saturation": "Lila mättnad", + "Put New Keywords Inside": "Lägg nya nyckelord inuti", + "Put New Keywords Inside This Keyword": "Lägg nya nyckelord inuti detta nyckelord", + "Put the locations you removed from Local back (no files change)": "Återställ platserna du tog bort från Lokalt (inga filer ändras)", + "Quality": "Kvalitet", + "Query Photos": "Sök bland foton", + "Quick Collection": "Snabbsamling", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Snabbsamling och målalbum (B i rutnätet), nyckelordsuppsättningar (⌥1–⌥9), färgetikettuppsättningar.", + "Quick Develop": "Snabbframkallning", + "Quiet Green": "Dämpad grön", + "Quit": "Avsluta", + "Quit Anyway": "Avsluta ändå", + "Quit LightCraft": "Avsluta LightCraft", + "Quit while tasks are running?": "Avsluta medan uppgifter körs?", + "Quit with unsaved changes?": "Avsluta med osparade ändringar?", + "Radial": "Radiell", + "Radial Gradient": "Radiell gradient", + "Radius": "Radie", + "Random": "Slumpmässig", + "Range": "Omfång", + "Rate ★": "Betygsätt ★", + "Rate ★ and advance": "Betygsätt ★ och gå vidare", + "Rate ★★": "Betygsätt ★★", + "Rate ★★ and advance": "Betygsätt ★★ och gå vidare", + "Rate ★★★": "Betygsätt ★★★", + "Rate ★★★ and advance": "Betygsätt ★★★ och gå vidare", + "Rate ★★★★": "Betygsätt ★★★★", + "Rate ★★★★ and advance": "Betygsätt ★★★★ och gå vidare", + "Rate ★★★★★": "Betygsätt ★★★★★", + "Rate ★★★★★ and advance": "Betygsätt ★★★★★ och gå vidare", + "Rating": "Betyg", + "Rating cleared": "Betyget rensat", + "Rating stars": "Betygsstjärnor", + "Ratings & flags": "Betyg och flaggor", + "Raw": "Raw", + "raw": "raw", + "RAW decoding": "RAW-avkodning", + "Raw default": "Raw-standard", + "Raw defaults": "Raw-standardvärden", + "Raw files": "Raw-filer", + "Raw photos": "Raw-foton", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Raw-foton som DNG, med redigeringarna inbäddade. Alternativen för storlek, färg och utdata gäller inte.", + "read in the wrong units.": "läses in i fel enheter.", + "Read Metadata from File": "Läs metadata från fil", + "Read metadata updates from XMP sidecars": "Läs metadatauppdateringar från XMP-sidofiler", + "Read mode": "Läsläge", + "Read the SAM License": "Läs SAM-licensen", + "Reading": "Läsning", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "När en sidofil läses ersätts fotots betyg, etiketter, metadata och redigeringar med det som står i den.", + "Reading folder": "Läser mapp", + "Reading folder…": "Läser mapp…", + "Reading Lightroom catalog": "Läser Lightroom-katalog", + "Reading Lightroom catalog…": "Läser Lightroom-katalog…", + "Reading photos…": "Läser foton…", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "läser kortet en tredje gång (Flytta jämför fortfarande byte för byte innan en källa raderas); en fil som har ändrats", + "Real name": "Riktigt namn", + "Recent": "Senaste", + "Recent Keywords": "Senaste nyckelord", + "Recent Keywords is built in: choose another name": "Senaste nyckelord är inbyggt: välj ett annat namn", + "Recent Libraries": "Senaste bibliotek", + "Recently Added": "Nyligen tillagda", + "Recently Deleted": "Nyligen raderade", + "Recognise faces (experimental)": "Känn igen ansikten (experimentellt)", + "Recognition models": "Igenkänningsmodeller", + "Red": "Röd", + "red": "röd", + "Red channel": "Röd kanal", + "Red Eye": "Röda ögon", + "Red Hue": "Röd nyans", + "Red label": "Röd etikett", + "Red Luminance": "Röd luminans", + "Red Primary": "Röd primärfärg", + "Red Saturation": "Röd mättnad", + "Red/Cyan Fringe": "Röd/cyan fransning", + "Redo": "Gör om", + "Reference": "Referens", + "Reference the files where they are": "Referera till filerna där de ligger", + "Reference View": "Referensvy", + "Refine": "Förfina", + "Refine AI Mask Detail": "Förfina detaljer i AI-mask", + "Refine Edges": "Förfina kanter", + "Refine Mask Edges": "Förfina maskkanter", + "Refine Saturation": "Förfina mättnad", + "Refining the mask's detail…": "Förfinar maskens detaljer…", + "Refresh Source": "Uppdatera källa", + "Refresh Source (/)": "Uppdatera källa (/)", + "Refresh View": "Uppdatera vy", + "Reject": "Avvisa", + "reject": "avvisa", + "Reject below focus": "Avvisa under fokus", + "Rejected": "Avvisad", + "Rejects": "Avvisade", + "Relative": "Relativ", + "Reliability": "Tillförlitlighet", + "Relink Photo": "Länka om foto", + "Relink photos whose file was renamed or moved": "Länka om foton vars fil har bytt namn eller flyttats", + "Relinked": "Omlänkad", + "Reload Changed Files": "Läs in ändrade filer igen", + "Reload from Disk": "Läs in igen från disk", + "Remove": "Ta bort", + "Remove (H)": "Ta bort (H)", + "Remove a filter above, or choose Clear all": "Ta bort ett filter ovan eller välj Rensa alla", + "Remove CA": "Ta bort CA", + "Remove Chromatic Aberration": "Ta bort kromatisk aberration", + "Remove Denoise Model": "Ta bort brusreduceringsmodell", + "Remove Disk from Library": "Ta bort disk från biblioteket", + "Remove Disk from Library…": "Ta bort disk från biblioteket…", + "Remove Face Box": "Ta bort ansiktsruta", + "Remove Face Model": "Ta bort ansiktsmodell", + "Remove Folder Color Label": "Ta bort mappens färgetikett", + "Remove Folder from Library": "Ta bort mapp från biblioteket", + "Remove from Album": "Ta bort från album", + "Remove from Favorites": "Ta bort från favoriter", + "Remove from favorites": "Ta bort från favoriter", + "Remove from Library…": "Ta bort från biblioteket…", + "Remove from Local": "Ta bort från Lokalt", + "Remove from Photo": "Ta bort från foto", + "Remove from Selected Photos": "Ta bort från markerade foton", + "Remove from Stack": "Ta bort från stapel", + "Remove from survey": "Ta bort från översikten", + "Remove from Target Album": "Ta bort från målalbum", + "Remove location info": "Ta bort platsinformation", + "Remove missing photos from the library": "Ta bort saknade foton från biblioteket", + "Remove region": "Ta bort region", + "Remove shortcut": "Ta bort kortkommando", + "Remove this face box (undo with Edit ▸ Undo)": "Ta bort den här ansiktsrutan (ångra med Redigera → Ångra)", + "Remove this filter": "Ta bort det här filtret", + "Remove this group": "Ta bort den här gruppen", + "Remove this rule": "Ta bort den här regeln", + "Removed": "Borttaget", + "Rename": "Byt namn", + "Rename Album": "Byt namn på album", + "Rename Component": "Byt namn på komponent", + "Rename Folder": "Byt namn på mapp", + "Rename Folder…": "Byt namn på mapp…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Byt namn på mapp… och Flytta mapp till… (Lokalt) kan ångras och göras om (ärende #97): mappen flyttas tillbaka på disken med", + "Rename Keyword": "Byt namn på nyckelord", + "Rename Keyword…": "Byt namn på nyckelord…", + "Rename Mask": "Byt namn på mask", + "Rename Person…": "Byt namn på person…", + "Rename Photos": "Byt namn på foton", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Byt namn på foton skriver aldrig över ett annat foto när bara skiftläget ändras (ärende #95): på skiftlägeskänsliga", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Byt namn på foton rapporterar filer som inte kunde flyttas tillbaka efter ett fel (ärende #105), t.ex. när en nätverksresurs kopplas från", + "Rename Photos…": "Byt namn på foton…", + "Rename Preset": "Byt namn på förinställning", + "Rename Preview": "Byt namn på förhandsgranskning", + "Rename Template Tags": "Byt namn på malltaggar", + "Rename Version": "Byt namn på version", + "Rename…": "Byt namn…", + "Render behind image": "Rendera bakom bilden", + "Rendering": "Rendering", + "Rendering…": "Renderar…", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "renderingar och skärmbilder skrivs fortfarande till en temporär fil som sedan byter namn på plats, men tvingas inte längre ut till disken", + "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).": "Renderar nästa bilder medan den aktuella visas, så att toningar aldrig behöver vänta. Av: varje bild renderas när den ska visas (mindre minne och arbete).", + "Reorder Album": "Ändra ordning i album", + "Reorder Masks": "Ändra ordning på masker", + "Reset": "Återställ", + "Reset All": "Återställ alla", + "Reset All Curves": "Återställ alla kurvor", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Återställ alla kurvor: punktkurvor (alla kanaler) och parametrisk (dubbelklicka på en kanal för att bara återställa den)", + "Reset all edits": "Återställ alla redigeringar", + "Reset all edits (Cmd+Shift+R)": "Återställ alla redigeringar (Cmd+Shift+R)", + "Reset All Keyboard Shortcuts": "Återställ alla kortkommandon", + "Reset Before to Import State": "Återställ Före till importtillståndet", + "Reset Control": "Återställ reglage", + "Reset Crop": "Återställ beskärning", + "Reset Edits": "Återställ redigeringar", + "Reset layout": "Återställ layout", + "Reset Panel Layout": "Återställ panellayout", + "Reset Parametric Curve": "Återställ parametrisk kurva", + "Reset Section": "Återställ avsnitt", + "Reset Sidebar Sections": "Återställ sidofältets avsnitt", + "Reset Sliders": "Återställ reglage", + "Reset Tone Curve": "Återställ tonkurva", + "Reshuffle": "Blanda om", + "Resize Face Box": "Ändra storlek på ansiktsruta", + "Resize panel window": "Ändra storlek på panelfönster", + "Resize panels": "Ändra storlek på paneler", + "Resize region": "Ändra storlek på region", + "Resize to": "Ändra storlek till", + "Resolution (ppi)": "Upplösning (ppi)", + "Restore": "Återställ", + "Restore (no shortcut)": "Återställ (inget kortkommando)", + "Restore keeps their edits; Import as new deletes the old record first.": "Återställ behåller deras redigeringar; Importera som nytt raderar den gamla posten först.", + "Restore Library from Backup…": "Återställ biblioteket från säkerhetskopia…", + "Restore them": "Återställ dem", + "Restore Version": "Återställ version", + "Retouch": "Retuschera", + "Return creates this keyword and gives it to the selected photos": "Retur skapar nyckelordet och ger det till de markerade fotona", + "Reverse the order": "Vänd på ordningen", + "Revert": "Återgå", + "Review": "Granskning", + "Review Import": "Granska import", + "Right": "Höger", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Högerklicka på ett foto → Ange som referensfoto, sedan Visa → Referensvy (Shift+R)", + "Right-click a preset to delete it": "Högerklicka på en förinställning för att radera den", + "Rights Usage Terms": "Villkor för användningsrätt", + "Rotate": "Rotera", + "Rotate Crop": "Rotera beskärning", + "Rotate Crop Aspect": "Rotera beskärningens bildförhållande", + "Rotate Left": "Rotera åt vänster", + "Rotate Right": "Rotera åt höger", + "Roughness": "Grovhet", + "Roundness": "Rundhet", + "Rules": "Regler", + "Sa": "lö", + "SAM 3 download stopped: it resumes where it left off next time.": "Hämtningen av SAM 3 stoppades: den fortsätter där den slutade nästa gång.", + "Same as raw default": "Samma som raw-standard", + "Same folder as original photo": "Samma mapp som originalfotot", + "Sample a colour on the photo": "Plocka en färg från fotot", + "Sample Mask Color": "Plocka maskfärg", + "Saturation": "Mättnad", + "Saturation Range": "Mättnadsomfång", + "Saturday": "Lördag", + "Save": "Spara", + "Save as a new set": "Spara som en ny uppsättning", + "Save as Preset": "Spara som förinställning", + "Save as set": "Spara som uppsättning", + "Save as Set…": "Spara som uppsättning…", + "Save as Smart Album…": "Spara som smart album…", + "Save as Template": "Spara som mall", + "Save As…": "Spara som…", + "Save as…": "Spara som…", + "Save Color Label Set": "Spara färgetikettuppsättning", + "Save Current Filter as Preset…": "Spara aktuellt filter som förinställning…", + "Save Current Keywords as Set…": "Spara aktuella nyckelord som uppsättning…", + "Save Current Settings as New Preset…": "Spara aktuella inställningar som ny förinställning…", + "Save Export Preset": "Spara exportförinställning", + "Save Filter Preset": "Spara filterförinställning", + "save goes through.": "sparningen går igenom.", + "Save Keyword Set": "Spara nyckelordsuppsättning", + "Save Metadata Preset": "Spara metadataförinställning", + "Save Metadata Preset…": "Spara metadataförinställning…", + "Save Metadata to File": "Spara metadata till fil", + "Save Point Curve Preset": "Spara förinställning för punktkurva", + "Save Point Curve…": "Spara punktkurva…", + "Save preset": "Spara förinställning", + "saved in": "sparas i", + "Saving Lightroom import index…": "Sparar Lightroom-importregister…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Att spara metadata till en XMP-sidofil som ett annat program har skrivit ersätter den inte längre (ärende #92): LightCraft sammanfogar sina", + "Scale": "Skala", + "Scan failed": "Skanningen misslyckades", + "Scan Folder for Changes": "Sök efter ändringar i mappen", + "Scanning": "Skannar", + "Scanning folder": "Skannar mapp", + "Scanning…": "Skannar…", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "scen) framkallas nu från sensordata med alla redigeringsverktyg, precis som kamerans andra raw-filer. Foton", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Poängen sparas på fotona: filtrera eller skapa smarta album med Fokus och Bästa av likartade bilder.", + "Screen": "Skärm", + "Scroll menu down": "Rulla menyn nedåt", + "Scroll menu up": "Rulla menyn uppåt", + "SDR": "SDR", + "SDR Brightness": "SDR-ljusstyrka", + "SDR Clarity": "SDR-klarhet", + "SDR Contrast": "SDR-kontrast", + "SDR Highlights": "SDR-högdagrar", + "SDR Shadows": "SDR-skuggor", + "SDR Whites": "SDR-vita", + "Search": "Sök", + "Search albums": "Sök album", + "Search commands or keys": "Sök kommandon eller tangenter", + "Search effects": "Sök effekter", + "Search Photos": "Sök foton", + "Search…": "Sök…", + "Seasons": "Årstider", + "second": "sekund", + "Second Window": "Andra fönster", + "Select": "Markera", + "Select a mask to apply or update presets.": "Markera en mask för att använda eller uppdatera förinställningar.", + "Select a photo to edit": "Markera ett foto att redigera", + "Select a photo to preview its slide": "Markera ett foto för att förhandsgranska dess bild", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Markera ett foto och aktivera sedan detta för att fylla bilden bakom varje foto med det.", + "Select All": "Markera alla", + "Select all": "Markera alla", + "Select by": "Markera efter", + "Select by Flag, Rating or Label": "Markera efter flagga, betyg eller etikett", + "Select Luminance Range": "Markera luminansomfång", + "Select Mask": "Markera mask", + "Select none": "Markera inga", + "Select Photos": "Markera foton", + "Select photos to give them keywords.": "Markera foton för att ge dem nyckelord.", + "Select photos, then choose View → Survey (N)": "Markera foton och välj sedan Visa → Översikt (N)", + "Select Spot": "Markera fläck", + "Select two photos, then choose View → Compare (Shift+C)": "Markera två foton och välj sedan Visa → Jämför (Shift+C)", + "Selected": "Markerade", + "Selecting…": "Markerar…", + "Selenium Tone": "Selentoning", + "Send Feedback…": "Skicka återkoppling…", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separera flera nyckelord med kommatecken. Retur tilldelar dem till de markerade fotona.", + "Separated by commas": "Avgränsade med kommatecken", + "Sepia Tone": "Sepiaton", + "Sequence number zero-padded to N digits (1–9)": "Löpnummer utfyllt med nollor till N siffror (1–9)", + "Sequence number, counting from the start number": "Löpnummer, räknat från startnumret", + "Services": "Tjänster", + "Set": "Ange", + "Set Album Cover": "Ange albumomslag", + "Set as Album Cover": "Ange som albumomslag", + "Set as Before": "Ange som före", + "Set as Reference Photo": "Ange som referensfoto", + "Set as Target Album": "Ange som målalbum", + "Set as Target Album (B adds to it)": "Ange som målalbum (B lägger till i det)", + "Set as Top of Stack": "Ange som överst i stapeln", + "Set Before": "Ange före", + "Set Color Label": "Ange färgetikett", + "Set Crop": "Ange beskärning", + "Set date & time": "Ange datum och tid", + "Set Develop Values": "Ange framkallningsvärden", + "Set Flag": "Ange flagga", + "Set Folder Color Label": "Ange mappens färgetikett", + "Set Image Zoom and Pan": "Ange bildzoom och panorering", + "Set Keyboard Shortcut": "Ange kortkommando", + "Set Mask Adjustments": "Ange maskjusteringar", + "Set name": "Namn på uppsättning", + "Set Point Curve": "Ange punktkurva", + "Set Profile": "Ange profil", + "Set Rating": "Ange betyg", + "Set the mix from the photo's colours": "Ange mixen utifrån fotots färger", + "Set Top of Stack": "Ange överst i stapeln", + "Set up AI Denoise…": "Konfigurera AI-brusreducering…", + "Settings": "Inställningar", + "Settings file problem": "Problem med inställningsfil", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Inställningsfiler återställs aldrig i tysthet (ärende #103): en skadad `prefs.json`, `presets.json` eller `view.json` behålls", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Inställningar som nya raw-foton utgår från. Att ändra dem påverkar inte foton som redan finns i biblioteket.", + "Settings pasted": "Inställningar inklistrade", + "Settings to include": "Inställningar att ta med", + "Settings…": "Inställningar…", + "Shade": "Skugga", + "Shadow": "Skugga", + "Shadows": "Skuggor", + "Shadows Hue": "Skuggnyans", + "Shadows Luminance": "Skuggluminans", + "Shadows Saturation": "Skuggmättnad", + "Shadows split": "Skuggdelning", + "Shadows Tint": "Skuggfärgton", + "Sharpen": "Skärpa", + "Sharpening": "Skärpning", + "Sharpness": "Skärpa", + "Shift": "Shift", + "Shift by": "Förskjut med", + "Shooting Data": "Fotograferingsdata", + "Short Edge": "Kortsida", + "Show": "Visa", + "Show / hide": "Visa/dölj", + "Show All": "Visa alla", + "Show All Mask Overlays": "Visa alla masköverlägg", + "Show all overlays": "Visa alla överlägg", + "Show Clipping": "Visa klippning", + "Show commands without a shortcut": "Visa kommandon utan kortkommando", + "Show Deghost Overlay": "Visa överlägg för spökborttagning", + "Show destination gamut warning": "Visa färgomfångsvarning för målet", + "Show display gamut warning": "Visa färgomfångsvarning för skärmen", + "Show every keyword of the selected photos": "Visa alla nyckelord för de markerade fotona", + "Show fewer": "Visa färre", + "Show file names": "Visa filnamn", + "Show guides": "Visa stödlinjer", + "Show Hidden Local Locations": "Visa dolda lokala platser", + "Show in Compare": "Visa i Jämför", + "Show in Explorer": "Visa i Utforskaren", + "Show in File Manager": "Visa i filhanteraren", + "Show in Finder": "Visa i Finder", + "Show in Library": "Visa i Bibliotek", + "Show less": "Visa färre", + "Show Luminance Map": "Visa luminanskarta", + "Show Mask": "Visa mask", + "Show mask": "Visa mask", + "Show Mask Overlay": "Visa masköverlägg", + "Show Mask Pins": "Visa masknålar", + "Show on Map": "Visa på kartan", + "Show Original": "Visa original", + "Show overlay (O)": "Visa överlägg (O)", + "Show Person": "Visa person", + "Show Photo Counts": "Visa antal foton", + "Show photos": "Visa foton", + "Show Photos with Keyword": "Visa foton med nyckelordet", + "Show Pins": "Visa nålar", + "Show ratings, flags and edit badges": "Visa betyg, flaggor och redigeringsmärken", + "Show Sidebar Section": "Visa sidofältsavsnitt", + "Show Source": "Visa källa", + "Show the import review when a camera or card is connected": "Visa importgranskningen när en kamera eller ett kort ansluts", + "Show the Navigator while zoomed in": "Visa navigatorn vid inzoomning", + "Show the template tags; click one to insert it": "Visa mallens taggar; klicka på en för att infoga den", + "Show Thumbnails": "Visa miniatyrer", + "Show/Hide Mask": "Visa/dölj mask", + "Show/hide My Photos panel": "Visa/dölj panelen Mina foton", + "Shows": "Visar", + "Shutter Speed": "Slutartid", + "Shutter speed": "Slutartid", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "syskon) öppnas inte längre klargröna (ärende #148): deras vitbalans och svartnivå vid fotograferingen läses från", + "Side panels": "Sidopaneler", + "Sidebar Sections": "Sidofältsavsnitt", + "Sidecar names": "Namn på sidofiler", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "sidofil som inte är giltig XMP kopieras först till `<name>.xmp.bak-<time>`. Med standardnamngivningen efter filstam får en raw-fil och en", + "Silver": "Silver", + "Simulate Paper & Ink": "Simulera papper och bläck", + "Size": "Storlek", + "size": "storlek", + "Size (% of short edge)": "Storlek (% av kortsidan)", + "Size limit": "Storleksgräns", + "Skip": "Hoppa över", + "Skipped: files already in the library elsewhere": "Överhoppade: filer som redan finns på annan plats i biblioteket", + "Skipped: files that can't be read": "Överhoppade: filer som inte kan läsas", + "Sky": "Himmel", + "Slide": "Bild", + "Slide duration": "Visningstid per bild", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Bilderna använder bildspelets layout, överlägg samt intro- och slutskärmar, i visningsordning.", + "Slideshow": "Bildspel", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "Bildspel i menyn Visa spelar upp fotona i vyn med början från det markerade. Inställningarna sparas med biblioteket.", + "Slideshow paused": "Bildspelet pausat", + "Slideshow resumed": "Bildspelet återupptogs", + "Slideshow Templates": "Bildspelsmallar", + "Slideshow · Space pauses · Esc ends": "Bildspel · Blanksteg pausar · Esc avslutar", + "Slideshow, second window, All Metadata, System Info.": "Bildspel, ett andra fönster, All metadata, Systeminfo.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Mindre, snabbare kataloger: foton som du bara har tittat på i Lokalt (aldrig lagt till, betygsatt eller redigerat) glöms bort när", + "Smart Album": "Smart album", + "Smart Album Rule Fields": "Regelfält för smarta album", + "smart album value|No": "Nej", + "smart album value|Yes": "Ja", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Smarta album uppdateras automatiskt: ändra deras regler eller använd Foto → Radera foto", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Smarta album med regelredigerare: matcha alla/någon/ingen, nästlade grupper, 26 fält.", + "Smart preview available": "Smart förhandsgranskning tillgänglig", + "Smart Preview Status": "Status för smart förhandsgranskning", + "Smart previews": "Smarta förhandsgranskningar", + "smart previews": "smarta förhandsgranskningar", + "Smart previews folder changed": "Mappen för smarta förhandsgranskningar har ändrats", + "Smart Previews Location": "Plats för smarta förhandsgranskningar", + "Smoothness": "Mjukhet", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "så att ingen visas som saknad. Om du byter namn på ett foto i ett raw- + JPEG-par kopieras deras gemensamma `IMG_0001.xmp` i stället för att ta", + "Soft B&W": "Mjuk svartvit", + "Soft Pastel": "Mjuk pastell", + "Soft Proofing": "Skärmkorrektur", + "Soft Skin": "Mjuk hy", + "Solo Mode": "Sololäge", + "Some rules need fixing (Edit Smart Album…):": "Vissa regler behöver rättas (Redigera smart album…):", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony ARW-filer från före ungefär 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II och deras", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Nedskalade förlustfria ARW-filer från Sony ILCE-7M4 avkodar nu delsamplade YCbCr-rutor till linjär RGB,", + "Sort": "Sortera", + "Sort Albums A–Z": "Sortera album A–Ö", + "Sort Albums by Name": "Sortera album efter namn", + "Sort by": "Sortera efter", + "Sort Contents A–Z": "Sortera innehåll A–Ö", + "Source": "Källa", + "source": "källa", + "Source code on GitHub": "Källkod på GitHub", + "Specific folder": "Angiven mapp", + "Speed": "Hastighet", + "Spherical": "Sfärisk", + "Spiral": "Spiral", + "Split Stack": "Dela upp stapel", + "Spot": "Fläck", + "Spring Fresh": "Vårfräsch", + "Square": "Kvadrat", + "Square Grid": "Kvadratiskt rutnät", + "Square Grid (G toggles)": "Kvadratiskt rutnät (G växlar)", + "Square Grid: show file names and formats": "Kvadratiskt rutnät: visa filnamn och format", + "Stack": "Stapla", + "Stack photos taken within this time of each other:": "Stapla foton som tagits inom denna tid från varandra:", + "Stacks": "Staplar", + "Standard": "Standard", + "Star rating (0–5)": "Stjärnbetyg (0–5)", + "Start at": "Börja vid", + "Start number": "Startnummer", + "Starting": "Startar", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "när GPU:n startas, startar appen nästa gång med GPU-rendering avstängd och visar hur du slår på den igen.", + "Starting…": "Startar…", + "starts using it for AI Denoise": "börjar använda den för AI-brusreducering", + "starts with": "börjar med", + "Startup": "Start", + "State / Province": "Delstat/provins", + "still saves your queued changes in the closing snapshot.": "sparar ändå dina köade ändringar i den avslutande ögonblicksbilden.", + "Stop": "Stoppa", + "Stop Building Previews": "Sluta skapa förhandsgranskningar", + "Stop Using as Target Album": "Sluta använda som målalbum", + "Stopped": "Stoppad", + "Stopped by an error (see the log)": "Stoppad av ett fel (se loggen)", + "Stopping…": "Stoppar…", + "Straighten": "Räta upp", + "Straighten Tool": "Verktyget Räta upp", + "Stroke border": "Konturkant", + "Strong Contrast": "Stark kontrast", + "Style": "Stil", + "Su": "sö", + "Subfolder": "Undermapp", + "Subfolder name": "Namn på undermapp", + "Subject": "Ämne", + "Subtract": "Subtrahera", + "Subtract from this mask": "Subtrahera från den här masken", + "Suggest Names for Faces": "Föreslå namn för ansikten", + "Suggestions": "Förslag", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Föreslår vem som finns på ett foto utifrån de ansikten du har namngett. Allt stannar på din dator.", + "Summer Haze": "Sommardis", + "Sunday": "Söndag", + "Survey": "Översikt", + "Survey (N)": "Översikt (N)", + "Swap": "Byt plats", + "Swap Aspect (X)": "Byt bildförhållande (X)", + "Swap Before and After": "Byt plats på före och efter", + "Swap Before and After Settings": "Byt plats på före- och efterinställningar", + "Swap Compare Photos": "Byt plats på jämförda foton", + "Switch light / dark theme": "Växla mellan ljust och mörkt tema", + "Sync Settings": "Synka inställningar", + "Sync with System": "Synka med systemet", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synkade. Importera → Kopiera kontrollerar nu varje kopia mot innehållshashen som togs när kortet skannades i stället för att", + "Synchronize": "Synkronisera", + "Synchronize Folder": "Synkronisera mapp", + "Synchronize Folder…": "Synkronisera mapp…", + "Synchronizing folder": "Synkroniserar mapp", + "Synchronizing…": "Synkroniserar…", + "Synonyms": "Synonymer", + "System": "Enligt systemet", + "System default": "Systemstandard", + "System Info": "Systeminfo", + "System Info…": "Systeminfo…", + "Table": "Tabell", + "Tags": "Taggar", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Taggar: se Taggar bredvid mallen. Filerna byter namn på disken (tillsammans med sina XMP-sidofiler); befintliga namn får -1, -2…", + "Take a snapshot": "Ta en ögonblicksbild", + "Targeted Adjustment": "Riktad justering", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Riktad justering på: dra uppåt eller nedåt på fotot · Esc för att sluta", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Riktad justering: dra uppåt/nedåt på fotot för att justera färgerna där", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Riktad justering: dra uppåt/nedåt på fotot för att justera kurvan där", + "Teal & Amber": "Blågrönt och bärnsten", + "Teal & Orange": "Blågrönt och orange", + "Temp": "Temp", + "Temperature": "Temperatur", + "Template": "Mall", + "Templates": "Mallar", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Tillfällig session – ditt bibliotek är inte öppet, och inget du gör här sparas.", + "terms unclear": "oklara termer", + "Test Denoise Model": "Testa brusreduceringsmodell", + "Test Face Model": "Testa ansiktsmodell", + "Tested and works on this computer": "Testad och fungerar på den här datorn", + "Text": "Text", + "Text direction": "Textriktning", + "Text overlays": "Textöverlägg", + "Texture": "Textur", + "Th": "to", + "That includes the photos in the folders inside it.": "Det inkluderar fotona i mapparna i den.", + "That key can't be used as a shortcut": "Den tangenten kan inte användas som kortkommando", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "kameran; kartlagda sensordefekter fylls i. `.rwl`- och `.raw`-filer importeras också. Foton som redan", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalogen har en formatversion (ärende #102). Om du öppnar ett bibliotek från en äldre LightCraft uppgraderas det; ett bibliotek", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Kontrollporten (`--control`) stänger en anslutning så snart den tar emot något som inte är en JSON-begäran", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Brusreduceringsmodellen är installerad och används: reglaget Mängd för AI-brusreducering under Detalj fungerar nu", + "The download didn't work:": "Det gick inte att hämta:", + "The ending screen, after the last photo.": "Avslutningsskärmen, efter det sista fotot.", + "The export watermark, drawn on the photo and sized relative to it.": "Exportvattenstämpeln, som ritas på fotot och storleksanpassas efter det.", + "The first time, macOS asks whether LightCraft may control Photos.": "Första gången frågar macOS om LightCraft får styra Photos.", + "the graphics card": "grafikkortet", + "the graphics card is not used": "grafikkortet används inte", + "The graphics card is set up when the next photo is made.": "Grafikkortet konfigureras när nästa foto skapas.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Importen läser katalogen utan att ändra den och lägger till dess foton, redigeringar, nyckelord och samlingar i biblioteket som är öppet nu.", + "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.": "Introt spelas en gång före det första fotot; avslutningen efter det sista fotot när Upprepa är av. Identitetsplåten är den som angetts under Överlägg. Titlar tonas som bilderna och slumpmässig ordning flyttar dem aldrig.", + "The intro screen, before the first photo.": "Introskärmen, före det första fotot.", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG-filen använder `IMG_0001.JPG.xmp`.", + "The layout image can't be shown": "Layoutbilden kan inte visas", + "The library is up to date with this folder.": "Biblioteket är uppdaterat med den här mappen.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "mappträdet under Lokalt och Automatisk import läser också disken i arbetstrådar. Importförloppsfönstret har Avbryt.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Modellen sparas bara på den här datorn. LightCraft laddar aldrig upp eller delar den.", + "The number at the end of the original name (IMG_0042 → 0042)": "Numret i slutet av originalnamnet (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Originaltillägget läggs alltid till; {ext} lägger bara också in det i namnet.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Originalfilerna, oförändrade, var och en med en XMP-sidecarfil med dess redigeringar.", + "The photo has these settings": "Fotot har de här inställningarna", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "Foto-menyn och fotots snabbmeny) hämtar förhandsgranskningen i full storlek och fyller i kamerametadata som de", + "the photos in view": "fotona i vyn", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Fotona i vyn som bilder i ett bildspel, med inställningarna ovan. Förloppet visas i aktivitetsstapeln.", + "The processor does the work.": "Processorn gör arbetet.", + "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3-modellen är installerad: masker för Objekt och Beskriv är klara.", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "samma namn och \"Skriv över\" (eller Exportera med föregående som upprepar det), en exakt utdatasökväg från kontroll-", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Det markerade fotot som bild i bildspelet. Uppspelningen fyller skärmen; förhandsgranskningen av bildförhållandet ändrar bara den här förhandsgranskningen.", + "the selected photos": "de markerade fotona", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Miniatyrcachen räknar och raderar bara sina egna filer (ärende #98): ett bibliotek som öppnas på en mapp som", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "deras mapp inte har bläddrats i på 30 dagar – dina filer och sidecar-filer finns kvar, och när du bläddrar i mappen visas de", + "Their photos and the keywords below them come together under one keyword.": "Deras foton och nyckelorden under dem samlas under ett nyckelord.", + "Their photos in the grid": "Deras foton i rutnätet", + "Theme": "Tema", + "There is no model file to install": "Det finns ingen modellfil att installera", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "De här fotona finns bara i minnet. Öppna ett bibliotek på disken och importera katalogen igen för att spara dem.", + "They can be restored from Recently Deleted until it is emptied.": "De kan återställas från Nyligen raderade tills den töms.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "De flyttas till Nyligen raderade och kan återställas; inga filer på disken påverkas.", + "Thirds": "Tredjedelar", + "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.": "Den här versionen kan inte hämta modellen än. Om du vill installera den manuellt lägger du model.safetensors, vocab.json och merges.txt från Metas facebook/sam3 i mappen ovan: Objekt och Beskriv fungerar så snart de finns där.", + "This build cannot run denoise models.": "Den här versionen kan inte köra brusreduceringsmodeller.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Den här versionen kan inte köra igenkänningsmodeller: de kan läggas till och väljas, men inte användas.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Den här versionen har ingenstans att spara brusreduceringsmodeller, så AI-brusreducering är inte tillgänglig. (Skrivbordsappen har det.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Den här versionen har ingenstans att spara ansiktsmodeller, så modellistan är inte tillgänglig. (Skrivbordsappen har det.)", + "This Computer": "Den här datorn", + "This file cannot be used yet": "Den här filen kan inte användas än", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Den här mappen är inte tillgänglig (frånkopplad enhet?). Smarta förhandsgranskningar byggs och används inte förrän den är tillbaka eller du väljer en annan mapp.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Den här mappen finns inte: den har flyttats eller bytt namn, eller så är dess disk inte ansluten.", + "This group is empty: add a rule or remove it.": "Den här gruppen är tom: lägg till en regel eller ta bort den.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Det här är en Lightroom-katalog, inte ett LightCraft-bibliotek. Vill du importera den till ett LightCraft-bibliotek i stället?", + "This is the album you're editing": "Det här är albumet du redigerar", + "this photo": "det här fotot", + "This photo can't be opened": "Det här fotot kan inte öppnas", + "This raw is now developed from its raw data": "Den här raw-filen framkallas nu från sina rådata", + "This raw variant still can't be decoded": "Den här raw-varianten kan fortfarande inte avkodas", + "This screen is off: turn it on under Titles to play it.": "Den här skärmen är av: slå på den under Titlar för att spela upp den.", + "this screen's profile": "den här skärmens profil", + "Threshold": "Tröskel", + "Thumbnail cache": "Miniatyrcache", + "Thumbnail size": "Miniatyrstorlek", + "Thursday": "Torsdag", + "Tick the box to accept the model's terms first": "Markera rutan för att godkänna modellens villkor först", + "Tidying the thumbnail cache": "Städar miniatyrcachen", + "Time zone": "Tidszon", + "Tint": "Färgton", + "Title": "Titel", + "Title bar": "Namnlist", + "Title metadata": "Titelmetadata", + "Title text": "Titeltext", + "Titles": "Titlar", + "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).": "Om du i stället vill ge den här redigeraren själva originalfilen (raw-filen, utan kopia: till exempel DxO PhotoLab) högerklickar du på ett foto → Redigera i → Öppna original i annan redigerare (RAW, ingen kopia).", + "to the camera's own JPEG.": "till kamerans egen JPEG.", + "Toggle Keyword from Set": "Växla nyckelord från uppsättning", + "Toggle Mask": "Växla mask", + "Toggle Section": "Växla avsnitt", + "Toggle Zoom": "Växla 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}. Annan text visas som den skrivs.", + "Tone Curve": "Tonkurva", + "Tool Overlay": "Verktygsöverlägg", + "Tools": "Verktyg", + "Top": "Överkant", + "Top Level": "Översta nivån", + "Transfer": "Överföring", + "Treatment": "Behandling", + "Triangle": "Triangel", + "true": "true", + "Try Again": "Försök igen", + "Try again": "Försök igen", + "Try Saving Again": "Försök spara igen", + "Try the graphics card again": "Försök med grafikkortet igen", + "Tu": "ti", + "Tuesday": "Tisdag", + "Tungsten": "Glödlampa", + "Turn Off": "Stäng av", + "Turn off": "Stäng av", + "Turn on Include subfolders, or pick another folder under Local": "Slå på Inkludera undermappar, eller välj en annan mapp under Lokalt", + "Type": "Typ", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "TZ/ZS-serien, Leica D-Lux, V-Lux och C-Lux samt generationen GH6, GH7, G9 II, S5 II och S9. De utgår från en", + "Uncheck All": "Avmarkera alla", + "Uncheck the files of this type": "Avmarkera filerna av den här typen", + "Undo": "Ångra", + "Unedited": "Oredigerad", + "Unedited raws": "Oredigerade raw-filer", + "Unflag": "Ta bort flagga", + "Unflagged": "Utan flagga", + "Ungroup Stack": "Dela upp stapel", + "Ungrouped": "Ogrupperade", + "Unknown": "Okänd", + "unknown": "okänd", + "Unknown Date": "Okänt datum", + "Unknown licence": "Okänd licens", + "Unknown tags stay as typed — check the preview for a {typo}.": "Okända taggar behålls som du skrev dem – leta efter {typo} i förhandsgranskningen.", + "Unnamed face": "Namnlöst ansikte", + "Unnamed Faces": "Namnlösa ansikten", + "Unrated": "Utan betyg", + "Unreadable": "Oläsbar", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "orört – äldre versioner läser inte längre en del av det som en skadad logg. När den här versionen har öppnat ett bibliotek,", + "Update": "Uppdatera", + "Update Mask Shape": "Uppdatera maskens form", + "Update Preset with Current Settings": "Uppdatera förinställning med aktuella inställningar", + "Update Rules from Current Filter": "Uppdatera regler från aktuellt filter", + "Update to Current Process": "Uppdatera till aktuell process", + "Update Version": "Uppdatera version", + "Update Version with Current Settings": "Uppdatera version med aktuella inställningar", + "Update with Current Settings": "Uppdatera med aktuella inställningar", + "Updated the edits saved in the external editor": "Uppdaterade redigeringarna som sparats i den externa redigeraren", + "Upright": "Uppräta", + "Urban": "Urban", + "Use": "Använd", + "Use camera-specific defaults": "Använd kameraspecifika standardvärden", + "Use export watermark": "Använd exportvattenstämpel", + "Use Keyword Set": "Använd nyckelordsuppsättning", + "Use Library Folder": "Använd biblioteksmappen", + "Use Screen Profile": "Använd skärmprofil", + "Use selected photo": "Använd markerat foto", + "Use sRGB": "Använd sRGB", + "Use the GPU for rendering": "Använd GPU:n för rendering", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "användes (t.ex. en överhoppad programvaruadapter som llvmpipe) och varför den senaste renderingen föll tillbaka.", + "User Presets": "Användarförinställningar", + "Username": "Användarnamn", + "Value": "Värde", + "Variance": "Varians", + "Version": "Version", + "Version name": "Versionsnamn", + "Versions": "Versioner", + "Versions (Shift+V)": "Versioner (Shift+V)", + "Vertical": "Vertikal", + "Vertical inset (% of short edge)": "Vertikal infällning (% av kortsidan)", + "Vertical text": "Lodrät text", + "Vibrance": "Lyster", + "Video": "Video", + "video": "video", + "Video Duration": "Videolängd", + "Videos": "Videor", + "View": "Visa", + "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).": "Visningsprogram som inte läser PNG-blocket cICP visar PQ-värden som sRGB: platt och urblekt. Använd JPEG (förstärkningskarta) för en bild som ser rätt ut överallt.", + "Viewing and sharing": "Visning och delning", + "Vignette": "Vinjett", + "Vignetting": "Vinjettering", + "Vintage": "Vintage", + "Virtual Copy": "Virtuell kopia", + "Visible": "Synlig", + "Visualize HDR": "Visualisera HDR", + "Visualize range": "Visualisera omfång", + "Visualize Spots": "Visualisera fläckar", + "Visualize Spots (A)": "Visualisera fläckar (A)", + "Vivid": "Livfull", + "Vivid Pop": "Livfull pop", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "volymer (Linux, skiftlägeskänslig APFS) är `img_1.JPG` bredvid `IMG_1.JPG` ett annat foto och det omdöpta får", + "Wait for the import to finish": "Vänta tills importen är klar", + "Waiting to start…": "Väntar på att starta…", + "Warm Film": "Varm film", + "Warm Glow": "Varm glöd", + "Warm Print": "Varm utskrift", + "Warm Table": "Varmt bord", + "Watched folder": "Bevakad mapp", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatisk import från bevakad mapp; Konvertera till DNG; Duplicera; Bygg standard-/1:1-/smarta förhandsgranskningar.", + "Watermark": "Vattenstämpel", + "We": "on", + "Wednesday": "onsdag", + "weeks": "veckor", + "were missing, without touching anything already set.": "saknades, utan att röra något som redan var angivet.", + "What's New": "Nyheter", + "When pins show on the photo (Shift+H cycles)": "När nålar visas på fotot (Shift+H växlar)", + "When rated or hovered": "När det har betyg eller pekaren är över", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "när webbläsaren kan rensa bort biblioteket. Webbhotell: exemplets cache-huvuden markerar inte längre de (ohashade) filerna", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "om originalen finns kontrolleras i en arbetstråd (rutnätsminiatyrer, panelen Info, fotomenyn, Saknas", + "White Balance": "Vitbalans", + "White Balance Selector": "Vitbalansväljare", + "White Balance Selector (W)": "Vitbalansväljare (W)", + "Whites": "Vita", + "Whole names": "Hela namn", + "Width": "Bredd", + "Width (% of photo)": "Bredd (% av fotot)", + "Width (px)": "Bredd (px)", + "Width × Height": "Bredd × höjd", + "Will be replaced": "Kommer att ersättas", + "Will be restored": "Kommer att återställas", + "Will Export": "Kommer att exporteras", + "Window": "Fönster", + "Winter Blue": "Vinterblå", + "with a counterpart here come along, the rest is listed.": "med en motsvarighet här följer med, resten listas.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "utan att spara och Avsluta (ärende #100). Den startar inte längre i tysthet en demosession som såg ut som ett återställt bibliotek och", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Fungerar på raw-filer från kameror med en vanlig (Bayer-)sensor. Andra filer behåller sin vanliga brusreducering.", + "would make this album include itself through other smart albums": "skulle få det här albumet att inkludera sig självt via andra smarta album", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "skrivet av en nyare LightCraft avvisas med ”det här biblioteket skrevs av en nyare version av LightCraft” och lämnas", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "skrivet av en nyare LightCraft) talar LightCraft om det och varför, och erbjuder Försök igen, Välj ett annat bibliotek…, Fortsätt", + "Wrote the XMP sidecars that were waiting": "Skrev de XMP-sidofiler som väntade", + "XMP Sidecar Preferences": "Inställningar för XMP-sidofiler", + "XMP sidecars": "XMP-sidofiler", + "Year": "År", + "year, 2 digits": "år, 2 siffror", + "year, 4 digits": "år, 4 siffror", + "years": "år", + "Yellow": "Gul", + "yellow": "gul", + "Yellow Hue": "Gul nyans", + "Yellow label": "Gul etikett", + "Yellow Luminance": "Gul luminans", + "Yellow Saturation": "Gul mättnad", + "Yes": "Ja", + "You can close this: the download continues, and resumes if interrupted.": "Du kan stänga det här: hämtningen fortsätter och återupptas om den avbryts.", + "Your library couldn't be opened": "Det gick inte att öppna ditt bibliotek", + "Your Name": "Ditt namn", + "Your name or studio": "Ditt namn eller studio", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Egna mallar kräver en inställningsmapp, som den här versionen saknar; de inbyggda fungerar.", + "Your templates": "Dina mallar", + "Zoom": "Zoom", + "Zoom 100%": "Zooma 100 %", + "Zoom In": "Zooma in", + "Zoom Out": "Zooma ut", + "Zoom to fill frame": "Zooma för att fylla bildrutan", + "Zoom to Fit": "Zooma för att anpassa", + "Zoom to page level": "Zooma till sidnivå", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} är tagningstiden; ett foto utan en sådan använder tiden då det importerades.", + "© 2026 Your Name": "© 2026 Ditt namn", + "© Your Name": "© Ditt namn", + "− harder border · + softer border": "− hårdare kant · + mjukare kant", + "≤ at most": "≤ högst", + "≥ at least": "≥ minst", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-dra för att räta upp, ⇧G guidad upprätning, ett rutnät under transformering.", + "★ Favorites": "★ Favoriter", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 4cf3dc80b..0ada44e08 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-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/uk.json b/crates/ui-egui/locales/uk.json index fbdef1c5f..4dff39f78 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -78,6 +78,7 @@ "Deselect All": "Зняти всі виділення", "Detail": "Деталі", "Details": "Подробиці", + "Dimensions": "Розміри", "Display P3": "Display P3", "Display name": "Відображуване ім’я", "Distortion": "Спотворення", @@ -1379,6 +1380,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": "Показати оцінки, прапорці й позначки змін", @@ -1528,7 +1530,7 @@ "White Balance Selector (W)": "Вибір балансу білого (W)", "Width (% of photo)": "Ширина (% фото)", "Width (px)": "Ширина (пікселів)", - "Width × Height": "Ширина × висота", + "Width & Height": "Ширина & висота", "Will be replaced": "Буде замінено", "Will be restored": "Буде відновлено", "Winter Blue": "Зимова синява", diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json new file mode 100644 index 000000000..9ff8ed6b9 --- /dev/null +++ b/crates/ui-egui/locales/vi-formats.json @@ -0,0 +1,301 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} có nhiều tệp trông giống nhau: dùng Định vị)", + " ({} in Recently Deleted)": " ({} trong Đã xóa gần đây)", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " · {:.0}% clipped": " · {:.0}% bị cắt", + " · {failed} couldn't be rendered": " · {failed} không thể kết xuất", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + " · {queued} waiting": " · {queued} đang chờ", + " · {skipped} skipped (file exists)": " · {skipped} đã bỏ qua (tệp đã tồn tại)", + " · {} duplicate{} skipped": " · {} bản trùng{:.0} đã bỏ qua", + " · {} failed: {}": " · {} thất bại: {}", + " · {} moved": " · {} đã di chuyển", + " · {} not readable": " · {} không đọc được", + " · {} original{} left at the source": " · {} tệp gốc{:.0} còn ở nguồn", + " · {} restored from Recently Deleted": " · {} đã khôi phục từ Đã xóa gần đây", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One không nhập danh sách có từ khóa chứa ; , < hoặc >: {names}", + " — kept but not applied: {}": " — kept but not applied: {}", + " — left out, as a keyword list can't hold them: {names}": " — bỏ qua vì danh sách từ khóa không chứa được: {names}", + " — not carried over: {}{}": " — không được chuyển sang: {}{}", + "+{n} more": "+{n} nữa", + "+{} more": "+{} nữa", + ", {failed} file{} not readable": ", {failed} tệp{:.0} không đọc được", + ", {} label": ", nhãn {}", + ", {} star{}": ", {} sao{:.0}", + "1 task is still running: {names}. Quitting stops it.": "Còn 1 tác vụ đang chạy: {names}. Thoát sẽ dừng tác vụ.", + "; {kept} already had a location": "; {kept} đã có vị trí", + "; {n} gained synonyms": "; {n} có thêm từ đồng nghĩa", + "; {outside} outside its time range": "; {outside} ngoài khoảng thời gian của tuyến", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} dòng (Δ {}), +{} / −{} tài nguyên nhị phân, {} – {}", + "Add {local_n} to My Photos": "Thêm {local_n} vào Ảnh của tôi", + "Add {} Photo{}": "Thêm {} ảnh{:.0}", + "Add {} Photo{}, restore {}": "Thêm {} ảnh{:.0}, khôi phục {}", + "Add “{}” to Favorites": "Thêm «{}» vào Yêu thích", + "Added {n} keyword{}": "Đã thêm {n} từ khóa{:.0}", + "Added {n} photo{} to My Photos": "Đã thêm {n} ảnh{:.0} vào Ảnh của tôi", + "Added {n} photo{} to “{}”": "Đã thêm {n} ảnh{0:.0} vào «{1}»", + "Added {what} to {name}": "Đã thêm {what} vào {name}", + "Added “{keyword}” to {n} photo{}": "Đã thêm «{keyword}» vào {n} ảnh{:.0}", + "Adding photos… {} of {}": "Đang thêm ảnh… {} / {}", + "All {n} selected photos move by the same amount.": "Tất cả {n} ảnh đã chọn dịch chuyển cùng một lượng.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Đã có trong thư viện, trong Đã xóa gần đây: đã chọn {} ảnh{:.0} · nhấp chuột phải → Khôi phục, hoặc Xóa vĩnh viễn để nhập lại", + "Already in the library: {} photo{} selected in All Photos": "Đã có trong thư viện: đã chọn {} ảnh{:.0} trong Tất cả ảnh", + "Already in “{}”": "Already in “{}”", + "Applied {name}": "Applied {name}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Auto ({})": "Tự động ({})", + "Auto Import: added {} photo{}": "Tự động nhập: đã thêm {} ảnh{:.0}", + "Auto Sync: edits apply to {n} photos": "Đồng bộ tự động: chỉnh sửa áp dụng cho {n} ảnh", + "Automatic ({} MB)": "Tự động ({} MB)", + "Build 1:1 Previews ({scope})": "Tạo xem trước 1:1 ({scope})", + "Build Smart Previews ({scope})": "Tạo xem trước thông minh ({scope})", + "Build Standard-Sized Previews ({scope})": "Tạo xem trước cỡ chuẩn ({scope})", + "Building previews for {} photos…": "Đang tạo xem trước cho {} ảnh…", + "Click {}:1": "Nhấp {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "Couldn't open the editor: {e}": "Không thể mở trình chỉnh sửa: {e}", + "Create Keyword Tag Inside “{name}”…": "Tạo từ khóa trong «{name}»…", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "Create {n} Virtual Copies": "Tạo {n} bản sao ảo", + "Creates {} stacks from {} of {scope}": "Tạo {} chồng từ {} trong {scope}", + "Decoder: {reason}": "Bộ giải mã: {reason}", + "Delete \"{label}\"": "Xóa «{label}»", + "Delete \"{label}\" (and the mask)": "Xóa «{label}» (và mặt nạ)", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete the keyword “{name}”?": "Xóa từ khóa «{name}»?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} Photos": "Xóa {n} ảnh", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Delete “{current}”": "Xóa «{current}»", + "Delete “{name}”?": "Delete “{name}”?", + "Denoising {name}: {done} of {total} tiles{}": "Khử nhiễu {name}: {done} / {total} ô{}", + "Discard Smart Previews ({scope})": "Hủy xem trước thông minh ({scope})", + "Done ({what}): {done} photo{plural}": "Xong ({what}): {done} ảnh{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Tải từ {host}. Sau khi tải xong và kiểm tra, LightCraft cài đặt, {after}.", + "Duplicate \"{label}\"": "Nhân bản «{label}»", + "Effect: {name}": "Effect: {name}", + "Example: {example}": "Ví dụ: {example}", + "Exported to {path}": "Exported to {path}", + "Exported {n} keyword{}": "Đã xuất {n} từ khóa{:.0}", + "Exported {n} photo{}": "Đã xuất {n} ảnh{:.0}", + "Exported {ok} of {total} photo{}": "Đã xuất {ok} / {total} ảnh{:.0}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Exported {} point curve preset{}": "Đã xuất {} thiết lập sẵn đường cong điểm{:.0}", + "Exported {} preset{}": "Đã xuất {} thiết lập sẵn{:.0}", + "Exporting {} of {total}": "Đang xuất {} / {total}", + "Filter bar — {active} active filter{}": "Thanh lọc — {active} bộ lọc đang bật{:.0}", + "Filtered: {label}": "Đã lọc: {label}", + "Focus {:.0}": "Tiêu điểm {:.0}", + "Folder “{}” (and its subfolders)": "Thư mục «{}» (và thư mục con)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Tìm thấy {n} ảnh thiếu{:.0}; còn thiếu {left}{unsure}", + "Healed {n} dust spot{}": "Đã xử lý {n} vết bụi{:.0}", + "Import cancelled · {} photo{} added": "Đã hủy nhập · đã thêm {} ảnh{:.0}", + "Import failed: {e}": "Nhập thất bại: {e}", + "Import warnings ({n}):\n{warnings}": "Cảnh báo khi nhập ({n}):\n{warnings}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "Imported {name}": "Imported {name}", + "Imported {n} point curve preset{}": "Đã nhập {n} thiết lập sẵn đường cong điểm{:.0}", + "Imported {n} preset{}": "Đã nhập {n} thiết lập sẵn{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Đã nhập {n} thiết lập sẵn{:.0}, {f} tệp{:.0} không đọc được", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Đã nhập {profiles} hồ sơ{:.0} (Trình duyệt hồ sơ → nhóm của chúng)", + "Imported {} photo{}": "Đã nhập {} ảnh{:.0}", + "In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…", + "Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Label Set: {name}": "Bộ nhãn: {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 chưa giải mã được biến thể raw này ({}). Bạn đang chỉnh JPEG xem trước nhúng của máy ảnh, đã gồm style ảnh (ví dụ Monochrome) và cân bằng trắng.", + "LightCraft could not open its window: {e}": "LightCraft không thể mở cửa sổ: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft không thể mở cửa sổ: {e}\n\nChi tiết trong tệp nhật ký: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Đã kiểm tra catalog Lightroom: {photos} ảnh, {collections} bộ sưu tập, {missing} thiếu", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Hình học Lightroom cho {path} không được giữ; biến đổi Guided Upright đã lưu không được hỗ trợ.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Hình học Lightroom cho {path} không được giữ; biến đổi Upright đã lưu không được hỗ trợ.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Hình học Lightroom cho {path} không được giữ; biến đổi đã lưu bị bỏ qua vì Upright đang tắt.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Hình học Lightroom cho {path} không được giữ; {mode} sẽ được tính lại.", + "Lightroom import complete: {imported} photos added": "Nhập Lightroom xong: đã thêm {imported} ảnh", + "Lightroom import complete: {imported} photos added, {failed} failed": "Nhập Lightroom xong: đã thêm {imported} ảnh, {failed} thất bại", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Nhập Lightroom xong: đã thêm {imported} ảnh; cảnh báo chỉ mục: {warning}", + "Lightroom import failed: {e}{warning}": "Nhập Lightroom thất bại: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Kiểm tra Lightroom thất bại: {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. Phông: {} (OFL). Biểu tượng: tác phẩm gốc.", + "Making the denoised picture… {}%": "Đang tạo ảnh đã khử nhiễu… {}%", + "Matches: {}": "Khớp: {}", + "Merging… {stage} {:.0}%": "Đang ghép… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Move {what} to Recently Deleted?": "Chuyển {what} vào Đã xóa gần đây?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", + "Moved {} photo(s)": "Đã di chuyển {} ảnh", + "Moved; {} photo(s) relinked": "Đã di chuyển; liên kết lại {} ảnh", + "Named ({named_n})": "Có tên ({named_n})", + "New Folder in “{name}”": "Thư mục mới trong «{name}»", + "On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng", + "Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép", + "Preset: {name}": "Thiết lập sẵn: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Chỉ xem trước — chỉnh sửa JPEG nhúng của máy ảnh ({})", + "Preview only — {}": "Chỉ xem trước — {}", + "Previews ready for {done} photo{plural}": "Xem trước sẵn sàng cho {done} ảnh{plural:.0}", + "Profile: {}": "Hồ sơ: {}", + "Proof Copy ({})": "Bản sao thử ({})", + "Proof Preview · {}": "Xem trước thử · {}", + "Put inside “{parent}”": "Đặt trong «{parent}»", + "Quick Develop · {n} photos": "Phát triển nhanh · {n} ảnh", + "Quick Develop: {name}": "Phát triển nhanh: {name}", + "Rated {}": "Đã xếp hạng {}", + "Reading Lightroom catalog… {done} of {total}": "Đang đọc catalog Lightroom… {done} / {total}", + "Reading photos… {done} of {total}": "Đang đọc ảnh… {done} / {total}", + "Reading photos… {} of {}": "Đang đọc ảnh… {} / {}", + "Redo {}": "Làm lại {}", + "Remove “{name}”": "Gỡ «{name}»", + "Remove “{name}” and its {count} photo{} from the library?": "Gỡ «{name}» và {count} ảnh{:.0} khỏi thư viện?", + "Remove “{}” from Favorites": "Gỡ «{}» khỏi Yêu thích", + "Removed from {}": "Đã gỡ khỏi {}", + "Removed {what} from {name}": "Đã gỡ {what} khỏi {name}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Rename {n} Photos…": "Đổi tên {n} ảnh…", + "Rename “{name}”": "Đổi tên «{name}»", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Đổi tên «{from}» trên {n} ảnh{:.0} (cả từ khóa con). Dùng | cho cấp, ví dụ Du lịch|Ý. Tên đã có sẽ gộp hai mục.", + "Rendering on the CPU: {why}": "Đang kết xuất trên CPU: {why}", + "Replace {} with:": "Thay {} bằng:", + "Reset {name}": "Đặt lại {name}", + "Reset {} Channel": "Đặt lại kênh {}", + "Restore {}": "Khôi phục {}", + "Restore {} Photo{}": "Khôi phục {} ảnh{:.0}", + "Restored {} photo{} from Recently Deleted": "Đã khôi phục {} ảnh{:.0} từ Đã xóa gần đây", + "Running on the processor: {}.": "Chạy trên bộ xử lý: {}.", + "Running on {}.": "Chạy trên {}.", + "Saved export preset “{}”": "Đã lưu thiết lập sẵn xuất «{}»", + "Saved template {name}": "Saved template {name}", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Chấm điểm {scope} theo tiêu điểm và phơi sáng, tìm ảnh tương tự chụp trong vài giây (burst).", + "Show {hidden} hidden location{}": "Hiện {hidden} vị trí ẩn{:.0}", + "Showing {n} of {}": "Hiển thị {n} / {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Hiện điều khoản mô hình, rồi tải {} từ {host}, cài và bắt đầu dùng. Không gửi gì khác.", + "Skipped {problem}": "Skipped {problem}", + "Slideshow exported: {what}": "Slideshow exported: {what}", + "Smart album: {}": "Album thông minh: {}", + "Tagged {n} photo{} from the tracklog": "Đã gắn thẻ {n} ảnh{:.0} từ nhật ký tuyến", + "Template preview: {name}": "Template preview: {name}", + "The SAM 3 download failed: {e}": "Tải SAM 3 thất bại: {e}", + "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.", + "There is a keyword set “{name}” already": "Đã có bộ từ khóa «{name}»", + "There is a “{name}” at the top level already. Merge the two?": "Đã có «{name}» ở cấp cao nhất. Gộp hai mục?", + "Trained on: {provenance}": "Huấn luyện trên: {provenance}", + "Undo {}": "Hoàn tác {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Thẻ{s:.0} không xác định {} giữ nguyên như đã gõ{:.0} (xem Thẻ)", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "Updated {name}": "Updated {name}", + "Updated “{name}”": "Đã cập nhật «{name}»", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Phiên bản {} — thư viện ảnh và trình phát triển raw thuần Rust, clean-room.", + "Version: {}": "Phiên bản: {}", + "Virtual copy “{name}” of {of}": "Bản sao ảo «{name}» của {of}", + "Working on {what} for {} photos…": "Đang xử lý {what} cho {} ảnh…", + "XMP adjustments were not imported for {path}: {fields}.": "Điều chỉnh XMP không được nhập cho {path}: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Hồ sơ XMP «{profile}» không được áp dụng cho {path}; màu có thể khác Lightroom.", + "new, inside {parent}": "new, inside {parent}", + "none (CPU rendering): {why}": "không (kết xuất CPU): {why}", + "not added to Apple Photos: {e}": "không thêm được vào Apple Photos: {e}", + "the {n} selected photos": "{n} ảnh đã chọn", + "the {} photos in view": "{} ảnh đang hiển thị", + "these {count} photos": "{count} ảnh này", + "{:.1} MB": "{:.1} MB", + "{count} more warnings.": "{count} more warnings.", + "{count} smart preview{} · {:.1} MB": "{count} xem trước thông minh{:.0} · {:.1} MB", + "{done} of {total}": "{done} / {total}", + "{done} of {total} MB": "{done} / {total} MB", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "{f} thư mục (và thư mục con)", + "{label} Grading": "Phân cấp màu {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} trên mọi ảnh đã chọn", + "{label} {index} of {n}": "{label} {index} / {n}", + "{label}, {n} photos": "{label}, {n} ảnh", + "{matching} of {t} photos": "{matching} / {t} ảnh", + "{matching} photos": "{matching} ảnh", + "{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.", + "{mode} spot {} of {}": "Vết {mode} {} / {}", + "{month}": "Tháng {month}", + "{month} {year}": "Tháng {month} năm {year}", + "{msg} ({failed} failed)": "{msg} ({failed} thất bại)", + "{name} Label": "Nhãn {name}", + "{name} Off": "{name} tắt", + "{name} On": "{name} bật", + "{name} opened for editing; it is stacked with the original": "{name} đã mở để chỉnh sửa; được xếp chồng với bản gốc", + "{name} opened in the other editor": "Đã mở {name} trong trình chỉnh sửa khác", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — nhấp đúp để đặt lại", + "{n} JPEG files": "{n} JPEG files", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Đã bỏ qua {n} ảnh cục bộ{0:.0}: album chỉ chứa ảnh thư viện (hãy thêm vào Ảnh của tôi trước)", + "{n} added to Apple Photos": "Đã thêm {n} vào Apple Photos", + "{n} added to Apple Photos album “{album}”": "Đã thêm {n} vào album Apple Photos «{album}»", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Không ghi được {n} thay đổi{0:.0} lên đĩa: {e}\nNếu thoát ngay, sẽ mất.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Đã lưu {n} thay đổi{:.0} trong bộ nhớ nhưng chưa ghi đĩa: {e}\nLightCraft sẽ thử lại tự động; thoát ngay sẽ mất.{:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Đã lưu {n} thay đổi{:.0} trong bộ nhớ nhưng chưa ghi đĩa: {e} — LightCraft sẽ thử lại", + "{n} found · {sel} selected": "Tìm thấy {n} · đã chọn {sel}", + "{n} originals opened in the other editor": "Đã mở {n} bản gốc trong trình chỉnh sửa khác", + "{n} pages": "{n} pages", + "{n} photos": "{n} ảnh", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} photos in {} are hidden by the filters above": "{n} ảnh trong «{}» bị ẩn bởi bộ lọc phía trên", + "{n} photos opened for editing; each is stacked with its original": "Đã mở {n} ảnh để chỉnh sửa; mỗi ảnh xếp chồng với bản gốc", + "{n} photo{}": "{n} ảnh{:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} ảnh{:.0} khớp · tự cập nhật khi ảnh thay đổi", + "{n} photo{} now · updates automatically as photos change": "Hiện {n} ảnh{:.0} · tự cập nhật khi ảnh thay đổi", + "{n} preset{}": "{n} preset{}", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} ảnh tương tự{:.0} · Xem → Xóa bộ lọc để thấy tất cả", + "{n} tasks are still running: {names}. Quitting stops them.": "Còn {n} tác vụ đang chạy: {names}. Thoát sẽ dừng chúng.", + "{n} {noun}": "{noun}: {n}", + "{pct} %": "{pct} %", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{pid} (missing)": "{pid} (thiếu)", + "{sel_n} selected · {counted}": "Đã chọn {sel_n} · {counted}", + "{size} GB": "{size} GB", + "{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 of AV1 video", + "{verb} {n} Photo{}": "{verb} {n} ảnh{:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day}/{month}/{year}", + "{what} merge added": "Đã thêm ghép {what}", + "{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})", + "{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 cancelled": "Đã hủy ghép {what}", + "{what} merge failed: {e}": "Ghép {what} thất bại: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Chỉ thị {{date:…}}: {}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "{} and higher": "{} trở lên", + "{} contributors · {} commits": "{} người đóng góp · {} commit", + "{} failed": "{} thất bại", + "{} files": "{} tệp", + "{} files and {f} folder{}": "{} tệp và {f} thư mục{:.0}", + "{} item{}": "{} mục{:.0}", + "{} label (click more labels to show any of them)": "Nhãn «{}» (nhấp thêm nhãn để hiện ảnh có bất kỳ nhãn nào)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} / 4 đường dẫn — kéo dọc các đường cần thẳng đứng hoặc nằm ngang.", + "{} of {} from {host}": "{} / {} từ {host}", + "{} photos": "{} ảnh", + "{} photos · exposures {} EV": "{} ảnh · phơi sáng {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} ảnh · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "{} ảnh{:.0} không đọc được", + "{} sample{}": "{} mẫu{:.0}", + "{} sec": "{} giây", + "{} spot(s) on this photo": "{} vùng trên ảnh này", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Giữ trong thư mục «denoise» của thư viện; khi đạt giới hạn, xóa trước bản cũ nhất không ảnh nào dùng.", + "{} — show their photos": "{} — hiện ảnh của họ", + "· {dups} already in the library": "· {dups} đã có trong thư viện", + "· {in_source} identical copies": "· {in_source} bản sao giống hệt", + "· {n} photo{}": "· {n} ảnh{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} trong Đã xóa gần đây", + "“{parent}” has a “{name}” already. Merge the two?": "«{parent}» đã có «{name}». Gộp hai mục?", + "“{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.", + "• {assumption}": "• {assumption}", + "… and {more} more": "… và {more} nữa", + "…and {more} more": "…and {more} more" +} diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json new file mode 100644 index 000000000..6908be70e --- /dev/null +++ b/crates/ui-egui/locales/vi.json @@ -0,0 +1,2224 @@ +{ + " (library default)": " (mặc định thư viện)", + " (inverted)": " (đã đảo)", + " · best of its burst": " · đẹp nhất trong cụm", + " · in a burst": " · trong một cụm", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "\"được lưu trong bộ nhớ nhưng chưa ghi ra đĩa\", thanh trên cùng hiển thị cảnh báo và LightCraft tiếp tục thử lại cho đến khi", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): một DNG được đối chiếu với dữ liệu raw, ghi vào tệp tạm, đồng bộ và đọc lại trước khi nhận", + "% of all commits": "% tất cả commit", + "& Containing": "& Chứa", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Trợ giúp → Thông tin hệ thống và Cài đặt → Hiệu năng giải thích vì sao GPU không", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(ví dụ D750, D780, D850, D7500, Z 50) không còn hiển thị gần như đen hoặc vùng tối bị nén: mức đen của chúng đã được", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(issue #94): yêu cầu HTTP từ trang web không còn mang lệnh trong phần thân. Số dòng được giới hạn ở", + "+ Group": "+ Nhóm", + "+ Rule": "+ Quy tắc", + ", picked": ", đã chọn", + ", rejected": ", đã loại", + "0: nothing is rejected.": "0: không loại gì.", + "1 day": "1 ngày", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 on studio-mac)\") thay vì để cả hai ghi và lặng lẽ bỏ qua chỉnh sửa của nhau. Sự cố không bao giờ để lại", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "xem trước 1620 × 1080 và nhập kèm metadata: thời gian chụp, máy ảnh, ống kính, phơi sáng, GPS và XMP. Raw của chúng", + "2026-04 or 2026-04-12": "2026-04 hoặc 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 thiết lập sẵn tích hợp mới: Chân dung, Phong cảnh, Đô thị, Ẩm thực, Mùa, Cổ điển và toner Đen trắng.", + "4 MiB and connections at 16.": "4 MiB và tối đa 16 kết nối.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 drivers): trên Windows cửa sổ và kết xuất GPU chỉ dùng DirectX 12 và không tải driver Vulkan trừ khi", + "= exactly": "= đúng bằng", + "A blank template keeps the original names.": "Mẫu trống giữ tên gốc.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Thay đổi không thể lưu ra đĩa (đĩa đầy hoặc đã rút) không còn im lặng: lệnh báo", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Bộ lọc đang ẩn ảnh trong chế độ xem này: đổi bộ lọc hoặc xóa (Xem → Xóa bộ lọc)", + "A folder is already being synchronized": "Một thư mục đang được đồng bộ", + "A folder is being synchronized": "Một thư mục đang được đồng bộ", + "A keyword set needs a name": "Bộ từ khóa cần một tên", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Một thư viện chỉ mở trong một chương trình tại một thời điểm (issue #99): mở thư viện mà LightCraft hoặc `lightcraft-cli` đã", + "A one-time download of about": "Tải một lần khoảng", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Lưu thất bại giữa chừng (đĩa đầy, ổ mạng bị ngắt) không còn trông như catalog bị hỏng", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "NAS đang ngủ, ổ mạng bị ngắt hoặc ổ USB đang quay lên không còn làm đóng băng cửa sổ (issue #104):", + "AI Denoise": "Khử nhiễu AI", + "AI Denoise Model": "Mô hình khử nhiễu AI", + "AI Denoise is enabled on the requesting photo": "Khử nhiễu AI đã bật trên ảnh yêu cầu", + "AI Denoise is off: press Use on a model.": "Khử nhiễu AI đang tắt: nhấn Dùng trên một mô hình.", + "AI Denoise needs a model. Install one in Settings.": "Khử nhiễu AI cần một mô hình. Hãy cài trong Cài đặt.", + "AI Mask Model Status": "Trạng thái mô hình mặt nạ AI", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Mặt nạ AI với SAM 3 (Object và Describe trong bảng Mặt nạ): bấm vào đối tượng để chọn (⌥-click để lại", + "About": "Giới thiệu", + "About LightCraft": "Giới thiệu LightCraft", + "Accept & Download": "Chấp nhận và tải", + "Active": "Đang dùng", + "Activity": "Hoạt động", + "Actual size": "Kích thước thực", + "Add": "Thêm", + "Add Catchlight": "Thêm điểm sáng mắt", + "Add Face Model…": "Thêm mô hình khuôn mặt…", + "Add Folder to Local": "Thêm thư mục vào Cục bộ", + "Add Mask": "Thêm mặt nạ", + "Add Object Click": "Thêm cú nhấp đối tượng", + "Add Point Color Sample": "Thêm mẫu Point Color", + "Add Red Eye Correction": "Thêm sửa mắt đỏ", + "Add Remove Spot": "Thêm điểm xóa", + "Add Selected Photos": "Thêm ảnh đã chọn", + "Add a rule (⌥-click: a group)": "Thêm quy tắc (⌥-nhấp: một nhóm)", + "Add in place": "Thêm tại chỗ", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Thêm tại chỗ: thư viện tham chiếu tệp ở vị trí hiện tại; không sao chép hay di chuyển gì.", + "Add keyword": "Thêm từ khóa", + "Add number": "Thêm số", + "Add to": "Thêm vào", + "Add to Album": "Thêm vào album", + "Add to All Selected Photos": "Thêm vào mọi ảnh đã chọn", + "Add to Apple Photos": "Thêm vào Ảnh Apple", + "Add to Favorites": "Thêm vào yêu thích", + "Add to Local": "Thêm vào Cục bộ", + "Add to My Photos": "Thêm vào Ảnh của tôi", + "Add to My Photos First": "Thêm vào Ảnh của tôi trước", + "Add to Selected Photos": "Thêm vào ảnh đã chọn", + "Add to Target Album": "Thêm vào album đích", + "Add to favorites": "Thêm vào yêu thích", + "Add to this mask": "Thêm vào mặt nạ này", + "Add/Subtract/Intersect Mask": "Thêm/Trừ/Giao mặt nạ", + "Added to photos you import that don't already have it.": "Được thêm vào ảnh bạn nhập nếu chưa có.", + "Adding": "Đang thêm", + "Adding Lightroom photos…": "Đang thêm ảnh Lightroom…", + "Adding photos…": "Đang thêm ảnh…", + "Adding to Apple Photos": "Đang thêm vào Ảnh Apple", + "Adding to Apple Photos…": "Đang thêm vào Ảnh Apple…", + "Advanced search…": "Tìm kiếm nâng cao…", + "After": "Sau", + "After 30 days": "Sau 30 ngày", + "After 7 days": "Sau 7 ngày", + "After 90 days": "Sau 90 ngày", + "After a year": "Sau một năm", + "After installation, AI Denoise will turn on for the requesting photo.": "Sau khi cài, Khử nhiễu AI sẽ bật cho ảnh yêu cầu.", + "Album": "Album", + "Albums": "Album", + "Albums hold library photos: Add to My Photos first": "Album chứa ảnh thư viện: hãy Thêm vào Ảnh của tôi trước", + "All": "Tất cả", + "All Metadata": "Tất cả siêu dữ liệu", + "All Metadata…": "Tất cả siêu dữ liệu…", + "All Photos": "Tất cả ảnh", + "All rights reserved": "Đã đăng ký bản quyền", + "All the photos imported from this disk are included.": "Tất cả ảnh nhập từ đĩa này đều được bao gồm.", + "All types": "Mọi loại", + "Already installed. Remove it before installing a replacement.": "Đã cài. Hãy gỡ trước khi cài bản thay thế.", + "Alt Text": "Văn bản thay thế", + "Always": "Luôn luôn", + "Amount": "Độ mạnh", + "Analyzing the photo for AI masks…": "Đang phân tích ảnh cho mặt nạ AI…", + "Angle": "Góc", + "Any Searchable Text": "Mọi văn bản tìm được", + "Any camera": "Mọi máy ảnh", + "Any keyword": "Mọi từ khóa", + "Any lens": "Mọi ống kính", + "Aperture": "Khẩu độ", + "Appearance": "Giao diện", + "Appearance Mode": "Chế độ giao diện", + "Appearance Settings…": "Cài đặt giao diện…", + "Apple Photos Imports": "Ảnh nhập từ Apple Photos", + "Application": "Ứng dụng", + "Apply": "Áp dụng", + "Apply Color Label Set": "Áp dụng bộ nhãn màu", + "Apply Filter Preset": "Áp dụng thiết lập sẵn bộ lọc", + "Apply Metadata Preset": "Áp dụng thiết lập sẵn siêu dữ liệu", + "Apply Point Curve Preset": "Áp dụng thiết lập sẵn đường cong điểm", + "Apply Preset": "Áp dụng thiết lập sẵn", + "Apply Settings JSON": "Áp dụng JSON cài đặt", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Áp dụng ánh xạ tông cục bộ lưu trong raw (Apple ProRAW), như máy ảnh hiển thị", + "Approved": "Đã duyệt", + "Aqua": "Ngọc lam", + "Aqua Hue": "Hue ngọc lam", + "Aqua Luminance": "Độ sáng ngọc lam", + "Aqua Saturation": "Độ bão hòa ngọc lam", + "Arrange Panel": "Sắp xếp bảng", + "ArtCraft Website": "Trang web ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — thêm ứng dụng sáng tạo", + "As Shot": "Như khi chụp", + "As shot": "Như khi chụp", + "Ascending": "Tăng dần", + "Aspect": "Tỷ lệ", + "Aspect Ratio": "Tỷ lệ khung hình", + "Assisted Culling": "Sàng lọc hỗ trợ", + "Assisted Culling…": "Sàng lọc hỗ trợ…", + "Auto": "Tự động", + "Auto Advance": "Tự chuyển tiếp", + "Auto Advance Off": "Tắt tự chuyển tiếp", + "Auto Advance On": "Bật tự chuyển tiếp", + "Auto Advance after rating or flagging": "Tự chuyển tiếp sau khi xếp hạng hoặc gắn cờ", + "Auto Advance: move to the next photo after rating or flagging": "Tự chuyển tiếp: sang ảnh tiếp theo sau khi xếp hạng hoặc gắn cờ", + "Auto Align": "Tự căn chỉnh", + "Auto B&W Mix": "Tự trộn B&W", + "Auto Crop": "Tự cắt", + "Auto Import": "Tự nhập", + "Auto Import Now": "Tự nhập ngay", + "Auto Import Settings": "Cài đặt tự nhập", + "Auto Mask": "Tự tạo mặt nạ", + "Auto Select": "Tự chọn", + "Auto Settings": "Cài đặt tự động", + "Auto Straighten": "Tự chỉnh thẳng", + "Auto Sync": "Tự đồng bộ", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Auto Sync: chỉnh sửa áp dụng cho mọi ảnh đã chọn. Auto B&W mix. Phiên bản tự động.", + "Auto settings applied": "Đã áp dụng cài đặt tự động", + "Auto-Stack by Capture Time": "Tự xếp chồng theo thời gian chụp", + "Auto-Stack by Capture Time…": "Tự xếp chồng theo thời gian chụp…", + "Auto-Tag Photos from Tracklog": "Tự gắn thẻ ảnh từ nhật ký hành trình", + "Auto-Tag from Tracklog": "Tự gắn thẻ từ nhật ký hành trình", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Auto-Tag from Tracklog: vị trí GPS cho ảnh từ nhật ký track GPX, khớp theo thời gian chụp.", + "Auto-Tag from Tracklog…": "Tự gắn thẻ từ nhật ký hành trình…", + "Automatic": "Tự động", + "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.": "Tự động kết xuất chế độ xem Chi tiết đúng kích thước hiển thị, tối đa bằng pixel của ảnh. Chọn kích thước giới hạn: nhỏ hơn thì nhanh hơn.", + "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.": "Tự động dùng card đồ họa khi nhanh hơn bộ xử lý: cả hai được đo thời gian trên máy này lần đầu khi tạo ảnh.", + "Automatically Write Changes into XMP": "Tự ghi thay đổi vào XMP", + "Automatically write changes into XMP sidecars": "Tự ghi thay đổi vào sidecar XMP", + "Autumn Gold": "Vàng mùa thu", + "B&W": "B&W", + "B&W Mix": "Trộn B&W", + "B&W Mixer": "Bộ trộn B&W", + "Back": "Quay lại", + "Back Up Library…": "Sao lưu thư viện…", + "Back to Edit": "Quay lại chỉnh sửa", + "Back to Edit (E)": "Quay lại chỉnh sửa (E)", + "Back to Grid": "Quay lại lưới", + "Back to this person": "Quay lại người này", + "Background": "Nền", + "Background Tasks": "Tác vụ nền", + "Balance": "Cân bằng", + "Basic": "Cơ bản", + "Before": "Trước", + "Before/After (Y)": "Trước/Sau (Y)", + "Before/After Settings": "Cài đặt Trước/Sau", + "Before/After Split": "Chia Trước/Sau", + "Before/After Split Top/Bottom": "Chia Trước/Sau trên/dưới", + "Before/After Top/Bottom": "Trước/Sau trên/dưới", + "Begin Interaction": "Bắt đầu tương tác", + "Best of Similar Shots": "Ảnh đẹp nhất trong nhóm tương tự", + "Binary assets added": "Tài nguyên nhị phân đã thêm", + "Binary assets removed": "Tài nguyên nhị phân đã gỡ", + "Bit depth": "Độ sâu bit", + "Black & White": "Đen trắng", + "Blacks": "Vùng đen", + "Bleach Bypass": "Bleach Bypass", + "Bleached": "Tẩy", + "Blending": "Hòa trộn", + "Blue": "Lam", + "Blue Hour Boost": "Tăng giờ xanh", + "Blue Hue": "Hue xanh lam", + "Blue Luminance": "Độ sáng xanh lam", + "Blue Primary": "Xanh lam chính", + "Blue Saturation": "Độ bão hòa xanh lam", + "Blue channel": "Kênh xanh lam", + "Blue label": "Nhãn lam", + "Blue/Yellow Fringe": "Viền xanh lam/vàng", + "Blurry photos below the score are flagged as rejects.": "Ảnh mờ dưới điểm ngưỡng được gắn cờ đã loại.", + "Boundary Warp": "Biến dạng biên", + "Bright & Airy": "Sáng và thoáng", + "Bring All to Front": "Đưa tất cả lên trước", + "Bring the library up to date with this folder and the folders inside it:": "Cập nhật thư viện theo thư mục này và các thư mục bên trong:", + "Browse Folder": "Duyệt thư mục", + "Browse Folder…": "Duyệt thư mục…", + "Browse Profiles": "Duyệt hồ sơ", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Phiên bản trình duyệt (thử nghiệm), giữ thư viện an toàn (issue #107): Tệp → Sao lưu thư viện… tải catalog", + "Browse…": "Duyệt…", + "Brush": "Cọ", + "Brush Settings": "Cài đặt cọ", + "Brush Stroke": "Nét cọ", + "Build Previews": "Tạo bản xem trước", + "Build Smart Previews": "Tạo xem trước thông minh", + "Building previews": "Đang tạo bản xem trước", + "Building smart previews": "Đang tạo xem trước thông minh", + "By Date": "Theo ngày", + "By day (YYYY/YYYY-MM-DD)": "Theo ngày (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "Theo tháng (YYYY/YYYY-MM)", + "CPU threads": "Luồng CPU", + "Cache": "Bộ nhớ đệm", + "Calibration": "Hiệu chỉnh", + "Camera": "Máy ảnh", + "Camera Info": "Thông tin máy ảnh", + "Camera local tone mapping": "Ánh xạ tông cục bộ của máy ảnh", + "Camera make and model": "Hãng và model máy ảnh", + "Cameras and Cards": "Máy ảnh và thẻ nhớ", + "Cancel": "Hủy", + "Cancel AI Mask Model Download": "Hủy tải mô hình mặt nạ AI", + "Cancel Background Task": "Hủy tác vụ nền", + "Cancel Denoise": "Hủy khử nhiễu", + "Cancel Denoise Model Download": "Hủy tải mô hình khử nhiễu", + "Cancel Download": "Hủy tải", + "Cancel Face Model Download": "Hủy tải mô hình khuôn mặt", + "Cancel Interaction": "Hủy tương tác", + "Cancel Pending Photo Action": "Hủy thao tác ảnh đang chờ", + "Cancel Preview Build": "Hủy tạo bản xem trước", + "Cancel pending action": "Hủy thao tác đang chờ", + "Candidate": "Ảnh ứng viên", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Ảnh Canon CR2 từ EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II và 1D Mark IV (và các", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Tệp Canon CR3 hiển thị JPEG nhúng full-size (ví dụ 6960 × 4640 trên EOS R6 Mark III) thay vì", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Tệp Canon EOS R5 Mark II C-RAW từng mở ở chế độ \"chỉ xem trước\" (hơn một nửa, tùy", + "Caption": "Chú thích", + "Capture Date": "Ngày chụp", + "Capture date and time in your own format (directives below)": "Ngày giờ chụp theo định dạng của bạn (chỉ thị bên dưới)", + "Capture date as YYYYMMDD": "Ngày chụp dạng YYYYMMDD", + "Captured": "Đã chụp", + "Captured from": "Chụp từ", + "Captured until": "Chụp đến", + "Catalog Statistics": "Thống kê catalog", + "Catchlight": "Điểm sáng mắt", + "Change": "Đổi", + "Change Component Mode": "Đổi chế độ thành phần", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Ký tự không hợp lệ trong tên tệp (/ \\ : * ? \" < > |) thành -; tên đã tồn tại thêm -1, -2….", + "Charcoal": "Than", + "Check All": "Chọn tất cả", + "Check Output Path": "Kiểm tra đường dẫn xuất", + "Check the files of this type": "Chọn tệp loại này", + "Checking and installing the model…": "Đang kiểm tra và cài mô hình…", + "Checking names…": "Đang kiểm tra tên…", + "Checking…": "Đang kiểm tra…", + "Choose Another Library…": "Chọn thư viện khác…", + "Choose Denoise Model": "Chọn mô hình khử nhiễu", + "Choose Edit Settings to Copy": "Chọn cài đặt chỉnh sửa để sao chép", + "Choose Edit Settings to Copy…": "Chọn cài đặt chỉnh sửa để sao chép…", + "Choose Face Recognition Model": "Chọn mô hình nhận diện khuôn mặt", + "Choose Folder…": "Chọn thư mục…", + "Choose Profile…": "Chọn hồ sơ…", + "Choose a model below (Use) to start.": "Chọn một mô hình bên dưới (Dùng) để bắt đầu.", + "Choose a photo in the grid or filmstrip": "Chọn một ảnh trên lưới hoặc dải phim", + "Choose an album…": "Chọn một album…", + "Choose…": "Chọn…", + "Cinematic": "Điện ảnh", + "City": "Thành phố", + "Clarity": "Độ rõ", + "Clean Studio": "Studio sạch", + "Clear": "Xóa", + "Clear Cache": "Xóa bộ nhớ đệm", + "Clear Denoise Cache": "Xóa bộ nhớ đệm khử nhiễu", + "Clear Filters": "Xóa bộ lọc", + "Clear Guides": "Xóa đường dẫn", + "Clear History": "Xóa lịch sử", + "Clear Preview Cache": "Xóa bộ nhớ đệm xem trước", + "Clear Quick Collection": "Xóa Bộ sưu tập nhanh", + "Clear Recent Libraries": "Xóa thư viện gần đây", + "Clear Reference": "Xóa tham chiếu", + "Clear all": "Xóa tất cả", + "Clear cache": "Xóa bộ nhớ đệm", + "Clear form": "Xóa biểu mẫu", + "Clear rating": "Xóa xếp hạng", + "Clear rating and advance": "Xóa xếp hạng và chuyển tiếp", + "Cleared History": "Đã xóa lịch sử", + "Click Zoom Ratio": "Tỷ lệ thu phóng khi nhấp", + "Click a shortcut, then press the new keys (Esc cancels).": "Nhấp một phím tắt, rồi nhấn tổ hợp mới (Esc để hủy).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Bấm thẻ để chèn. Ví dụ cho IMG_0042.CR3 chụp 14 Jan 2026, 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Nhấp ảnh trên lưới để bật/tắt từ khóa; Esc dừng", + "Click the object to select it · ⌥-click leaves a part out": "Nhấp đối tượng để chọn · ⌥-nhấp để loại một phần", + "Click the photo to pick a colour · ⇧-click adds more": "Nhấp ảnh để chọn màu · ⇧-nhấp để thêm", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Nhấp ảnh để chọn màu; ⇧-nhấp để thêm (tối đa 5)", + "Click to clear these filters": "Nhấp để xóa các bộ lọc này", + "Click to restore · double-click to rename": "Nhấp để khôi phục · nhấp đúp để đổi tên", + "Click to type a value": "Nhấp để nhập giá trị", + "Clone": "Nhân bản vùng", + "Close": "Đóng", + "Close Panel": "Đóng bảng", + "Close Window": "Đóng cửa sổ", + "Close all": "Đóng tất cả", + "Close file": "Đóng tệp", + "Close panel": "Đóng bảng", + "Cloudy": "Nhiều mây", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, giữ ảnh và xóa chỉnh sửa.)", + "Collapse": "Thu gọn", + "Collapse All Stacks": "Thu gọn mọi chồng", + "Collapse Stack": "Thu gọn chồng", + "Color": "Màu", + "Color Grading": "Phân cấp màu", + "Color Label": "Nhãn màu", + "Color Label Names": "Tên nhãn màu", + "Color Label Sets": "Bộ nhãn màu", + "Color Mixer": "Bộ trộn màu", + "Color Noise Reduction": "Giảm nhiễu màu", + "Color Priority": "Ưu tiên màu", + "Color Range": "Dải màu", + "Color label cleared": "Đã xóa nhãn màu", + "Color space": "Không gian màu", + "Color space: sRGB (AVIF)": "Không gian màu: sRGB (AVIF)", + "Colors": "Màu sắc", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Lọc nhãn màu với nhiều nhãn cùng lúc; cây thư mục mở rộng trong Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Mặt nạ vùng màu: bấm ảnh để lấy mẫu. Vùng độ sáng: thanh phạm vi, độ mượt, bản đồ độ sáng.", + "Combine files…": "Gộp tệp…", + "Command Journal": "Nhật ký lệnh", + "Comments panel": "Bảng bình luận", + "Commits": "Commit", + "Company": "Công ty", + "Compare": "So sánh", + "Compare (Shift+C)": "So sánh (Shift+C)", + "Compare Before and After": "So sánh Trước và Sau", + "Compare needs at least two photos": "So sánh cần ít nhất hai ảnh", + "Component options": "Tùy chọn thành phần", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Nikon NEF nén (nén không mất và có mất, 12 và 14 bit — ví dụ D3200, D5100, D7000, D750, D850, Z 50)", + "Compression": "Nén", + "Concrete Cool": "Bê tông lạnh", + "Confirm before deleting": "Xác nhận trước khi xóa", + "Confirm before moving photos to Recently Deleted": "Xác nhận trước khi chuyển ảnh vào Đã xóa gần đây", + "Constrain Crop": "Giới hạn cắt", + "Contact Sheet PDF…": "PDF tờ tiếp xúc…", + "Content-Aware": "Nhận biết nội dung", + "Continue": "Tiếp tục", + "Continue Without Saving": "Tiếp tục không lưu", + "Contrast": "Tương phản", + "Contributors": "Người đóng góp", + "Convert to DNG": "Chuyển sang DNG", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Chuyển sang DNG, Sao chép dạng DNG, Photo Merge và xem trước thông minh không còn ghi thẳng vào tệp cuối (issue", + "Cool Fade": "Phai lạnh", + "Cool Morning": "Sáng sớm lạnh", + "Copy": "Sao chép", + "Copy After's Settings to Before": "Sao chép cài đặt Sau sang Trước", + "Copy Before to After": "Sao chép Trước sang Sau", + "Copy Before's Settings to After": "Sao chép cài đặt Trước sang Sau", + "Copy Edit Settings": "Sao chép cài đặt chỉnh sửa", + "Copy History Step Settings to Before": "Sao chép cài đặt bước lịch sử sang Trước", + "Copy Metadata": "Sao chép siêu dữ liệu", + "Copy Name": "Tên bản sao", + "Copy as DNG": "Sao chép thành DNG", + "Copy into the library (else use the files where they are)": "Sao chép vào thư viện (không thì dùng tệp tại chỗ)", + "Copy pages": "Sao chép trang", + "Copy the files (into the library's Originals/, or a folder you choose)": "Sao chép tệp (vào Originals/ của thư viện, hoặc thư mục bạn chọn)", + "Copy to": "Sao chép tới", + "Copy to Clipboard": "Sao chép vào bảng tạm", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Sao chép: tệp được sao chép vào thư mục bên dưới và thư viện dùng bản sao; bản gốc giữ nguyên.", + "Copyright": "Bản quyền", + "Copyright Info URL": "URL thông tin bản quyền", + "Copyright Status": "Trạng thái bản quyền", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Trạng thái bản quyền, điều khoản sử dụng quyền và URL thông tin bản quyền trong Info, thiết lập metadata và xuất.", + "Copyrighted": "Có bản quyền", + "Country": "Quốc gia", + "Create": "Tạo", + "Create Album": "Tạo album", + "Create Album…": "Tạo album…", + "Create Folder": "Tạo thư mục", + "Create Folder…": "Tạo thư mục…", + "Create Keyword": "Tạo từ khóa", + "Create Keyword Tag": "Tạo thẻ từ khóa", + "Create New Mask": "Tạo mặt nạ mới", + "Create PDF from clipboard": "Tạo PDF từ bảng tạm", + "Create PDF from file…": "Tạo PDF từ tệp…", + "Create PDF from images…": "Tạo PDF từ ảnh…", + "Create Preset": "Tạo thiết lập sẵn", + "Create Preset…": "Tạo thiết lập sẵn…", + "Create Proof Copy": "Tạo bản sao proof", + "Create Smart Album": "Tạo album thông minh", + "Create Smart Album from Filter…": "Tạo album thông minh từ bộ lọc…", + "Create Smart Album…": "Tạo album thông minh…", + "Create Stack": "Tạo chồng", + "Create Version": "Tạo phiên bản", + "Create Version from Step": "Tạo phiên bản từ bước", + "Create Virtual Copy": "Tạo bản sao ảo", + "Creator": "Người tạo", + "Creator metadata": "Siêu dữ liệu người tạo", + "Crisp Landscape": "Phong cảnh sắc nét", + "Crisp Vista": "Tầm nhìn sắc nét", + "Crop": "Cắt", + "Crop & Rotate": "Cắt và xoay", + "Crop & Rotate (C)": "Cắt và xoay (C)", + "Crop Aspect": "Tỷ lệ cắt", + "Crop, Rotate, Geometry": "Cắt, xoay, hình học", + "Cropped": "Đã cắt", + "Cross Process": "Cross Process", + "Culling": "Sàng lọc", + "Curve": "Đường cong", + "Custom": "Tùy chỉnh", + "Custom template…": "Mẫu tùy chỉnh…", + "Custom…": "Tùy chỉnh…", + "Cut": "Cắt", + "Cut pages": "Cắt trang", + "Cycle Crop Overlay": "Chuyển lớp phủ cắt", + "Cycle Crop Overlay Orientation": "Chuyển hướng lớp phủ cắt", + "Cycle Info Overlay": "Chuyển lớp phủ thông tin", + "Cycle Mask Overlay Color": "Chuyển màu lớp phủ mặt nạ", + "Cycle Mask Overlay Mode": "Chuyển chế độ lớp phủ mặt nạ", + "Cylindrical": "Trụ", + "Dark & Moody": "Tối và trầm", + "Dark Mode": "Chế độ tối", + "Dark Theme": "Chủ đề tối", + "Darken": "Làm tối", + "Darks": "Vùng tối vừa", + "Date": "Ngày", + "Date Groups": "Nhóm ngày", + "Day": "Ngày", + "Daylight": "Ánh sáng ban ngày", + "Decrease Brush Feather": "Giảm lông cọ", + "Decrease Brush Size": "Giảm kích thước cọ", + "Decrease Rating": "Giảm xếp hạng", + "Deep Sky": "Bầu trời sâu", + "Defringe": "Khử viền màu", + "Deghost Amount": "Độ khử bóng ma", + "Dehaze": "Khử sương mù", + "Delete": "Xóa", + "Delete (⌫)": "Xóa (⌫)", + "Delete Album": "Xóa album", + "Delete All Masks": "Xóa mọi mặt nạ", + "Delete Color Label Set": "Xóa bộ nhãn màu", + "Delete Component": "Xóa thành phần", + "Delete Export Preset": "Xóa thiết lập sẵn xuất", + "Delete Filter Preset": "Xóa thiết lập sẵn bộ lọc", + "Delete Imported Profile": "Xóa hồ sơ đã nhập", + "Delete Keyword": "Xóa từ khóa", + "Delete Keyword Set": "Xóa bộ từ khóa", + "Delete Keyword…": "Xóa từ khóa…", + "Delete Mask": "Xóa mặt nạ", + "Delete Metadata Preset": "Xóa thiết lập sẵn siêu dữ liệu", + "Delete Permanently": "Xóa vĩnh viễn", + "Delete Photo": "Xóa ảnh", + "Delete Photos": "Xóa ảnh", + "Delete Point Color": "Xóa Point Color", + "Delete Point Color Sample": "Xóa mẫu Point Color", + "Delete Point Curve Preset": "Xóa thiết lập sẵn đường cong điểm", + "Delete Preset": "Xóa thiết lập sẵn", + "Delete Red Eye": "Xóa mắt đỏ", + "Delete Red Eye Correction": "Xóa sửa mắt đỏ", + "Delete Spot": "Xóa điểm", + "Delete Version": "Xóa phiên bản", + "Delete all": "Xóa tất cả", + "Delete all spots": "Xóa mọi điểm", + "Delete mask": "Xóa mặt nạ", + "Delete them": "Xóa chúng", + "Denoise Model Downloads": "Tải mô hình khử nhiễu", + "Denoise Models": "Mô hình khử nhiễu", + "Denoise Photos": "Khử nhiễu ảnh", + "Denoise Pump": "Bơm khử nhiễu", + "Denoise Settings": "Cài đặt khử nhiễu", + "Denoise Status": "Trạng thái khử nhiễu", + "Denoise all photos with an Amount": "Khử nhiễu mọi ảnh có Độ mạnh", + "Density": "Mật độ", + "Depth Range": "Dải độ sâu", + "Descending": "Giảm dần", + "Describe": "Mô tả", + "Describe what to keep": "Mô tả phần giữ lại", + "Describe what to leave out": "Mô tả phần loại trừ", + "Describe what to select": "Mô tả phần cần chọn", + "Describe…": "Mô tả…", + "Description": "Mô tả", + "Deselect": "Bỏ chọn", + "Deselect All": "Bỏ chọn tất cả", + "Desert Heat": "Nóng sa mạc", + "Desert Warmth": "Ấm sa mạc", + "Desktop": "Màn hình chính", + "Detail": "Chi tiết", + "Detail (D)": "Chi tiết (D)", + "Details": "Chi tiết", + "Detect Faces": "Phát hiện khuôn mặt", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Phát hiện khuôn mặt (trình đơn Ảnh) cần nó: tải một lần, dung lượng nhỏ.", + "Develop": "Phát triển", + "Diagonal": "Chéo", + "Discard Preview Cache": "Hủy bộ nhớ đệm xem trước", + "Discarding smart previews": "Đang hủy xem trước thông minh", + "Display": "Màn hình", + "Display P3": "Display P3", + "Display Profile": "Hồ sơ màn hình", + "Display name": "Tên hiển thị", + "Display profile not used": "Không dùng hồ sơ màn hình", + "Display theme": "Chủ đề hiển thị", + "Distortion": "Méoc", + "Document properties…": "Thuộc tính tài liệu…", + "Don't enlarge": "Không phóng to", + "Done": "Xong", + "Download": "Tải xuống", + "Download AI Mask Model": "Tải mô hình mặt nạ AI", + "Download Denoise Model": "Tải mô hình khử nhiễu", + "Download Face Model": "Tải mô hình khuôn mặt", + "Download Place Names (GeoNames)": "Tải tên địa điểm (GeoNames)", + "Download the SAM 3 Model?": "Tải mô hình SAM 3?", + "Downloaded and checked. Installing…": "Đã tải và kiểm tra. Đang cài…", + "Downloading model": "Đang tải mô hình", + "Downloading place names": "Đang tải tên địa điểm", + "Downloads": "Tải xuống", + "Drag Crop Handle": "Kéo tay nắm cắt", + "Drag along the horizon; double-click for Auto": "Kéo theo đường chân trời; nhấp đúp để Tự động", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Kéo qua mắt trên ảnh; đồng tử bên trong được tìm tự động.", + "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.": "Kéo để đặt hue và độ bão hòa. Giữ Shift để giữ hue, Cmd/Ctrl để giữ độ bão hòa, Alt/Option để chỉnh tinh. Nhấp đúp để đặt lại.", + "Draw Guides": "Vẽ đường dẫn", + "Drop here for the top level": "Thả vào đây cho cấp cao nhất", + "Duplicate": "Nhân bản", + "Duplicate Component": "Nhân bản thành phần", + "Duplicate Mask": "Nhân bản mặt nạ", + "Duplicate and Invert Mask": "Nhân bản và đảo mặt nạ", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Mỗi / bắt đầu một cấp thư mục; thẻ được điền theo từng ảnh (cấp thiếu siêu dữ liệu thành \"unknown\").", + "Edge": "Cạnh", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "Cài đặt Edge và nhận lượt phóng to sắc nét hơn ở nền. Mô hình chạy trong LightCraft bằng pure Rust", + "Edit": "Chỉnh sửa", + "Edit (E)": "Chỉnh sửa (E)", + "Edit Capture Time": "Sửa thời gian chụp", + "Edit Capture Time…": "Sửa thời gian chụp…", + "Edit Color Label Names": "Sửa tên nhãn màu", + "Edit Color Label Names…": "Sửa tên nhãn màu…", + "Edit Copy for External Editor": "Sửa bản sao cho trình chỉnh sửa ngoài", + "Edit Date": "Ngày chỉnh sửa", + "Edit In": "Chỉnh trong", + "Edit Info": "Thông tin chỉnh sửa", + "Edit Keyword": "Sửa từ khóa", + "Edit Keyword Set": "Sửa bộ từ khóa", + "Edit Keyword Tag": "Sửa thẻ từ khóa", + "Edit Keyword Tag…": "Sửa thẻ từ khóa…", + "Edit Label Names": "Sửa tên nhãn", + "Edit Label Names…": "Sửa tên nhãn…", + "Edit Mask": "Sửa mặt nạ", + "Edit Mask Component": "Sửa thành phần mặt nạ", + "Edit Sections": "Các phần chỉnh sửa", + "Edit Set…": "Sửa bộ…", + "Edit Smart Album": "Chỉnh sửa album thông minh", + "Edit Smart Album…": "Chỉnh sửa album thông minh…", + "Edit Spot": "Sửa điểm", + "Edit in External Editor": "Chỉnh trong trình ngoài", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Chỉnh trong trình soạn ngoài (⇧⌘E): bản sao TIFF 16 bit, xếp chồng, làm mới khi bạn quay lại.", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Chỉnh HDR: vùng sáng trên trắng SDR, cho xuất và màn hình HDR", + "Edit in Other Editor": "Chỉnh trong trình khác", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Chỉnh trong trình khác (⌥⌘E) mở bản sao trong ứng dụng này (tên app trên macOS, đường dẫn chương trình ở nơi khác).", + "Edit in PhotoCraft": "Chỉnh trong PhotoCraft", + "Edit settings copied": "Đã sao chép cài đặt chỉnh sửa", + "Edited": "Đã chỉnh sửa", + "Edited or not": "Đã chỉnh hoặc chưa", + "Editing": "Đang chỉnh sửa", + "Effect": "Hiệu ứng", + "Effects": "Hiệu ứng", + "Empty Recently Deleted": "Dọn Đã xóa gần đây", + "Enable Lens Corrections": "Bật hiệu chỉnh ống kính", + "Enable Profile Corrections": "Bật hiệu chỉnh theo hồ sơ", + "End Interaction": "Kết thúc tương tác", + "English": "English", + "Enter Full Screen": "Vào toàn màn hình", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Nhập ngày dạng YYYY-MM-DD HH:MM:SS", + "Erase": "Xóa nét", + "Evaluate Face Recognition": "Đánh giá nhận diện khuôn mặt", + "Every EXIF, GPS and XMP field in the file": "Mọi trường EXIF, GPS và XMP trong tệp", + "Existing previews": "Bản xem trước hiện có", + "Exit": "Thoát", + "Expand": "Mở rộng", + "Expand All Stacks": "Mở rộng mọi chồng", + "Expand Stack": "Mở rộng chồng", + "Expand/Collapse Stack": "Mở rộng/Thu gọn chồng", + "Export": "Xuất", + "Export Album with Preset": "Xuất album với thiết lập sẵn", + "Export Album…": "Xuất album…", + "Export As…": "Xuất thành…", + "Export Contact Sheet PDF": "Xuất PDF tờ tiếp xúc", + "Export Containing Keywords": "Xuất từ khóa chứa", + "Export Group…": "Xuất nhóm…", + "Export Keywords": "Xuất từ khóa", + "Export Keywords…": "Xuất từ khóa…", + "Export Now": "Xuất ngay", + "Export Point Curve Presets": "Xuất thiết lập sẵn đường cong điểm", + "Export Point Curve Presets…": "Xuất thiết lập sẵn đường cong điểm…", + "Export Presets": "Thiết lập sẵn xuất", + "Export Presets…": "Xuất thiết lập sẵn…", + "Export Synonyms": "Xuất từ đồng nghĩa", + "Export User Presets…": "Xuất thiết lập sẵn của người dùng…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Tên tệp xuất dùng token Đổi tên ảnh ({title}, {seq:2}, {date:%Y-%m-%d}…), cùng {num}, {folder}, {lens}, {iso}, {rating}, {creator} mới.", + "Export stopped unexpectedly": "Xuất dừng đột ngột", + "Export with Preset": "Xuất với thiết lập sẵn", + "Export with Previous": "Xuất với lần trước", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Tệp xuất được ghi vào tệp tạm rồi đổi tên vào vị trí, nên đĩa đầy hoặc ổ đã rút", + "Exporting": "Đang xuất", + "Exporting contact sheet": "Đang xuất tờ tiếp xúc", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Xuất và kết xuất không bao giờ ghi đè bản gốc của ảnh (issue #93): xuất vào thư mục của ảnh với", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Xuất không bao giờ đen vì GPU (issue #78): kết xuất GPU hết tài nguyên thiết bị", + "Export…": "Xuất…", + "Exposure": "Phơi sáng", + "Exposure (shutter · aperture · ISO)": "Phơi sáng (màn trập · khẩu độ · ISO)", + "Extended Description": "Mô tả mở rộng", + "External Editing": "Chỉnh sửa ngoài", + "External Editor": "Trình chỉnh sửa ngoài", + "Face Boxes": "Khung khuôn mặt", + "Face Model Downloads": "Tải mô hình khuôn mặt", + "Face Models": "Mô hình khuôn mặt", + "Face Recognition On/Off": "Bật/tắt nhận diện khuôn mặt", + "Face boxes": "Khung khuôn mặt", + "Face detector": "Bộ phát hiện khuôn mặt", + "Face names written to XMP by Lightroom and other apps show up here.": "Tên khuôn mặt ghi vào XMP bởi Lightroom và ứng dụng khác hiện ở đây.", + "Face recognition": "Nhận diện khuôn mặt", + "Faces": "Khuôn mặt", + "Faces of a Person": "Khuôn mặt của một người", + "Faded Matte": "Mờ matte", + "Faded Slide": "Slide phai", + "Faded Urban": "Đô thị phai", + "Failed": "Thất bại", + "Failed its last test": "Lần thử gần nhất thất bại", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Xuất và nhập thẻ nhanh hơn, với cùng mức bảo vệ cho dữ liệu không thể tạo lại (issue #134): xuất,", + "Favorite Preset": "Thiết lập sẵn yêu thích", + "Favorite Profile": "Hồ sơ yêu thích", + "Favorites": "Yêu thích", + "Feather": "Lông", + "File": "Tệp", + "File & date": "Tệp và ngày", + "File Extension": "Phần mở rộng tệp", + "File Format": "Định dạng tệp", + "File Name": "Tên tệp", + "File Path": "Đường dẫn tệp", + "File Size": "Kích thước tệp", + "File Type": "Loại tệp", + "File name": "Tên tệp", + "File types:": "Loại tệp:", + "Filename": "Tên tệp", + "Files with the same content as another file in this import: only the first is imported.": "Tệp trùng nội dung với tệp khác trong lần nhập này: chỉ tệp đầu được nhập.", + "Fill": "Lấp đầy", + "Fill Edges": "Lấp cạnh", + "Fill Location": "Điền vị trí", + "Fill Location from GPS": "Điền vị trí từ GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Điền Thành phố, Bang và Quốc gia trống từ GPS (tên địa danh GeoNames ngoại tuyến)", + "Film": "Phim", + "Filmstrip": "Dải phim", + "Filmstrip (/)": "Dải phim (/)", + "Filter": "Bộ lọc", + "Filter Bar": "Thanh bộ lọc", + "Filter Keywords": "Lọc từ khóa", + "Filter Presets": "Thiết lập sẵn bộ lọc", + "Filter bar": "Thanh bộ lọc", + "Filter fields": "Trường bộ lọc", + "Find Blemishes": "Tìm khuyết điểm", + "Find Dust Spots": "Tìm điểm bụi", + "Find Missing Photos": "Tìm ảnh bị thiếu", + "Find Missing Photos…": "Tìm ảnh bị thiếu…", + "Find Similar Photos": "Tìm ảnh tương tự", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Tìm ảnh được thêm hoặc thiếu trong thư mục trên đĩa, và sidecar XMP bị ứng dụng khác sửa", + "Find tools and commands…": "Tìm công cụ và lệnh…", + "Finding faces": "Đang tìm khuôn mặt", + "Find…": "Tìm…", + "First": "Đầu tiên", + "First commit": "Commit đầu tiên", + "Fit": "Vừa khung", + "Fit (F)": "Vừa khung (F)", + "Fit on Screen": "Vừa màn hình", + "Fit to width": "Vừa chiều rộng", + "Fit visible": "Vừa phần hiển thị", + "Five stars": "Năm sao", + "Fix the marked rules to save this album.": "Sửa các quy tắc đã đánh dấu để lưu album này.", + "Fixed keys": "Phím cố định", + "Flag": "Gắn cờ", + "Flag as Pick": "Gắn cờ Đã chọn", + "Flag as Reject": "Gắn cờ Đã loại", + "Flagged as Pick": "Đã gắn cờ Đã chọn", + "Flagged as Reject": "Đã gắn cờ Đã loại", + "Flash": "Đèn flash", + "Flip H": "Lật ngang", + "Flip Horizontal": "Lật ngang", + "Flip Overlay (⇧O)": "Lật lớp phủ (⇧O)", + "Flip V": "Lật dọc", + "Flip Vertical": "Lật dọc", + "Float panel": "Bảng nổi", + "Flow": "Dòng cọ", + "Fluorescent": "Huỳnh quang", + "Focal Length": "Tiêu cự", + "Focal length": "Tiêu cự", + "Focus (assisted culling)": "Lấy nét (sàng lọc hỗ trợ)", + "Folder": "Thư mục", + "Folder name (renamed on disk; its photos follow)": "Tên thư mục (đổi tên trên đĩa; ảnh theo thư mục)", + "Folders": "Thư mục", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Thư mục và {date} dùng thời gian chụp; ảnh không có thì dùng ngày hôm nay.", + "Food": "Ẩm thực", + "Forget Unchanged Local Photos": "Quên ảnh cục bộ không đổi", + "Forget unchanged photos": "Quên ảnh không đổi", + "Form fields panel": "Bảng trường biểu mẫu", + "Format": "Định dạng", + "Forward": "Tiến", + "Found automatically": "Tìm tự động", + "Fr": "T6", + "Frame time": "Thời gian khung", + "Frame update": "Cập nhật khung", + "Free": "Tự do", + "Fresh & Bright": "Tươi sáng", + "Friday": "Thứ Sáu", + "Full": "Đầy đủ", + "Full Screen Preview": "Xem trước toàn màn hình", + "Full screen mode": "Chế độ toàn màn hình", + "Full size": "Kích thước đầy đủ", + "GPS Track Log": "Nhật ký theo dõi GPS", + "GPU Rendering": "Kết xuất GPU", + "GPU rendering": "Kết xuất GPU", + "General": "Chung", + "Generated demo photo": "Ảnh demo được tạo", + "Geometry": "Hình học", + "Get Develop Settings": "Lấy thiết lập chỉnh sửa", + "Global Hue": "Sắc độ toàn cục", + "Global Luminance": "Độ sáng toàn cục", + "Global Saturation": "Độ bão hòa toàn cục", + "Glossy": "Bóng", + "Go back to listing them by name": "Quay lại liệt kê theo tên", + "Go to History Step": "Đi tới bước Lịch sử", + "Golden": "Vàng", + "Golden Glow": "Ánh vàng", + "Golden Hour": "Giờ vàng", + "Grab bag": "Tạp hợp", + "Grain": "Hạt", + "Graphic": "Đồ họa", + "Graphics card": "Card đồ họa", + "Green": "Lục", + "Green Amount": "Lượng xanh lá", + "Green Hue": "Sắc xanh lá", + "Green Hue High": "Sắc xanh lá cao", + "Green Hue Low": "Sắc xanh lá thấp", + "Green Luminance": "Độ sáng xanh lá", + "Green Primary": "Màu gốc xanh lá", + "Green Saturation": "Độ bão hòa xanh lá", + "Green channel": "Kênh xanh lá", + "Green label": "Nhãn lục", + "Grid": "Lưới", + "Grid Info": "Thông tin lưới", + "Gritty Street": "Phố sần sùi", + "Group": "Nhóm", + "Group by Date: Automatic": "Nhóm theo ngày: Tự động", + "Group by Day": "Nhóm theo ngày", + "Group by Month": "Nhóm theo tháng", + "Group by Year": "Nhóm theo năm", + "Group by date": "Nhóm theo ngày", + "Group into Stack": "Gom thành chồng", + "Group name": "Tên nhóm", + "Guided": "Có hướng dẫn", + "Guided Upright": "Chỉnh thẳng có hướng dẫn", + "Guided Upright Guides": "Đường hướng dẫn chỉnh thẳng", + "HDR": "HDR", + "HDR Headroom": "Dự trữ HDR", + "HDR Merge": "Ghép HDR", + "HDR Merge Preview": "Xem trước ghép HDR", + "HDR Panorama Merge": "Ghép panorama HDR", + "HDR Panorama Merge Preview": "Xem trước ghép panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR với thiết lập lần trước", + "HDR Panoramas": "Panorama HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorama HDR", + "HDR with Last Settings": "HDR với thiết lập lần trước", + "HDR…": "HDR…", + "Has Edits": "Có chỉnh sửa", + "Has GPS": "Có GPS", + "Heal": "Sửa vết", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Sửa các điểm tối nhỏ, mềm do bụi cảm biến để lại trên vùng mịn", + "Height": "Chiều cao", + "Height (px)": "Chiều cao (px)", + "Help": "Trợ giúp", + "Hide LightCraft": "Ẩn LightCraft", + "Hide Mask": "Ẩn mặt nạ", + "Hide Others": "Ẩn ứng dụng khác", + "Hide This Section": "Ẩn mục này", + "Hides this shortcut only; the folder and its photos stay as they are": "Chỉ ẩn lối tắt này; thư mục và ảnh trong đó giữ nguyên", + "High": "Cao", + "High Contrast B&W": "B&W tương phản cao", + "Highlight": "Vùng sáng", + "Highlights": "Vùng sáng", + "Highlights Hue": "Sắc vùng sáng", + "Highlights Luminance": "Độ sáng vùng sáng", + "Highlights Saturation": "Độ bão hòa vùng sáng", + "Highlights split": "Tách vùng sáng", + "Histogram": "Biểu đồ histogram", + "History": "Lịch sử", + "History & Activity (Y)": "Lịch sử & Hoạt động (Y)", + "Home": "Thư mục Nhà", + "Horizontal": "Ngang", + "Horizontal inset (% of short edge)": "Lùi ngang (% cạnh ngắn)", + "Horizontal text": "Chữ ngang", + "Hours": "Giờ", + "How to install the model": "Cách cài mô hình", + "Hue": "Sắc độ", + "Hue Range": "Dải sắc độ", + "I have read these terms and accept them for my own use": "Tôi đã đọc các điều khoản này và chấp nhận cho việc sử dụng của mình", + "ISO Speed": "Tốc độ ISO", + "ISO speed": "Tốc độ ISO", + "If file exists": "Nếu tệp đã tồn tại", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Nếu thư viện không mở được khi khởi động (đang mở ở chương trình khác, không đọc được, trên ổ chưa kết nối,", + "Image": "Ảnh", + "Import": "Nhập", + "Import Date": "Ngày nhập", + "Import Keywords": "Nhập từ khóa", + "Import Keywords…": "Nhập từ khóa…", + "Import Lightroom Catalog": "Nhập catalog Lightroom", + "Import Lightroom Catalog…": "Nhập catalog Lightroom…", + "Import Photos": "Nhập ảnh", + "Import Photos…": "Nhập ảnh…", + "Import Point Curve Presets": "Nhập thiết lập sẵn đường cong điểm", + "Import Point Curve Presets…": "Nhập thiết lập sẵn đường cong điểm…", + "Import Presets": "Nhập thiết lập sẵn", + "Import Presets…": "Nhập thiết lập sẵn…", + "Import Profiles": "Nhập hồ sơ", + "Import Profiles & Presets…": "Nhập hồ sơ và thiết lập sẵn…", + "Import as new": "Nhập như mới", + "Import from Device": "Nhập từ thiết bị", + "Import from Folder…": "Nhập từ thư mục…", + "Import new photos": "Nhập ảnh mới", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Nhập ảnh bằng Tệp → Nhập ảnh… (Cmd+Shift+I), hoặc thả vào đây", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Nhập thiết lập sẵn từ trình soạn khác: preset XMP, tệp `.lrtemplate` cổ điển, \"DNG presets\" từ app di động và `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Nhập → Sao chép xác minh mọi bản sao, như Di chuyển (issue #96): mỗi tệp ghi thành tệp mới, đồng bộ ra đĩa và", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Nhập → Di chuyển: ảnh vào đích (cùng thư mục và đổi tên như Sao chép, ví dụ", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Nhập: sao chép vào thư mục bất kỳ, theo ngày / theo tháng / một thư mục, đổi tên khi nhập, thiết lập metadata, Sao chép dạng DNG.", + "Imported": "Đã nhập", + "Imported Photos": "Ảnh đã nhập", + "Importing": "Đang nhập", + "Importing Lightroom Catalog": "Đang nhập catalog Lightroom", + "Importing Lightroom catalog": "Đang nhập catalog 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).": "Nhập quét nguồn để tìm ảnh; không thêm thư mục vào Local (dùng Local → Duyệt thư mục… để làm việc trong thư mục mà không nhập).", + "In Color": "Màu", + "In Photo Grid and Square Grid: Flag as Pick and advance": "Trong Lưới ảnh và Lưới vuông: Gắn cờ Đã chọn và chuyển tiếp", + "In Photo Grid and Square Grid: expand or collapse the stack": "Trong Lưới ảnh và Lưới vuông: mở rộng hoặc thu gọn chồng", + "In Recently Deleted": "Trong Đã xóa gần đây", + "In Use": "Đang dùng", + "In a Stack": "Trong chồng", + "In library": "Trong thư viện", + "In memory — nothing is saved": "Trong bộ nhớ — chưa lưu gì", + "In use": "Đang dùng", + "In-memory session: these settings last until LightCraft quits.": "Phiên trong bộ nhớ: các cài đặt này chỉ tồn tại đến khi LightCraft thoát.", + "Include on Export": "Bao gồm khi xuất", + "Include selected photos": "Bao gồm ảnh đã chọn", + "Include subfolders": "Bao gồm thư mục con", + "Increase Brush Feather": "Tăng độ mềm cọ", + "Increase Brush Size": "Tăng kích thước cọ", + "Increase Rating": "Tăng xếp hạng", + "Index Faces": "Lập chỉ mục khuôn mặt", + "Index Faces in the Background": "Lập chỉ mục khuôn mặt nền", + "Info": "Thông tin", + "Info (I)": "Thông tin (I)", + "Info Overlay Off": "Tắt lớp phủ thông tin", + "Info Overlay: Exposure": "Lớp phủ thông tin: Phơi sáng", + "Info Overlay: File": "Lớp phủ thông tin: Tệp", + "Info overlay": "Lớp phủ thông tin", + "Insert at the cursor": "Chèn tại con trỏ", + "Inspect Denoise Model": "Kiểm tra mô hình khử nhiễu", + "Inspect Face Model File": "Kiểm tra tệp mô hình khuôn mặt", + "Inspect Lightroom Catalog": "Kiểm tra catalog Lightroom", + "Inspect Photo": "Kiểm tra ảnh", + "Inspecting Lightroom Catalog": "Đang kiểm tra catalog Lightroom", + "Install Denoise Model": "Cài mô hình khử nhiễu", + "Install Face Model": "Cài mô hình khuôn mặt", + "Install a denoise model to enable AI Denoise on this photo": "Cài mô hình khử nhiễu để bật AI Khử nhiễu cho ảnh này", + "Install from file…": "Cài từ tệp…", + "Install the SAM 3 Model": "Cài mô hình SAM 3", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Cài mô hình ONNX của bạn kèm denoise-model.json bên cạnh. Bản build này không tải mô hình.", + "Instant ’70s": "Thập niên 70 tức thì", + "Intent:": "Ý định:", + "Interface": "Giao diện", + "Interface language": "Ngôn ngữ giao diện", + "Intersect": "Giao", + "Intersect with": "Giao với", + "Into one folder": "Vào một thư mục", + "Invert": "Đảo", + "Invert Component": "Đảo thành phần", + "Invert Mask": "Đảo mặt nạ", + "It tests this album, so testing it back would loop": "Nó kiểm tra album này, nên kiểm tra ngược lại sẽ tạo vòng lặp", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Ảnh đã khử nhiễu chưa được tạo, và ảnh không tự tạo (Cài đặt, AI Denoise).", + "JPEG (Large)": "JPEG (Lớn)", + "JPEG (Small)": "JPEG (Nhỏ)", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG cùng tên (`IMG_0001.CR3` + `IMG_0001.JPG`) không còn dùng chung một sidecar: raw giữ `IMG_0001.xmp`,", + "Join the ArtCraft Discord": "Tham gia Discord ArtCraft", + "Join the ArtCraft Discord…": "Tham gia Discord ArtCraft…", + "Join the ArtCraft community on Discord": "Tham gia cộng đồng ArtCraft trên Discord", + "Keep in Local": "Giữ trong Local", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Giữ trên ổ còn dung lượng: xem trước thông minh khoảng 1 MB mỗi ảnh. Bộ nhớ đệm thumbnail nằm trong thư viện.", + "Keyboard Shortcuts": "Phím tắt", + "Keyboard shortcuts": "Phím tắt", + "Keyword": "Từ khóa", + "Keyword Count": "Số từ khóa", + "Keyword Info": "Thông tin từ khóa", + "Keyword List": "Danh sách từ khóa", + "Keyword Lists": "Danh sách từ khóa", + "Keyword Name": "Tên từ khóa", + "Keyword Painter": "Cọ từ khóa", + "Keyword Set": "Bộ từ khóa", + "Keyword Sets": "Bộ từ khóa", + "Keyword Suggestions": "Gợi ý từ khóa", + "Keyword Tag Options": "Tùy chọn thẻ từ khóa", + "Keyword to paint": "Từ khóa để tô", + "Keywords": "Từ khóa", + "Keywords & People": "Từ khóa và Người", + "Keywords (K)": "Từ khóa (K)", + "Keywords (⌥1–⌥9)": "Từ khóa (⌥1–⌥9)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Từ khóa bạn thêm hiện ở đây; ⌥1–⌥9 áp dụng chúng.", + "LUT-based": "Dựa trên LUT", + "Label": "Nhãn", + "Label Set": "Bộ nhãn", + "Landscape": "Ngang", + "Landscape (wide)": "Ngang (rộng)", + "Language": "Ngôn ngữ", + "Last": "Cuối", + "Last GPU fallback": "Dự phòng GPU lần trước", + "Last commit": "Commit cuối", + "Last loupe render": "Lần kết xuất kính lúp gần nhất", + "Last view": "Lần xem trước", + "Lateral Chromatic Aberration": "Quang sai sắc ngang", + "Layer": "Lớp", + "Layers": "Lớp", + "Leave them": "Giữ nguyên", + "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.": "Trái: dải chuẩn (SDR) đến trắng SDR. Phải: dải HDR, mỗi dải một stop trên trắng SDR (đường cam: dự trữ HDR). Khi bật Hình dung dải HDR, các dải lấy màu của nó.", + "Lens": "Ống kính", + "Lens Blur": "Làm mờ ống kính", + "Lens Corrections": "Hiệu chỉnh ống kính", + "Level": "Mức", + "Library": "Thư viện", + "Library Folders": "Thư mục thư viện", + "Library Info": "Thông tin thư viện", + "Library Originals": "Bản gốc thư viện", + "Library Preferences": "Tùy chọn thư viện", + "Library State": "Trạng thái thư viện", + "Library name": "Tên thư viện", + "Library saved": "Đã lưu thư viện", + "Licence:": "Giấy phép:", + "Light": "Ánh sáng", + "Light Mode": "Chế độ sáng", + "Light Theme": "Chủ đề sáng", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 và cũ hơn từ chối (\"unsupported format … v2\").", + "LightCraft Default": "Mặc định LightCraft", + "LightCraft Help": "Trợ giúp LightCraft", + "LightCraft Preset": "Thiết lập sẵn LightCraft", + "LightCraft Presets": "Thiết lập sẵn LightCraft", + "LightCraft Website": "Trang web LightCraft", + "LightCraft could not start": "LightCraft không thể khởi động", + "LightCraft couldn't find where to keep your library.": "LightCraft không tìm được nơi lưu thư viện của bạn.", + "LightCraft couldn't open the library at": "LightCraft không mở được thư viện tại", + "LightCraft does not know this model, so it assumed:": "LightCraft không biết mô hình này, nên đã giả định:", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft không còn sự cố khi khởi động trên PC Windows có driver Vulkan lỗi (issue #136, ví dụ một số Intel UHD", + "LightCraft on GitHub": "LightCraft trên GitHub", + "LightCraft website": "Trang web LightCraft", + "LightCraft — Second Window": "LightCraft — Cửa sổ thứ hai", + "Lightroom Classic Catalog": "Catalog Lightroom Classic", + "Lightroom import cancelled": "Đã hủy nhập Lightroom", + "Lightroom inspection cancelled": "Đã hủy kiểm tra Lightroom", + "Lights": "Đèn", + "Lights Out": "Tắt đèn", + "Limit": "Giới hạn", + "Limit file size (KB, 0 = off)": "Giới hạn kích thước tệp (KB, 0 = tắt)", + "Line delta": "Chênh lệch dòng", + "Linear": "Tuyến tính", + "Linear Gradient": "Chuyển sắc tuyến tính", + "Lines +/−": "Dòng +/−", + "Lines added": "Dòng đã thêm", + "Lines deleted": "Dòng đã xóa", + "List Albums": "Liệt kê album", + "List Develop Controls": "Liệt kê điều khiển chỉnh sửa", + "List History": "Liệt kê Lịch sử", + "List Presets": "Liệt kê thiết lập sẵn", + "List Profiles": "Liệt kê hồ sơ", + "List this folder in Local from now on": "Liệt kê thư mục này trong Cục bộ từ nay", + "Loading preview…": "Đang tải xem trước…", + "Local": "Cục bộ", + "Local folders": "Thư mục cục bộ", + "Local library — no cloud account needed": "Thư viện cục bộ — không cần tài khoản đám mây", + "Locate Missing Files…": "Định vị tệp bị thiếu…", + "Locate Missing File…": "Định vị tệp bị thiếu…", + "Locate Photo": "Định vị ảnh", + "Location": "Vị trí", + "Lock": "Khóa", + "Lock Crop Aspect": "Khóa tỷ lệ cắt", + "Locked": "Đã khóa", + "Long Edge": "Cạnh dài", + "Looking for changes": "Đang tìm thay đổi", + "Looking for photos…": "Đang tìm ảnh…", + "Looking up places": "Đang tra địa danh", + "Lossless": "Không mất dữ liệu", + "Lossy DNG files and Smart Previews open as raw photos.": "Tệp DNG có mất dữ liệu và xem trước thông minh mở như ảnh RAW.", + "Low": "Thấp", + "Luminance": "Độ sáng", + "Luminance Range": "Dải độ sáng", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Giao diện Luminar: tệp `.lmp` và bộ sưu tập `.mplumpack` nhập thành thiết lập sẵn (nhóm theo bộ sưu tập); các thanh trượt", + "Lush Greens": "Xanh lá tươi", + "Magenta": "Đỏ tím", + "Magenta Hue": "Sắc đỏ tím", + "Magenta Luminance": "Độ sáng đỏ tím", + "Magenta Saturation": "Độ bão hòa đỏ tím", + "Make Candidate the Select": "Đặt ảnh ứng viên thành ảnh được chọn", + "Make it now": "Tạo ngay", + "Make the denoised picture of the photos I am looking at": "Tạo ảnh đã khử nhiễu cho ảnh tôi đang xem", + "Manual": "Thủ công", + "Marquee zoom": "Thu phóng vùng chọn", + "Mask Adjustment": "Điều chỉnh mặt nạ", + "Masking": "Mặt nạ", + "Masking (M)": "Mặt nạ (M)", + "Masks": "Mặt nạ", + "Match": "Khớp", + "Match Total Exposures": "Khớp tổng phơi sáng", + "Matrix/TRC": "Matrix/TRC", + "Matte": "Mờ", + "Matte Soft": "Mờ mềm", + "Med": "TB", + "Medium Contrast": "Tương phản trung bình", + "Megapixels": "Megapixel", + "Memory Budget": "Ngân sách bộ nhớ", + "Memory Usage": "Dung lượng bộ nhớ", + "Memory budget": "Ngân sách bộ nhớ", + "Memory for caches": "Bộ nhớ cho bộ nhớ đệm", + "Menu": "Trình đơn", + "Merge": "Ghép", + "Merge Keywords": "Gộp từ khóa", + "Merge into…": "Ghép vào…", + "Merged": "Đã ghép", + "Merged PRs": "PR đã gộp", + "Merging": "Đang ghép", + "Merging…": "Đang ghép…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Điều khoản của Meta, không phải của LightCraft. Tải xuống nghĩa là chấp nhận chúng.", + "Metadata": "Siêu dữ liệu", + "Metadata Preset": "Thiết lập sẵn siêu dữ liệu", + "Metadata Presets": "Thiết lập sẵn siêu dữ liệu", + "Metadata preset": "Thiết lập sẵn siêu dữ liệu", + "Metadata read": "Đã đọc siêu dữ liệu", + "Midnight": "Nửa đêm", + "Midpoint": "Điểm giữa", + "Midtones": "Vùng trung tính", + "Midtones Hue": "Sắc vùng trung tính", + "Midtones Luminance": "Độ sáng vùng trung tính", + "Midtones Saturation": "Độ bão hòa vùng trung tính", + "Midtones split": "Tách vùng trung tính", + "Minimize": "Thu nhỏ", + "Missing Photos": "Ảnh bị thiếu", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Metadata thiếu ({camera}, {title}…) để khoảng trống; tên rỗng giữ tên gốc.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Ảnh thiếu chuyển sang Đã xóa gần đây và có thể khôi phục; không đụng tệp trên đĩa.", + "Misty Morning": "Sáng sương mù", + "Mo": "T2", + "Mode": "Chế độ", + "Model": "Mô hình", + "Models": "Mô hình", + "Modified Date": "Ngày sửa đổi", + "Monday": "Thứ Hai", + "Monitor profile": "Hồ sơ màn hình", + "Mono Red Filter": "Lọc đỏ đơn sắc", + "Mono Rich": "Đơn sắc đậm", + "Mono Soft": "Đơn sắc mềm", + "Monochrome": "Đơn sắc", + "Month": "Tháng", + "Moody": "U ám", + "Moody Portrait": "Chân dung u ám", + "More": "Thêm", + "More preset options": "Thêm tùy chọn thiết lập sẵn", + "Move": "Di chuyển", + "Move Album": "Di chuyển album", + "Move Down": "Di chuyển xuống", + "Move Down in Stack": "Di chuyển xuống trong chồng", + "Move Folder": "Di chuyển thư mục", + "Move Folder To…": "Di chuyển thư mục tới…", + "Move Keyword": "Di chuyển từ khóa", + "Move Mask": "Di chuyển mặt nạ", + "Move Preset to Group": "Chuyển thiết lập sẵn vào nhóm", + "Move Preset to New Group": "Chuyển thiết lập sẵn vào nhóm mới", + "Move Selected Photos Here": "Di chuyển ảnh đã chọn vào đây", + "Move Sidebar Section": "Di chuyển mục thanh bên", + "Move Up": "Di chuyển lên", + "Move Up in Stack": "Di chuyển lên trong chồng", + "Move in Stack": "Di chuyển trong chồng", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Di chuyển tệp (vào Originals/ của thư viện hoặc thư mục bạn chọn), gỡ khỏi nguồn", + "Move them": "Di chuyển chúng", + "Move to": "Chuyển tới", + "Move to Folder": "Chuyển vào thư mục", + "Move to Folder…": "Chuyển vào thư mục…", + "Move to Group": "Chuyển vào nhóm", + "Move to group": "Chuyển vào nhóm", + "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.": "Di chuyển: tệp chuyển vào thư mục bên dưới; mỗi bản gốc (và sidecar XMP) chỉ bị gỡ khỏi nguồn sau khi bản sao được xác minh. Trùng lặp và tệp lỗi giữ nguyên vị trí.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Chuyển mọi ảnh nhập từ đĩa này sang Đã xóa gần đây; không đụng tệp", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Chuyển tệp và sidecar XMP vào thư mục này; không bao giờ ghi đè", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Chuyển ảnh nhập từ thư mục này sang Đã xóa gần đây; không đụng tệp", + "Muted": "Dịu", + "Muted Film": "Phim dịu", + "My Photos": "Ảnh của tôi", + "My Photos Panel": "Bảng Ảnh của tôi", + "Name": "Tên", + "Name (A–Z)": "Tên (A–Z)", + "Name Face": "Đặt tên khuôn mặt", + "Name Faces": "Đặt tên khuôn mặt", + "Name of the folder the original is in": "Tên thư mục chứa bản gốc", + "Named": "Đã đặt tên", + "Named People": "Người đã đặt tên", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Tên hiện trong menu nhãn, thanh lọc và bảng Info, và ghi vào XMP. Trống = tên màu mặc định.", + "Navigator": "Điều hướng", + "Needs Work": "Cần chỉnh", + "Neon Dusk": "Hoàng hôn neon", + "Neon Night": "Đêm neon", + "Nested group": "Nhóm lồng nhau", + "Neutral": "Trung tính", + "Never": "Không bao giờ", + "New": "Mới", + "New Album": "Album mới", + "New Album…": "Album mới…", + "New Folder on Disk": "Thư mục mới trên đĩa", + "New Folder on Disk…": "Thư mục mới trên đĩa…", + "New Folder…": "Thư mục mới…", + "New Group…": "Nhóm mới…", + "New Library": "Thư viện mới", + "New Library: Choose Where to Put It": "Thư viện mới: Chọn nơi đặt", + "New Library…": "Thư viện mới…", + "New Smart Album": "Album thông minh mới", + "New Smart Album from Filter…": "Album thông minh mới từ bộ lọc…", + "New Smart Album…": "Album thông minh mới…", + "New album": "Album mới", + "New album…": "Album mới…", + "New blank PDF": "PDF trống mới", + "New name": "Tên mới", + "New time": "Thời gian mới", + "New…": "Mới…", + "Next Appearance Mode": "Chế độ giao diện tiếp theo", + "Next History Step": "Bước lịch sử sau", + "Next Photo": "Ảnh tiếp theo", + "Next up for AI Denoise…": "Tiếp theo cho AI Khử nhiễu…", + "Night Blue": "Xanh đêm", + "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★) dùng nén intoPIX TicoRAW. LightCraft hiện chưa có giấy phép codec này, nên chỉ có JPEG xem trước nhúng của máy, không có dữ liệu cảm biến raw. Để chỉnh raw, hãy chụp NEF nén Lossless hoặc chuyển HE sang DNG bằng bộ chuyển có giấy phép.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF bắt đầu từ màu và tông khớp JPEG của máy, như Sony ARW, thay vì tông mờ,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon chia hai nửa nén khác nhau vẫn dùng xem trước tạm thời.)", + "No": "Không", + "No Camera or Card Found": "Không tìm thấy máy ảnh hoặc thẻ", + "No Date Groups": "Không có nhóm ngày", + "No Label": "Không nhãn", + "No Recent Libraries": "Không có thư viện gần đây", + "No Stars": "Không sao", + "No Tool": "Không có công cụ", + "No albums match": "Không có album khớp", + "No albums yet": "Chưa có album", + "No automatic versions.": "Không có phiên bản tự động.", + "No camera": "Không có máy ảnh", + "No contributor data was built into this copy.": "Bản này không có dữ liệu cộng tác viên.", + "No date": "Không có ngày", + "No dust spots found": "Không tìm thấy điểm bụi", + "No edits yet.": "Chưa có chỉnh sửa.", + "No exposure information": "Không có thông tin phơi sáng", + "No filter presets yet": "Chưa có thiết lập sẵn bộ lọc", + "No folders to browse": "Không có thư mục để duyệt", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Không tìm thấy bộ xử lý đồ họa: LightCraft kết xuất trên CPU, chậm hơn nhiều. Chi tiết trong Trợ giúp → Thông tin hệ thống.", + "No imported folders yet": "Chưa có thư mục đã nhập", + "No keywords are exported": "Không xuất từ khóa nào", + "No keywords match": "Không có từ khóa khớp", + "No keywords yet": "Chưa có từ khóa", + "No lens data embedded in this file (DNG lens opcodes only).": "Không có dữ liệu ống kính nhúng trong tệp này (chỉ opcode ống kính DNG).", + "No masks yet. Choose a mask type above.": "Chưa có mặt nạ. Chọn loại mặt nạ ở trên.", + "No matching commands": "Không có lệnh khớp", + "No matching photos": "Không có ảnh khớp", + "No metadata found": "Không tìm thấy siêu dữ liệu", + "No model credits were built into this copy.": "Bản này không có ghi công mô hình.", + "No named people in these photos": "Không có người được đặt tên trong các ảnh này", + "No named people yet": "Chưa có người được đặt tên", + "No new presets": "Không có thiết lập sẵn mới", + "No photo has it; the keywords below it go too.": "Không ảnh nào có nó; các từ khóa bên dưới cũng bị gỡ.", + "No photo selected": "Chưa chọn ảnh", + "No photos": "Không có ảnh", + "No photos found": "Không tìm thấy ảnh", + "No photos in this folder": "Không có ảnh trong thư mục này", + "No photos match the active filters": "Không có ảnh khớp bộ lọc đang bật", + "No photos match the filters (View → Clear Filters)": "Không có ảnh khớp bộ lọc (Xem → Xóa bộ lọc)", + "No photos yet": "Chưa có ảnh", + "No raw photos yet: cameras appear here once their photos are in the library.": "Chưa có ảnh raw: máy ảnh hiện ở đây khi ảnh của chúng đã có trong thư viện.", + "No reference photo": "Không có ảnh tham chiếu", + "No usable GPU found: rendering on the CPU": "Không tìm thấy GPU dùng được: kết xuất trên CPU", + "No versions yet. Create one to keep this look.": "Chưa có phiên bản. Tạo một phiên bản để giữ diện mạo này.", + "Noise": "Nhiễu", + "Noise Reduction": "Giảm nhiễu", + "Non-raw photos": "Ảnh không phải RAW", + "None": "Không có", + "None (the display is treated as sRGB)": "Không (màn hình được coi là sRGB)", + "Not Now": "Không phải lúc này", + "Not saved": "Chưa lưu", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Không tải gì cho đến khi bạn chấp nhận. Mô hình chỉ lưu trên máy này; LightCraft không tải lên hay chia sẻ.", + "Nothing to compare": "Không có gì để so sánh", + "Nothing to survey": "Không có gì để khảo sát", + "Nudge Develop Value": "Điều chỉnh nhẹ giá trị chỉnh sửa", + "OK": "OK", + "ONNX model": "Mô hình ONNX", + "Object": "Đối tượng", + "Object Mask": "Mặt nạ đối tượng", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Mặt nạ Object và Describe dùng SAM 3, mô hình phân đoạn của Meta. Không thuộc LightCraft, và mọi thứ khác vẫn hoạt động không cần nó.", + "October 2026": "Tháng 10 năm 2026", + "Off": "Tắt", + "Offset X": "Độ lệch X", + "Offset Y": "Độ lệch Y", + "On NixOS, start it from `nix develop`.": "Trên NixOS, hãy khởi chạy từ `nix develop`.", + "Only": "Chỉ", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Chỉ ảnh có lượng AI Denoise trên 0 mới được tạo. Xuất luôn tạo nếu thiếu.", + "Opacity": "Độ mờ", + "Opacity (%)": "Độ mờ (%)", + "Open Library": "Mở thư viện", + "Open Library…": "Mở thư viện…", + "Open Log Folder": "Mở thư mục nhật ký", + "Open Original in Other Editor (RAW, no copy)": "Mở bản gốc trong trình khác (RAW, không sao chép)", + "Open Recent Library": "Mở thư viện gần đây", + "Open in": "Mở trong", + "Open in Detail": "Mở chi tiết", + "Open page": "Mở trang", + "Open the model's page": "Mở trang mô hình", + "Open the place in OpenStreetMap": "Mở địa điểm trên OpenStreetMap", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Mở trang của mô hình trong trình duyệt, để đọc hoặc tự tải tệp.", + "Open…": "Mở…", + "Optics": "Quang học", + "Optimize Library": "Tối ưu thư viện", + "Optional name": "Tên tùy chọn", + "Options": "Tùy chọn", + "Orange": "Cam", + "Orange Hue": "Sắc cam", + "Orange Luminance": "Độ sáng cam", + "Orange Saturation": "Độ bão hòa cam", + "Orientation": "Hướng", + "Original": "Gốc", + "Original + Settings": "Gốc + Thiết lập", + "Original extension without the dot": "Phần mở rộng gốc không có dấu chấm", + "Original file name without its extension": "Tên tệp gốc không có phần mở rộng", + "Original files": "Tệp gốc", + "Original offline · editing the smart preview": "Gốc ngoại tuyến · đang chỉnh xem trước thông minh", + "Original offline · no smart preview": "Gốc ngoại tuyến · không có xem trước thông minh", + "Originals": "Bản gốc", + "Other editor": "Trình chỉnh sửa khác", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Ảnh khác (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Khác…", + "Overlay": "Lớp phủ", + "Overwrite": "Ghi đè", + "Pages": "Trang", + "Paint": "Tô", + "Paint only areas like the one under the brush": "Chỉ tô vùng giống vùng dưới cọ", + "Paint over a distraction on the photo to remove it.": "Tô lên vật gây mất tập trung trên ảnh để xóa nó.", + "Paint overlay": "Lớp phủ tô", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Raw Panasonic và Leica (RW2, RWL và tệp RAW cũ, DMC-LX1 đến DC-S1R II) develop từ dữ liệu raw trong", + "Panels": "Các bảng", + "Panorama": "Toàn cảnh", + "Panorama Merge": "Ghép panorama", + "Panorama Merge Preview": "Xem trước ghép panorama", + "Panorama with Last Settings": "Panorama với thiết lập lần trước", + "Panoramas": "Panorama", + "Panorama…": "Toàn cảnh…", + "Paper": "Giấy", + "Parametric curve": "Đường cong tham số", + "Paste": "Dán", + "Paste Edit Settings": "Dán cài đặt chỉnh sửa", + "Paste Metadata": "Dán siêu dữ liệu", + "Paste Selected Settings": "Dán thiết lập đã chọn", + "Paste Selected Settings…": "Dán cài đặt đã chọn…", + "Paste Settings": "Dán thiết lập", + "Paste Settings from Previous": "Dán thiết lập từ ảnh trước", + "Paste from Previous": "Dán từ ảnh trước", + "Paste pages": "Dán trang", + "Pastel Haze": "Sương pastel", + "Pending photo action cancelled": "Đã hủy thao tác ảnh đang chờ", + "Pending photo action cancelled because its library or photo changed": "Đã hủy thao tác ảnh đang chờ vì thư viện hoặc ảnh đã thay đổi", + "People": "Người", + "People Count": "Số người", + "Percent": "Phần trăm", + "Percentage": "Phần trăm", + "Perceptual": "Cảm nhận", + "Performance": "Hiệu năng", + "Person": "Người", + "Perspective": "Phối cảnh", + "Pet Eye": "Mắt thú cưng", + "Pet Eye Catchlight": "Điểm sáng mắt thú cưng", + "Photo": "Ảnh", + "Photo Above": "Ảnh bên trên", + "Photo Below": "Ảnh bên dưới", + "Photo Grid": "Lưới ảnh", + "Photo Grid (G)": "Lưới ảnh (G)", + "Photo Merge": "Ghép ảnh", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "Menu Ảnh. Dải phim cũng có menu ngữ cảnh ảnh. Thêm lại tệp đang ở Đã xóa gần đây không", + "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.": "Ảnh → Chỉnh trong → Chỉnh trong PhotoCraft (⇧⌘E, hoặc nhấp phải ảnh) kết xuất bản sao kèm chỉnh sửa, xếp chồng với bản gốc và mở trong PhotoCraft. Lưu ở đó thì bản sao cập nhật tại đây. Dạng PSD, PhotoCraft lưu thẳng lại. Để trống vị trí trừ khi dùng bản portable.", + "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).": "Ảnh → Chỉnh trong trình soạn ngoài (⇧⌘E) kết xuất bản sao TIFF 16 bit, xếp chồng với bản gốc và mở tại đây (tên app trên macOS, đường dẫn chương trình ở nơi khác).", + "PhotoCraft": "PhotoCraft", + "Photos": "Ảnh", + "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.": "Ảnh hiện qua hồ sơ ICC màn hình, nên màu đúng trên màn hình gam rộng và đã hiệu chuẩn, và khung Chi tiết dùng toàn gam của màn hình. Histogram, bộ nhớ đệm xem trước và xuất không bị ảnh hưởng.", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Ảnh trong Đã xóa gần đây có thể khôi phục trong app: nhấp phải → Khôi phục (hoặc Xóa vĩnh viễn), cũng trong", + "Photos per bracket (0 = auto)": "Ảnh mỗi nhóm bracket (0 = tự động)", + "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.": "Ảnh chỉ xem trong Local mà chưa thêm hay sửa sẽ rời catalog khi thư mục không được duyệt trong thời gian này (kiểm tra khi mở thư viện). Tệp vẫn trên đĩa; duyệt lại thư mục sẽ thấy chúng.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Ảnh vẫn ở Tất cả ảnh. Xóa thư mục cũng xóa album bên trong. Bạn có thể hoàn tác.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Ảnh đến thư mục này (tethering, máy quét, app đồng bộ) được thêm ngay khi hoàn tất.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Ảnh); nhập (cả kéo thả), Tìm ảnh thiếu, Tạo / Hủy xem trước thông minh, xem trước Đổi tên,", + "Pick": "Đã chọn", + "Pick Flag": "Cờ chọn", + "Pick a colour on the photo with the eyedropper.": "Chọn màu trên ảnh bằng ống nhỏ giọt.", + "Pick a date": "Chọn ngày", + "Pick the sharpest photo of each burst": "Chọn ảnh nét nhất của mỗi chuỗi", + "Picked": "Đã chọn", + "Picks": "Ảnh đã chọn", + "Pictures": "Hình ảnh", + "Pixels": "Pixel", + "Place Names Status": "Trạng thái tên địa danh", + "Place names download cancelled": "Đã hủy tải tên địa danh", + "Place names downloaded": "Đã tải tên địa danh", + "Point Color": "Màu điểm", + "Point Curve": "Đường cong điểm", + "Point Curve Presets": "Thiết lập sẵn đường cong điểm", + "Point curve": "Đường cong điểm", + "Portrait": "Dọc", + "Portrait (tall)": "Dọc (cao)", + "Position": "Vị trí", + "Preferences": "Tùy chọn", + "Preferences…": "Tùy chọn…", + "Prepare AI Masks": "Chuẩn bị mặt nạ AI", + "Preset": "Thiết lập sẵn", + "Preset name": "Tên thiết lập sẵn", + "Presets": "Thiết lập sẵn", + "Presets & Profiles": "Thiết lập sẵn & Hồ sơ", + "Presets (Shift+P)": "Thiết lập sẵn (Shift+P)", + "Presets and profiles": "Thiết lập sẵn và hồ sơ", + "Presets ▾": "Thiết lập sẵn ⌄", + "Press keys…": "Nhấn phím…", + "Preview": "Xem trước", + "Preview Build Progress": "Tiến độ tạo xem trước", + "Preview only": "Chỉ xem trước", + "Preview only: editing the camera's embedded JPEG": "Chỉ xem trước: đang chỉnh JPEG nhúng của máy ảnh", + "Preview size": "Kích thước xem trước", + "Preview unavailable": "Không có xem trước", + "Previews": "Xem trước", + "Previous History Step": "Bước lịch sử trước", + "Previous Photo": "Ảnh trước", + "Primaries (x, y)": "Màu gốc (x, y)", + "Print": "In", + "Print…": "In…", + "Processor": "Bộ xử lý", + "Profile": "Hồ sơ", + "Profile Browser": "Trình duyệt hồ sơ", + "Profile Distortion": "Hồ sơ méo hình", + "Profile Menu": "Menu hồ sơ", + "Profile Vignetting": "Hồ sơ tối góc", + "Profile:": "Hồ sơ:", + "Profiles": "Hồ sơ", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Hồ sơ gán trong cài đặt màu máy tính thường nằm ở ~/.local/share/icc.", + "Progressive": "Tiến dần", + "Projection": "Chiếu", + "Proof copy created": "Đã tạo bản proof", + "Properties": "Thuộc tính", + "Public Domain": "Phạm vi công cộng", + "Public domain": "Phạm vi công cộng", + "Pupil Size": "Kích thước đồng tử", + "Purge Unused Keywords": "Dọn từ khóa không dùng", + "Purple": "Tím", + "Purple Amount": "Lượng tím", + "Purple Hue": "Sắc tím", + "Purple Hue High": "Sắc tím cao", + "Purple Hue Low": "Sắc tím thấp", + "Purple Luminance": "Độ sáng tím", + "Purple Saturation": "Độ bão hòa tím", + "Put New Keywords Inside": "Đặt từ khóa mới vào trong", + "Put New Keywords Inside This Keyword": "Đặt từ khóa mới vào trong từ khóa này", + "Put the locations you removed from Local back (no files change)": "Đưa các vị trí đã gỡ khỏi Local trở lại (không đổi tệp)", + "Quality": "Chất lượng", + "Query Photos": "Truy vấn ảnh", + "Quick Collection": "Bộ sưu tập nhanh", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Quick Collection và album đích (B trên lưới), bộ từ khóa (⌥1–⌥9), bộ nhãn màu.", + "Quick Develop": "Chỉnh nhanh", + "Quiet Green": "Xanh êm", + "Quit": "Thoát", + "Quit Anyway": "Vẫn thoát", + "Quit LightCraft": "Thoát LightCraft", + "Quit while tasks are running?": "Thoát khi tác vụ đang chạy?", + "Quit with unsaved changes?": "Thoát khi còn thay đổi chưa lưu?", + "RAW decoding": "Giải mã RAW", + "Radial": "Hướng tâm", + "Radial Gradient": "Chuyển sắc hướng tâm", + "Radius": "Bán kính", + "Random": "Ngẫu nhiên", + "Range": "Dải", + "Rate ★": "Xếp hạng ★", + "Rate ★ and advance": "Xếp hạng ★ và chuyển tiếp", + "Rate ★★": "Xếp hạng ★★", + "Rate ★★ and advance": "Xếp hạng ★★ và chuyển tiếp", + "Rate ★★★": "Xếp hạng ★★★", + "Rate ★★★ and advance": "Xếp hạng ★★★ và chuyển tiếp", + "Rate ★★★★": "Xếp hạng ★★★★", + "Rate ★★★★ and advance": "Xếp hạng ★★★★ và chuyển tiếp", + "Rate ★★★★★": "Xếp hạng ★★★★★", + "Rate ★★★★★ and advance": "Xếp hạng ★★★★★ và chuyển tiếp", + "Rating": "Xếp hạng", + "Rating cleared": "Đã xóa xếp hạng", + "Ratings & flags": "Xếp hạng và cờ", + "Raw": "Raw", + "Raw default": "Mặc định RAW", + "Raw defaults": "Mặc định RAW", + "Raw files": "Tệp RAW", + "Raw photos": "Ảnh RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Ảnh raw dạng DNG, nhúng chỉnh sửa. Tùy chọn kích thước, màu và đầu ra không áp dụng.", + "Read Metadata from File": "Đọc siêu dữ liệu từ tệp", + "Read metadata updates from XMP sidecars": "Đọc cập nhật siêu dữ liệu từ sidecar XMP", + "Read mode": "Chế độ đọc", + "Read the SAM License": "Đọc giấy phép SAM", + "Reading": "Đang đọc", + "Reading Lightroom catalog": "Đang đọc catalog Lightroom", + "Reading Lightroom catalog…": "Đang đọc catalog Lightroom…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Đọc sidecar thay xếp hạng, nhãn, metadata và chỉnh sửa của ảnh bằng nội dung trong đó.", + "Reading folder": "Đang đọc thư mục", + "Reading folder…": "Đang đọc thư mục…", + "Reading photos…": "Đang đọc ảnh…", + "Real name": "Tên thật", + "Recent": "Gần đây", + "Recent Keywords": "Từ khóa gần đây", + "Recent Keywords is built in: choose another name": "Từ khóa gần đây là bộ sẵn: hãy chọn tên khác", + "Recent Libraries": "Thư viện gần đây", + "Recently Added": "Mới thêm", + "Recently Deleted": "Đã xóa gần đây", + "Recognise faces (experimental)": "Nhận diện khuôn mặt (thử nghiệm)", + "Recognition models": "Mô hình nhận dạng", + "Red": "Đỏ", + "Red Eye": "Mắt đỏ", + "Red Hue": "Sắc đỏ", + "Red Luminance": "Độ sáng đỏ", + "Red Primary": "Màu gốc đỏ", + "Red Saturation": "Độ bão hòa đỏ", + "Red channel": "Kênh đỏ", + "Red label": "Nhãn đỏ", + "Red/Cyan Fringe": "Viền đỏ/lam", + "Redo": "Làm lại", + "Reference": "Tham chiếu", + "Reference View": "Xem tham chiếu", + "Reference the files where they are": "Tham chiếu tệp tại chỗ", + "Refine": "Tinh chỉnh", + "Refine AI Mask Detail": "Tinh chỉnh chi tiết mặt nạ AI", + "Refine Edges": "Tinh chỉnh cạnh", + "Refine Mask Edges": "Tinh chỉnh cạnh mặt nạ", + "Refine Saturation": "Tinh chỉnh độ bão hòa", + "Refining the mask's detail…": "Đang tinh chỉnh chi tiết mặt nạ…", + "Refresh Source": "Làm mới nguồn", + "Refresh Source (/)": "Làm mới nguồn (/)", + "Reject": "Đã loại", + "Reject below focus": "Loại ảnh dưới ngưỡng lấy nét", + "Rejected": "Đã loại", + "Rejects": "Ảnh đã loại", + "Relative": "Tương đối", + "Reliability": "Độ tin cậy", + "Relink Photo": "Liên kết lại ảnh", + "Relink photos whose file was renamed or moved": "Liên kết lại ảnh có tệp đã đổi tên hoặc di chuyển", + "Relinked": "Đã liên kết lại", + "Reload Changed Files": "Tải lại tệp đã đổi", + "Reload from Disk": "Tải lại từ đĩa", + "Remove": "Xóa", + "Remove (H)": "Xóa (H)", + "Remove CA": "Xóa quang sai sắc", + "Remove Chromatic Aberration": "Xóa quang sai sắc", + "Remove Denoise Model": "Gỡ mô hình khử nhiễu", + "Remove Disk from Library": "Gỡ đĩa khỏi thư viện", + "Remove Disk from Library…": "Gỡ đĩa khỏi thư viện…", + "Remove Face Box": "Gỡ khung khuôn mặt", + "Remove Face Model": "Gỡ mô hình khuôn mặt", + "Remove Folder from Library": "Gỡ thư mục khỏi thư viện", + "Remove a filter above, or choose Clear all": "Gỡ bộ lọc ở trên, hoặc chọn Xóa tất cả", + "Remove from Album": "Gỡ khỏi album", + "Remove from Favorites": "Gỡ khỏi yêu thích", + "Remove from Library…": "Gỡ khỏi thư viện…", + "Remove from Local": "Gỡ khỏi Local", + "Remove from Photo": "Gỡ khỏi ảnh", + "Remove from Selected Photos": "Gỡ khỏi ảnh đã chọn", + "Remove from Stack": "Gỡ khỏi chồng", + "Remove from Target Album": "Gỡ khỏi album đích", + "Remove from favorites": "Gỡ khỏi yêu thích", + "Remove from survey": "Gỡ khỏi khảo sát", + "Remove location info": "Xóa thông tin vị trí", + "Remove missing photos from the library": "Gỡ ảnh mất khỏi thư viện", + "Remove shortcut": "Gỡ lối tắt", + "Remove this face box (undo with Edit ▸ Undo)": "Gỡ khung khuôn mặt này (hoàn tác bằng Chỉnh sửa → Hoàn tác)", + "Remove this filter": "Gỡ bộ lọc này", + "Remove this group": "Gỡ nhóm này", + "Remove this rule": "Gỡ quy tắc này", + "Removed": "Đã gỡ", + "Rename": "Đổi tên", + "Rename Album": "Đổi tên album", + "Rename Component": "Đổi tên thành phần", + "Rename Folder": "Đổi tên thư mục", + "Rename Folder…": "Đổi tên thư mục…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Đổi tên thư mục… và Di chuyển thư mục tới… (Local) có thể hoàn tác và làm lại (issue #97): thư mục di chuyển lại trên đĩa với", + "Rename Keyword": "Đổi tên từ khóa", + "Rename Keyword…": "Đổi tên từ khóa…", + "Rename Mask": "Đổi tên mặt nạ", + "Rename Photos": "Đổi tên ảnh", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Đổi tên ảnh không bao giờ ghi đè ảnh khác khi chỉ đổi hoa thường (issue #95): trên hệ thống phân biệt hoa thường", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Đổi tên ảnh báo tệp không thể chuyển lại sau lỗi (issue #105), ví dụ khi ổ mạng bị ngắt", + "Rename Photos…": "Đổi tên ảnh…", + "Rename Preset": "Đổi tên thiết lập sẵn", + "Rename Preview": "Đổi tên bản xem trước", + "Rename Template Tags": "Thẻ mẫu đổi tên", + "Rename Version": "Đổi tên phiên bản", + "Rename…": "Đổi tên…", + "Rendering": "Đang kết xuất", + "Rendering…": "Đang kết xuất…", + "Reorder Album": "Sắp xếp lại album", + "Reorder Masks": "Sắp xếp lại mặt nạ", + "Reset": "Đặt lại", + "Reset All": "Đặt lại tất cả", + "Reset All Curves": "Đặt lại tất cả đường cong", + "Reset All Keyboard Shortcuts": "Đặt lại tất cả phím tắt", + "Reset Before to Import State": "Đặt lại Before về trạng thái nhập", + "Reset Control": "Đặt lại điều khiển", + "Reset Crop": "Đặt lại cắt", + "Reset Edits": "Đặt lại chỉnh sửa", + "Reset Panel Layout": "Đặt lại bố cục bảng", + "Reset Parametric Curve": "Đặt lại đường cong tham số", + "Reset Section": "Đặt lại mục", + "Reset Sidebar Sections": "Đặt lại mục thanh bên", + "Reset Tone Curve": "Đặt lại đường cong tông màu", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Đặt lại mọi đường cong: đường cong điểm (mọi kênh) và tham số (nhấp đúp kênh để chỉ đặt lại kênh đó)", + "Reset all edits (Cmd+Shift+R)": "Đặt lại mọi chỉnh sửa (Cmd+Shift+R)", + "Reshuffle": "Xáo trộn lại", + "Resize Face Box": "Đổi kích thước khung khuôn mặt", + "Resize panel window": "Thay đổi kích thước cửa sổ bảng", + "Resize panels": "Thay đổi kích thước bảng", + "Resize to": "Thay đổi kích thước thành", + "Resolution (ppi)": "Độ phân giải (ppi)", + "Restore": "Khôi phục", + "Restore (no shortcut)": "Khôi phục (không có phím tắt)", + "Restore Library from Backup…": "Khôi phục thư viện từ bản sao lưu…", + "Restore Version": "Khôi phục phiên bản", + "Restore keeps their edits; Import as new deletes the old record first.": "Khôi phục giữ chỉnh sửa; Nhập như mới xóa bản ghi cũ trước.", + "Restore them": "Khôi phục chúng", + "Retouch": "Chỉnh sửa", + "Reverse the order": "Đảo thứ tự", + "Revert": "Hoàn tác", + "Review": "Xem lại", + "Review Import": "Xem lại nhập", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Nhấp phải ảnh → Đặt làm ảnh tham chiếu, rồi Xem → Chế độ tham chiếu (Shift+R)", + "Right-click a preset to delete it": "Nhấp phải thiết lập sẵn để xóa", + "Rights Usage Terms": "Điều khoản sử dụng quyền", + "Rotate": "Xoay", + "Rotate Crop": "Xoay vùng cắt", + "Rotate Crop Aspect": "Xoay tỷ lệ cắt", + "Rotate Left": "Xoay trái", + "Rotate Right": "Xoay phải", + "Roughness": "Độ thô", + "Roundness": "Độ tròn", + "Rules": "Quy tắc", + "SAM 3 download stopped: it resumes where it left off next time.": "Tải SAM 3 đã dừng: lần sau sẽ tiếp tục từ chỗ dừng.", + "SDR": "SDR", + "SDR Brightness": "Độ sáng SDR", + "SDR Clarity": "Độ rõ SDR", + "SDR Contrast": "Tương phản SDR", + "SDR Highlights": "Vùng sáng SDR", + "SDR Shadows": "Vùng tối SDR", + "SDR Whites": "Vùng trắng SDR", + "Sa": "T7", + "Same as raw default": "Giống mặc định RAW", + "Sample Mask Color": "Lấy mẫu màu mặt nạ", + "Sample a colour on the photo": "Lấy mẫu màu trên ảnh", + "Saturation": "Độ bão hòa", + "Saturation Range": "Dải độ bão hòa", + "Saturday": "Thứ Bảy", + "Save": "Lưu", + "Save As…": "Lưu thành…", + "Save Color Label Set": "Lưu bộ nhãn màu", + "Save Current Filter as Preset…": "Lưu bộ lọc hiện tại thành thiết lập sẵn…", + "Save Current Keywords as Set…": "Lưu từ khóa hiện tại thành bộ…", + "Save Export Preset": "Lưu thiết lập sẵn xuất", + "Save Filter Preset": "Lưu thiết lập sẵn bộ lọc", + "Save Keyword Set": "Lưu bộ từ khóa", + "Save Metadata Preset": "Lưu thiết lập sẵn siêu dữ liệu", + "Save Metadata Preset…": "Lưu thiết lập sẵn siêu dữ liệu…", + "Save Metadata to File": "Lưu siêu dữ liệu vào tệp", + "Save Point Curve Preset": "Lưu thiết lập sẵn đường cong điểm", + "Save Point Curve…": "Lưu đường cong điểm…", + "Save as Preset": "Lưu thành thiết lập sẵn", + "Save as Set…": "Lưu thành bộ…", + "Save as Smart Album…": "Lưu thành album thông minh…", + "Save as a new set": "Lưu thành bộ mới", + "Save as set": "Lưu thành bộ", + "Save as…": "Lưu thành…", + "Save preset": "Lưu thiết lập sẵn", + "Saving Lightroom import index…": "Đang lưu chỉ mục nhập Lightroom…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Lưu metadata vào sidecar XMP do ứng dụng khác ghi không còn thay thế (issue #92): LightCraft gộp", + "Scale": "Tỷ lệ", + "Scan Folder for Changes": "Quét thư mục tìm thay đổi", + "Scan failed": "Quét thất bại", + "Scanning": "Đang quét", + "Scanning folder": "Đang quét thư mục", + "Scanning…": "Đang quét…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Điểm số gắn trên ảnh: lọc hoặc tạo album thông minh với Focus và Best of Similar Shots.", + "Screen": "Màn hình", + "Scroll menu down": "Cuộn menu xuống", + "Scroll menu up": "Cuộn menu lên", + "Search": "Tìm kiếm", + "Search Photos": "Tìm ảnh", + "Search albums": "Tìm album", + "Search commands or keys": "Tìm lệnh hoặc phím", + "Search…": "Tìm…", + "Seasons": "Mùa", + "Second Window": "Cửa sổ thứ hai", + "Select": "Chọn", + "Select All": "Chọn tất cả", + "Select Luminance Range": "Chọn dải độ sáng", + "Select Mask": "Chọn mặt nạ", + "Select Photos": "Chọn ảnh", + "Select Spot": "Chọn điểm", + "Select a photo to edit": "Chọn ảnh để chỉnh sửa", + "Select by": "Chọn theo", + "Select by Flag, Rating or Label": "Chọn theo cờ, xếp hạng hoặc nhãn", + "Select photos to give them keywords.": "Chọn ảnh để gán từ khóa.", + "Select photos, then choose View → Survey (N)": "Chọn ảnh, rồi chọn Xem → Khảo sát (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Chọn hai ảnh, rồi chọn Xem → So sánh (Shift+C)", + "Selected": "Đã chọn", + "Selecting…": "Đang chọn…", + "Selenium Tone": "Tông selenium", + "Send Feedback…": "Gửi phản hồi…", + "Separated by commas": "Phân tách bằng dấu phẩy", + "Sepia Tone": "Tông sepia", + "Sequence number zero-padded to N digits (1–9)": "Số thứ tự thêm số 0 đến N chữ số (1–9)", + "Sequence number, counting from the start number": "Số thứ tự, đếm từ số bắt đầu", + "Services": "Dịch vụ", + "Set": "Bộ", + "Set Album Cover": "Đặt ảnh bìa album", + "Set Before": "Đặt Before", + "Set Color Label": "Đặt nhãn màu", + "Set Crop": "Đặt cắt", + "Set Develop Values": "Đặt giá trị chỉnh sửa", + "Set Flag": "Đặt cờ", + "Set Folder Color Label": "Đặt nhãn màu thư mục", + "Set Image Zoom and Pan": "Đặt thu phóng và pan ảnh", + "Set Keyboard Shortcut": "Đặt phím tắt", + "Set Mask Adjustments": "Đặt điều chỉnh mặt nạ", + "Set Point Curve": "Đặt đường cong điểm", + "Set Profile": "Đặt hồ sơ", + "Set Rating": "Đặt xếp hạng", + "Set Top of Stack": "Đặt đầu chồng", + "Set as Album Cover": "Đặt làm ảnh bìa album", + "Set as Before": "Đặt làm Before", + "Set as Reference Photo": "Đặt làm ảnh tham chiếu", + "Set as Target Album": "Đặt làm album đích", + "Set as Target Album (B adds to it)": "Đặt làm album đích (B thêm vào đó)", + "Set as Top of Stack": "Đặt làm đầu chồng", + "Set date & time": "Đặt ngày & giờ", + "Set name": "Đặt tên", + "Set the mix from the photo's colours": "Đặt hỗn hợp từ màu của ảnh", + "Set up AI Denoise…": "Thiết lập AI Khử nhiễu…", + "Settings": "Cài đặt", + "Settings file problem": "Lỗi tệp thiết lập", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Tệp cài đặt không bao giờ im lặng đặt lại (issue #103): `prefs.json`, `presets.json` hoặc `view.json` hỏng được giữ", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Cài đặt ảnh raw mới bắt đầu từ. Đổi chúng không ảnh hưởng ảnh đã có trong thư viện.", + "Settings pasted": "Đã dán thiết lập", + "Settings to include": "Thiết lập cần bao gồm", + "Settings…": "Thiết lập…", + "Shade": "Bóng râm", + "Shadow": "Vùng tối", + "Shadows": "Vùng tối", + "Shadows Hue": "Sắc vùng tối", + "Shadows Luminance": "Độ sáng vùng tối", + "Shadows Saturation": "Độ bão hòa vùng tối", + "Shadows Tint": "Tông vùng tối", + "Shadows split": "Tách vùng tối", + "Sharpen": "Làm nét", + "Sharpening": "Làm nét", + "Sharpness": "Độ nét", + "Shift": "Dịch chuyển", + "Shift by": "Dịch chuyển theo", + "Short Edge": "Cạnh ngắn", + "Show": "Hiện", + "Show All": "Hiện tất cả", + "Show All Mask Overlays": "Hiện mọi lớp phủ mặt nạ", + "Show Clipping": "Hiện vùng cắt tông", + "Show Deghost Overlay": "Hiện lớp phủ deghost", + "Show Hidden Local Locations": "Hiện vị trí Local đã ẩn", + "Show Luminance Map": "Hiện bản đồ độ sáng", + "Show Mask": "Hiện mặt nạ", + "Show Mask Overlay": "Hiện lớp phủ mặt nạ", + "Show Mask Pins": "Hiện ghim mặt nạ", + "Show Original": "Hiện bản gốc", + "Show Person": "Hiện người", + "Show Photo Counts": "Hiện số lượng ảnh", + "Show Photos with Keyword": "Hiện ảnh có từ khóa", + "Show Pins": "Hiện ghim", + "Show Sidebar Section": "Hiện mục thanh bên", + "Show Source": "Hiện nguồn", + "Show Thumbnails": "Hiện hình thu nhỏ", + "Show all overlays": "Hiện mọi lớp phủ", + "Show commands without a shortcut": "Hiện lệnh không có phím tắt", + "Show destination gamut warning": "Hiện cảnh báo gamut đích", + "Show display gamut warning": "Hiện cảnh báo gamut màn hình", + "Show file names": "Hiện tên tệp", + "Show in Compare": "Hiện trong So sánh", + "Show in Explorer": "Hiện trong Explorer", + "Show in File Manager": "Hiện trong trình quản lý tệp", + "Show in Finder": "Hiện trong trình quản lý tệp", + "Show less": "Hiện ít hơn", + "Show on Map": "Hiện trên bản đồ", + "Show overlay (O)": "Hiện lớp phủ (O)", + "Show ratings, flags and edit badges": "Hiện xếp hạng, cờ và huy hiệu chỉnh sửa", + "Show the Navigator while zoomed in": "Hiện Điều hướng khi phóng to", + "Show the template tags; click one to insert it": "Hiện thẻ mẫu; nhấp để chèn", + "Show/Hide Mask": "Hiện/Ẩn mặt nạ", + "Show/hide My Photos panel": "Hiện/ẩn bảng Ảnh của tôi", + "Shutter Speed": "Tốc độ màn trập", + "Shutter speed": "Tốc độ màn trập", + "Side panels": "Bảng bên", + "Sidebar Sections": "Mục thanh bên", + "Sidecar names": "Tên sidecar", + "Silver": "Bạc", + "Simulate Paper & Ink": "Mô phỏng giấy & mực", + "Size": "Kích thước", + "Size (% of short edge)": "Kích thước (% cạnh ngắn)", + "Size limit": "Giới hạn kích thước", + "Skip": "Bỏ qua", + "Skipped: files already in the library elsewhere": "Đã bỏ qua: tệp đã có trong thư viện ở nơi khác", + "Skipped: files that can't be read": "Đã bỏ qua: tệp không đọc được", + "Sky": "Bầu trời", + "Slideshow": "Trình chiếu", + "Slideshow paused": "Đã tạm dừng trình chiếu", + "Slideshow resumed": "Đã tiếp tục trình chiếu", + "Slideshow · Space pauses · Esc ends": "Trình chiếu · Space tạm dừng · Esc kết thúc", + "Slideshow, second window, All Metadata, System Info.": "Trình chiếu, cửa sổ thứ hai, Toàn bộ siêu dữ liệu, Thông tin hệ thống.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catalog nhỏ hơn, nhanh hơn: ảnh bạn chỉ xem trong Local (chưa thêm, xếp hạng hay chỉnh) bị quên khi", + "Smart Album": "Album thông minh", + "Smart Album Rule Fields": "Trường quy tắc album thông minh", + "Smart Preview Status": "Trạng thái xem trước thông minh", + "Smart Previews Location": "Vị trí xem trước thông minh", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Album thông minh với trình soạn quy tắc: khớp tất cả / bất kỳ / không, nhóm lồng nhau, 26 trường.", + "Smart preview available": "Có xem trước thông minh", + "Smart previews": "Xem trước thông minh", + "Smart previews folder changed": "Đã đổi thư mục xem trước thông minh", + "Smoothness": "Độ mịn", + "Soft B&W": "B&W mềm", + "Soft Pastel": "Pastel mềm", + "Soft Proofing": "Soft proofing", + "Soft Skin": "Da mềm", + "Some rules need fixing (Edit Smart Album…):": "Một số quy tắc cần sửa (Chỉnh sửa album thông minh…):", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony ARW trước khoảng 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II và các", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4 ARW lossless thu nhỏ giờ giải mã tile YCbCr subsampled thành RGB tuyến tính,", + "Sort": "Sắp xếp", + "Sort Albums A–Z": "Sắp xếp album A–Z", + "Sort Albums by Name": "Sắp xếp album theo tên", + "Sort Contents A–Z": "Sắp xếp nội dung A–Z", + "Sort by": "Sắp xếp theo", + "Source": "Nguồn", + "Source code on GitHub": "Mã nguồn trên GitHub", + "Speed": "Tốc độ", + "Spherical": "Cầu", + "Spiral": "Xoắn ốc", + "Split Stack": "Tách chồng", + "Spring Fresh": "Xuân tươi", + "Square": "Vuông", + "Square Grid": "Lưới vuông", + "Square Grid (G toggles)": "Lưới vuông (G bật/tắt)", + "Square Grid: show file names and formats": "Lưới vuông: hiện tên tệp và định dạng", + "Stack": "Chồng", + "Stack photos taken within this time of each other:": "Chồng ảnh chụp trong khoảng thời gian này của nhau:", + "Stacks": "Chồng", + "Standard": "Chuẩn", + "Star rating (0–5)": "Xếp hạng sao (0–5)", + "Start at": "Bắt đầu tại", + "Start number": "Số bắt đầu", + "Starting": "Đang bắt đầu", + "Starting…": "Đang bắt đầu…", + "Startup": "Khởi động", + "State / Province": "Bang / Tỉnh", + "Stop": "Dừng", + "Stop Building Previews": "Dừng tạo xem trước", + "Stop Using as Target Album": "Ngừng dùng làm album đích", + "Stopped": "Đã dừng", + "Stopped by an error (see the log)": "Dừng do lỗi (xem nhật ký)", + "Stopping…": "Đang dừng…", + "Straighten": "Chỉnh thẳng", + "Straighten Tool": "Công cụ chỉnh thẳng", + "Strong Contrast": "Tương phản mạnh", + "Style": "Kiểu", + "Su": "CN", + "Subfolder": "Thư mục con", + "Subfolder name": "Tên thư mục con", + "Subject": "Chủ thể", + "Subtract": "Trừ", + "Subtract from this mask": "Trừ khỏi mặt nạ này", + "Suggest Names for Faces": "Gợi ý tên cho khuôn mặt", + "Suggestions": "Gợi ý", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Gợi ý ai trong ảnh từ khuôn mặt bạn đã đặt tên. Mọi thứ ở trên máy bạn.", + "Summer Haze": "Sương hè", + "Sunday": "Chủ Nhật", + "Survey": "Khảo sát", + "Survey (N)": "Khảo sát (N)", + "Swap": "Hoán đổi", + "Swap Aspect (X)": "Hoán đổi tỷ lệ (X)", + "Swap Before and After": "Hoán đổi Before và After", + "Swap Before and After Settings": "Hoán đổi thiết lập Before và After", + "Swap Compare Photos": "Đổi chỗ ảnh so sánh", + "Switch light / dark theme": "Chuyển chủ đề sáng / tối", + "Sync Settings": "Đồng bộ thiết lập", + "Sync with System": "Đồng bộ với hệ thống", + "Synchronize": "Đồng bộ hóa", + "Synchronize Folder": "Đồng bộ thư mục", + "Synchronize Folder…": "Đồng bộ thư mục…", + "Synchronizing folder": "Đang đồng bộ thư mục", + "Synchronizing…": "Đang đồng bộ…", + "Synonyms": "Từ đồng nghĩa", + "System": "Hệ thống", + "System Info": "Thông tin hệ thống", + "System Info…": "Thông tin hệ thống…", + "System default": "Mặc định hệ thống", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "Dòng TZ/ZS, Leica D-Lux, V-Lux và C-Lux, và thế hệ GH6, GH7, G9 II, S5 II và S9. Chúng bắt đầu từ", + "Table": "Bảng", + "Tags": "Thẻ", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Thẻ: xem Thẻ bên cạnh mẫu. Tệp đổi tên trên đĩa (kèm sidecar XMP); tên có sẵn thêm -1, -2…", + "Take a snapshot": "Chụp snapshot", + "Targeted Adjustment": "Điều chỉnh có mục tiêu", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Chỉnh theo vùng đang bật: kéo lên/xuống trên ảnh · Esc để dừng", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Chỉnh có mục tiêu: kéo lên/xuống trên ảnh để chỉnh màu tại đó", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Chỉnh có mục tiêu: kéo lên/xuống trên ảnh để chỉnh đường cong tại đó", + "Teal & Amber": "Xanh mòng két & hổ phách", + "Teal & Orange": "Xanh mòng két & cam", + "Temp": "Nhiệt", + "Temperature": "Nhiệt độ", + "Template": "Mẫu", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Phiên tạm — thư viện chưa mở và không lưu gì bạn làm ở đây.", + "Test Denoise Model": "Thử mô hình khử nhiễu", + "Test Face Model": "Thử mô hình khuôn mặt", + "Tested and works on this computer": "Đã thử và chạy trên máy này", + "Text": "Văn bản", + "Text direction": "Hướng chữ", + "Texture": "Kết cấu", + "Th": "T5", + "That includes the photos in the folders inside it.": "Bao gồm cả ảnh trong các thư mục bên trong.", + "That key can't be used as a shortcut": "Phím đó không thể dùng làm phím tắt", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Đã cài mô hình SAM 3: Mặt nạ đối tượng và Mô tả đã sẵn sàng.", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Catalog có phiên bản định dạng (issue #102). Mở thư viện từ LightCraft cũ hơn sẽ nâng cấp; thư viện", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Cổng điều khiển (`--control`) đóng kết nối ngay khi nhận bất kỳ thứ gì không phải yêu cầu JSON", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Mô hình khử nhiễu đã cài và đang dùng: thanh AI Denoise Amount trong Chi tiết giờ hoạt động", + "The download didn't work:": "Tải xuống không thành công:", + "The first time, macOS asks whether LightCraft may control Photos.": "Lần đầu, macOS hỏi LightCraft có được phép điều khiển Photos không.", + "The graphics card is set up when the next photo is made.": "Card đồ họa được thiết lập khi ảnh tiếp theo được tạo.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Nhập đọc catalog không đổi nó và thêm ảnh, chỉnh sửa, từ khóa và bộ sưu tập vào thư viện đang mở.", + "The library is up to date with this folder.": "Thư viện đã đồng bộ với thư mục này.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Mô hình chỉ lưu trên máy này. LightCraft không tải lên hay chia sẻ.", + "The number at the end of the original name (IMG_0042 → 0042)": "Số ở cuối tên gốc (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Phần mở rộng gốc luôn được thêm; {ext} chỉ đặt thêm bên trong tên.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Tệp gốc không đổi, mỗi tệp kèm sidecar XMP chứa chỉnh sửa.", + "The photo has these settings": "Ảnh có các thiết lập này", + "The processor does the work.": "Bộ xử lý thực hiện công việc.", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Bộ nhớ đệm thumbnail chỉ đếm và xóa tệp của chính nó (issue #98): thư viện mở trên thư mục", + "Their photos and the keywords below them come together under one keyword.": "Ảnh của chúng và từ khóa bên dưới gộp chung dưới một từ khóa.", + "Theme": "Chủ đề", + "There is no model file to install": "Không có tệp mô hình để cài", + "They can be restored from Recently Deleted until it is emptied.": "Có thể khôi phục từ Mới xóa gần đây cho đến khi xóa hết.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Chúng chuyển sang Đã xóa gần đây và có thể khôi phục; không đụng tệp trên đĩa.", + "Thirds": "Một phần ba", + "This Computer": "Máy này", + "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.": "Bản build này chưa tải mô hình được. Cài tay: đặt model.safetensors, vocab.json và merges.txt từ facebook/sam3 của Meta vào thư mục trên: Object và Describe hoạt động ngay khi có đủ.", + "This build cannot run denoise models.": "Bản build này không chạy được mô hình khử nhiễu.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Bản build này không chạy mô hình nhận dạng: có thể thêm và chọn, không dùng được.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Bản build này không có chỗ lưu mô hình khử nhiễu, nên AI Denoise không khả dụng. (App desktop có.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Bản build này không có chỗ lưu mô hình khuôn mặt, nên danh sách mô hình không khả dụng. (App desktop có.)", + "This file cannot be used yet": "Tệp này chưa dùng được", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Thư mục không khả dụng (ổ đã rút?). Xem trước thông minh không được tạo hay dùng cho đến khi ổ trở lại hoặc bạn chọn thư mục khác.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Thư mục không còn: đã di chuyển hoặc đổi tên, hoặc ổ chưa kết nối.", + "This group is empty: add a rule or remove it.": "Nhóm này trống: thêm quy tắc hoặc xóa nhóm.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Đây là catalog Lightroom, không phải thư viện LightCraft. Nhập vào thư viện LightCraft thay vì?", + "This is the album you're editing": "Đây là album bạn đang chỉnh sửa", + "This photo can't be opened": "Không thể mở ảnh này", + "Threshold": "Ngưỡng", + "Thumbnail cache": "Bộ nhớ đệm hình thu nhỏ", + "Thursday": "Thứ Năm", + "Tick the box to accept the model's terms first": "Đánh dấu hộp để chấp nhận điều khoản mô hình trước", + "Time zone": "Múi giờ", + "Tint": "Tông", + "Title": "Tiêu đề", + "Title metadata": "Siêu dữ liệu tiêu đề", + "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).": "Để đưa tệp gốc cho trình này (raw, không sao chép: ví dụ DxO PhotoLab), nhấp phải ảnh → Chỉnh trong → Mở bản gốc trong trình khác (RAW, không sao chép).", + "Toggle Keyword from Set": "Bật/tắt từ khóa từ bộ", + "Toggle Mask": "Bật/tắt mặt nạ", + "Toggle Section": "Bật/tắt mục", + "Toggle Zoom": "Bật/tắt thu phóng", + "Tone Curve": "Đường cong tông màu", + "Tools": "Công cụ", + "Top Level": "Cấp cao nhất", + "Transfer": "Chuyển", + "Treatment": "Xử lý màu", + "Triangle": "Tam giác", + "Try Again": "Thử lại", + "Try Saving Again": "Thử lưu lại", + "Try again": "Thử lại", + "Try the graphics card again": "Thử lại card đồ họa", + "Tu": "T3", + "Tuesday": "Thứ Ba", + "Tungsten": "Đèn sợi đốt", + "Turn Off": "Tắt", + "Turn off": "Tắt", + "Turn on Include subfolders, or pick another folder under Local": "Bật Bao gồm thư mục con, hoặc chọn thư mục khác trong Local", + "Type": "Loại", + "Uncheck All": "Bỏ chọn tất cả", + "Uncheck the files of this type": "Bỏ chọn tệp loại này", + "Undo": "Hoàn tác", + "Unedited": "Chưa chỉnh sửa", + "Unedited raws": "RAW chưa chỉnh", + "Unflag": "Bỏ cờ", + "Unflagged": "Không gắn cờ", + "Ungroup Stack": "Bỏ nhóm chồng", + "Unknown": "Không rõ", + "Unknown Date": "Ngày không xác định", + "Unknown licence": "Giấy phép không xác định", + "Unknown tags stay as typed — check the preview for a {typo}.": "Thẻ không xác định giữ nguyên như đã gõ — kiểm tra xem trước xem có {typo} không.", + "Unnamed Faces": "Khuôn mặt chưa đặt tên", + "Unrated": "Chưa xếp hạng", + "Unreadable": "Không đọc được", + "Update": "Cập nhật", + "Update Mask Shape": "Cập nhật hình mặt nạ", + "Update Preset with Current Settings": "Cập nhật thiết lập sẵn bằng thiết lập hiện tại", + "Update Rules from Current Filter": "Cập nhật quy tắc từ bộ lọc hiện tại", + "Update Version": "Cập nhật phiên bản", + "Update Version with Current Settings": "Cập nhật phiên bản bằng thiết lập hiện tại", + "Update to Current Process": "Cập nhật lên quy trình hiện tại", + "Update with Current Settings": "Cập nhật bằng thiết lập hiện tại", + "Updated the edits saved in the external editor": "Đã cập nhật chỉnh sửa lưu trong trình soạn ngoài", + "Upright": "Chỉnh thẳng", + "Urban": "Đô thị", + "Use": "Dùng", + "Use Keyword Set": "Dùng bộ từ khóa", + "Use Library Folder": "Dùng thư mục thư viện", + "Use Screen Profile": "Dùng hồ sơ màn hình", + "Use camera-specific defaults": "Dùng mặc định theo máy ảnh", + "Use sRGB": "Dùng sRGB", + "Use the GPU for rendering": "Dùng GPU để kết xuất", + "User Presets": "Thiết lập sẵn người dùng", + "Username": "Tên người dùng", + "Value": "Giá trị", + "Variance": "Phương sai", + "Version": "Phiên bản", + "Version name": "Tên phiên bản", + "Versions": "Phiên bản", + "Versions (Shift+V)": "Phiên bản (Shift+V)", + "Vertical": "Dọc", + "Vertical inset (% of short edge)": "Lùi dọc (% cạnh ngắn)", + "Vertical text": "Chữ dọc", + "Vibrance": "Độ rực rỡ", + "Video": "Video", + "Video Duration": "Thời lượng video", + "Videos": "Video", + "View": "Xem", + "Viewing and sharing": "Xem và chia sẻ", + "Vignette": "Tối góc", + "Vignetting": "Tối góc", + "Vintage": "Vintage", + "Virtual Copy": "Bản sao ảo", + "Visible": "Hiển thị", + "Visualize HDR": "Hình dung HDR", + "Visualize Spots": "Hiển thị điểm bụi", + "Visualize Spots (A)": "Hiển thị điểm bụi (A)", + "Visualize range": "Hiển thị dải", + "Vivid": "Rực rỡ", + "Vivid Pop": "Bật rực", + "Wait for the import to finish": "Đợi nhập hoàn tất", + "Waiting to start…": "Đang chờ bắt đầu…", + "Warm Film": "Phim ấm", + "Warm Glow": "Ánh ấm", + "Warm Print": "In ấm", + "Warm Table": "Bàn ấm", + "Watched folder": "Thư mục theo dõi", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Tự động nhập thư mục theo dõi; Chuyển sang DNG; Nhân bản; Tạo Standard / 1:1 / Xem trước thông minh.", + "Watermark": "Hình mờ", + "We": "T4", + "Wednesday": "Thứ Tư", + "What's New": "Có gì mới", + "When rated or hovered": "Khi đã xếp hạng hoặc rê chuột", + "White Balance": "Cân bằng trắng", + "White Balance Selector": "Bộ chọn cân bằng trắng", + "White Balance Selector (W)": "Bộ chọn cân bằng trắng (W)", + "Whites": "Vùng trắng", + "Width": "Chiều rộng", + "Width (% of photo)": "Chiều rộng (% ảnh)", + "Width (px)": "Chiều rộng (px)", + "Width × Height": "Rộng × Cao", + "Will Export": "Sẽ xuất", + "Will be replaced": "Sẽ được thay thế", + "Will be restored": "Sẽ được khôi phục", + "Window": "Cửa sổ", + "Winter Blue": "Xanh đông", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Không lưu và Thoát (issue #100). Không còn im lặng bắt đầu phiên demo trông như thư viện bị đặt lại và", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Hoạt động trên raw từ máy cảm biến chuẩn (Bayer). Tệp khác giữ khử nhiễu thông thường.", + "XMP Sidecar Preferences": "Tùy chọn tệp sidecar XMP", + "XMP sidecars": "Sidecar XMP", + "Year": "Năm", + "Yellow": "Vàng", + "Yellow Hue": "Sắc vàng", + "Yellow Luminance": "Độ sáng vàng", + "Yellow Saturation": "Độ bão hòa vàng", + "Yellow label": "Nhãn vàng", + "Yes": "Có", + "You can close this: the download continues, and resumes if interrupted.": "Bạn có thể đóng: tải vẫn tiếp tục và tiếp tục nếu bị gián đoạn.", + "Your Name": "Tên của bạn", + "Your library couldn't be opened": "Không thể mở thư viện của bạn", + "Zoom": "Thu phóng", + "Zoom 100%": "Thu phóng 100%", + "Zoom In": "Phóng to", + "Zoom Out": "Thu nhỏ", + "Zoom to Fit": "Thu phóng vừa khung", + "Zoom to page level": "Thu phóng đến cấp trang", + "`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`) cùng sidecar XMP; mỗi bản gốc rời thẻ chỉ sau khi", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; trên ổ không phân biệt hoa thường đổi hoa thường vẫn thực hiện.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "ổ đầy) không còn được coi là đã tạo và Tạo xem trước thông minh thay thế.", + "a literal %": "ký tự % thực", + "a removed photo": "một ảnh đã gỡ", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "sau đó (issue #101): ghi dở bị cắt trước khi LightCraft thử lại, nên lần khởi động sau phát lại mọi", + "again. Change the period (or turn it off) in Settings → Performance.": "lại. Đổi chu kỳ (hoặc tắt) trong Thiết lập → Hiệu năng.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "với bản gốc của thư viện không còn quét cả thư viện khi không có gì ở đường dẫn đó.", + "all": "tất cả", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "đã có thư mục `thumbs/` của ảnh khác không còn mất chúng khi cắt gọn hoặc xóa cache.", + "and": "và", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "và kiểm tra tệp trước khi dùng. Mặt nạ giữ vùng chọn, nên kết xuất và xuất không cần mô hình.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "và mọi ảnh đã nhập thành một zip, và Tệp → Khôi phục thư viện từ bản sao lưu… đưa lại (thư viện hiện tại", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "và không bao giờ đóng băng cửa sổ. Tùy chọn và không thuộc LightCraft (SAM License của Meta, khoảng 3,4 GB):", + "any": "bất kỳ", + "are empty": "trống", + "aren't empty": "không trống", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "thành `<name>.corrupt-<time>` và bạn được báo; tệp không đọc được (ví dụ bị khóa bởi chương trình khác) được giữ nguyên", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "khi được hỏi. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (hoặc `WGPU_BACKEND` của wgpu, kết xuất GPU", + "blue": "lam", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "gói bất kỳ loại này — cả thư mục một lúc, nhóm theo pack. Mặt nạ trong thiết lập sẵn đi theo.", + "cached picture": "ảnh trong bộ nhớ đệm", + "cached pictures": "ảnh trong bộ nhớ đệm", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "máy ảnh không còn bị nướng vào. Ảnh đã nhập \"chỉ xem trước\" chuyển sang khi Tải lại. (Tệp", + "can't be tested that way": "không thể kiểm tra theo cách đó", + "can't match any rating from 0 to 5": "không khớp xếp hạng nào từ 0 đến 5", + "can't test a folder; choose an album in it": "không thể kiểm tra thư mục; hãy chọn album bên trong", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "đổi. Catalog đã chứa mảnh như vậy tải đầy đủ. Thoát khi nhật ký catalog không ghi được", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "thay đổi không lưu được thử lại một lần, rồi hỏi: Thử lưu lại, Vẫn thoát hoặc Hủy.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "kênh hoặc MCP, đường dẫn xem trước merge hoặc `lightcraft-cli render IMG.jpg -o IMG.jpg` bị từ chối với thông báo rõ", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "đối chiếu với nội dung đọc từ thẻ. Bản sao lỗi hoặc khác bị xóa và báo là thất bại", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "màu và tông khớp JPEG của máy, như raw Nikon và Sony, khung theo tỷ lệ khung hình đặt trong", + "comma, separated": "phân tách bằng dấu phẩy", + "commercial use allowed": "cho phép dùng thương mại", + "commercial use subject to GPL conditions": "dùng thương mại theo điều kiện GPL", + "contains": "chứa", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "bản sao được xác minh và có trong thư viện. Trùng lặp và tệp lỗi giữ nguyên chỗ.", + "copyrighted": "có bản quyền", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dữ liệu chưa giải mã, nên vẫn chỉ xem trước. Với CR3 nhập trước đó, Ảnh → Tải lại từ đĩa (giờ trong", + "day 01–31": "ngày 01–31", + "days": "ngày", + "denoiseModel.accept": "denoiseModel.accept", + "discarding smart previews": "đang hủy xem trước thông minh", + "doesn't contain": "không chứa", + "e.g. sky · the red car · car, road": "vd. bầu trời · xe đỏ · xe, đường", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "cạnh bị cắt bỏ, và tệp \"12-bit uncompressed\" không còn bị clip. Dòng RX100 hiển thị gần hơn nhiều", + "ends with": "kết thúc bằng", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "mọi định dạng máy ghi. Hầu hết body trước đó chỉ xem trước: GH1–GH5, G, GX, GF, GM, FZ, LX và", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "xuất. Catalog, sidecar XMP, cài đặt, DNG, merge, xem trước thông minh và bản sao khi nhập vẫn", + "false": "false", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "trường của mình và giữ phần còn lại — ví dụ cài develop và lịch sử chỉnh của app đó — byte for byte. Một", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "tệp (Sony chỉ lưu trong maker-note mã hóa trên các body này), vài cột padding bên phải", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "lần đầu dùng mặt nạ AI, LightCraft hỏi trước khi tải, hiện tiến trình, có thể hủy và tiếp tục,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "cho phiên thay vì bị ghi đè bằng mặc định. Cài đặt app (`ui.json`, ghi nhớ", + "gets -1, -2… instead of being replaced.": "nhận -1, -2… thay vì bị thay thế.", + "green": "lục", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "hiển thị trung tính hơi xanh (issue #150); cân bằng trắng chỉnh tương đối so với as-shot. NEF 12 bit", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "đang mở — trên máy này hoặc máy khác dùng chung thư mục — nói ai đang giữ (\"đã mở trong LightCraft (process", + "has the first date after the second": "ngày đầu sau ngày thứ hai", + "hour 00–23": "giờ 00–23", + "hours": "giờ", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "cách khôi phục hoặc xóa vĩnh viễn và nhập lại. (Để bắt đầu lại trên ảnh, Đặt lại chỉnh sửa,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "bỏ qua trước đó) chọn backend đồ họa; `off` kết xuất trên CPU. GPU giờ khởi động sau cửa sổ", + "image": "ảnh", + "immutable, and HOSTING.md describes the actual build.": "bất biến, và HOSTING.md mô tả bản build thực tế.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "nhập — nên \"nhập xong\" nghĩa là bản sao ổn trước khi format thẻ — và tên đã được dùng", + "imported as \"preview only\" switch over on Reload.": "đã nhập \"chỉ xem trước\" chuyển sang khi Tải lại.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "nhập trước đó chuyển sang với Ảnh → Tải lại từ đĩa.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "theo đoạn ngắn để GPU tích hợp chậm không bị watchdog reset. `ui.inspect` → `perf`", + "in use": "đang dùng", + "includes the folders inside it": "bao gồm thư mục bên trong", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "chưa hoàn thành được làm lại trên CPU — tệp là cùng ảnh dù cách nào. Công việc gửi tới GPU", + "installed": "đã cài", + "is": "là", + "is <": "<", + "is >": ">", + "is after": "sau", + "is before": "trước", + "is between": "nằm giữa", + "is empty": "trống", + "is in the last": "trong vòng", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "được giữ). Lưu thất bại (hết dung lượng) giờ hiện cảnh báo chưa lưu và thử lại, ảnh không lưu được", + "is refused and nothing is overwritten.": "bị từ chối và không gì bị ghi đè.", + "is unknown": "không rõ", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "sẵn sàng, và chỉ khi Cài đặt → Hiệu năng → Dùng GPU để kết xuất bật; nếu LightCraft thoát đột ngột khi", + "is yes or no": "là có hoặc không", + "is ≤": "≤", + "is ≥": "≥", + "isn't": "không phải", + "isn't a field rules know": "không phải trường quy tắc biết", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "không được thêm, tab thứ hai hiện thông báo thay vì ghi đè tab đầu, `?reset` hỏi trước, và trang nói", + "isn't empty": "không trống", + "isn't in the last": "không trong vòng", + "isn't one of the choices": "không phải một trong các lựa chọn", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "khỏi bên kia (issue #92). Tìm ảnh thiếu cũng tìm tệp đổi tên theo nội dung, ưu tiên khớp nội dung", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ảnh và sidecar XMP, và ảnh trỏ lại vào đó. Nếu chỗ cũ đã có thứ khác, hoàn tác", + "keep names — or e.g. {date}_{seq:3}": "giữ tên — hoặc vd. {date}_{seq:3}", + "latitude, longitude": "vĩ độ, kinh độ", + "layout is read from each file instead of assumed.": "bố cục đọc từ từng tệp thay vì giả định.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "không tạo DNG và giữ raw. Xem trước thông minh ghi cùng cách; bản hỏng (cắt ngắn do sự cố hoặc", + "library locked: the lock is the operating system's and goes away with the program.": "thư viện bị khóa: khóa của hệ điều hành và biến mất khi thoát chương trình.", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "thư viện) ghi nguyên tử và lưu ngay khi mở thư viện khác, không chỉ khi thoát. Thoát khi", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "không chỉ nói \"bỏ qua trùng\": mở bảng bên ở Đã xóa gần đây với ảnh được chọn và nói", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "mất hết khi thoát; phiên tạm hiện banner suốt và không ghi vào thư viện.", + "makes AI Denoise available": "cho phép dùng AI Khử nhiễu", + "memory only": "chỉ bộ nhớ", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "bộ nhớ, vượt giới hạn buffer GPU, gặp lỗi driver hoặc reset, hoặc trả về", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "thông báo, và bản gốc giữ nguyên byte for byte. Các lần xuất trước vẫn bị ghi đè khi được yêu cầu.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "giữa đợt: lỗi liệt kê chúng (cũ → mới) và thư viện trỏ tên mới (đổi tên một phần có thể hoàn tác),", + "minute": "phút", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "mô hình cảm biến bắt đầu hàng xanh-lam) không còn ra hồng (issue #85): bộ lọc màu", + "month 01–12": "tháng 01–12", + "months": "tháng", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "tên (không bao giờ thay tệp), và chỉ khi đó ảnh được liên kết lại hoặc bản sao raw bị gỡ — ghi thất bại", + "names an album that no longer exists": "chỉ album không còn tồn tại", + "needs a date like 2026, 2026-08 or 2026-08-14": "cần ngày như 2026, 2026-08 hoặc 2026-08-14", + "needs a number": "cần một số", + "needs a number more than 0": "cần số lớn hơn 0", + "needs a time like 1/250 or 2": "cần thời gian như 1/250 hoặc 2", + "needs an album": "cần một album", + "needs hours, days, weeks, months or years": "cần giờ, ngày, tuần, tháng hoặc năm", + "needs something to look for (or use “are empty”)": "cần nội dung để tìm (hoặc dùng “trống”)", + "needs something to look for (or use “aren't empty”)": "cần nội dung để tìm (hoặc dùng “không trống”)", + "needs something to look for (or use “is empty”)": "cần nội dung để tìm (hoặc dùng “trống”)", + "needs something to look for (or use “isn't empty”)": "cần nội dung để tìm (hoặc dùng “không trống”)", + "needs text": "cần văn bản", + "needs two values": "cần hai giá trị", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "không bao giờ để tệp cắt cụt; sidecar XMP của xuất \"Original\" cũng theo lựa chọn \"Nếu tệp đã tồn tại\".", + "non-commercial use only": "chỉ dùng phi thương mại", + "none": "không có", + "none (CPU rendering)": "không (kết xuất CPU)", + "none of": "không thuộc", + "not added to Apple Photos (cancelled)": "không thêm vào Apple Photos (đã hủy)", + "not available here": "không có tại đây", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "giờ develop từ dữ liệu raw thay vì JPEG nhúng của máy, nên kiểu ảnh Đen trắng hoặc khác đặt trên", + "of the following rules:": "trong các quy tắc sau:", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "trên thẻ sau Xem lại nhập được báo thay vì nhập với chi tiết cũ. Kiểm tra đường dẫn xuất", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "từng cái — luôn có thể xuất lại, và trên USB hoặc NAS mà đồng bộ từng tệp chi phối lô lớn", + "or": "hoặc", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "trên bản trùng tên cùng kích thước, và bỏ qua (và báo) ảnh không phân biệt được thay vì đoán.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "một phần), hoặc gõ vùng cần chọn (\"sky\", \"the red car\", \"car, road\"); cả hai kết hợp mặt nạ khác, có", + "photo has its denoised picture.": "ảnh đã có ảnh khử nhiễu.", + "photo waiting": "ảnh đang chờ", + "photos have their denoised picture.": "ảnh đã có ảnh khử nhiễu.", + "photos waiting": "ảnh đang chờ", + "pick": "đã chọn", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "giữ chỉnh RAW và xuất full-resolution thay vì dùng xem trước JPEG nhúng.", + "previews": "xem trước", + "publicDomain": "publicDomain", + "purple": "tím", + "raw": "raw", + "read in the wrong units.": "đọc sai đơn vị.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "đọc thẻ lần thứ ba (Di chuyển vẫn so byte for byte trước khi xóa nguồn); tệp đã đổi", + "red": "đỏ", + "reject": "đã loại", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "kết xuất và ảnh chụp màn hình vẫn ghi tệp tạm rồi đổi tên, nhưng không còn ép flush đĩa", + "save goes through.": "lưu thành công.", + "saved in": "lưu trong", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "cảnh) giờ develop từ dữ liệu cảm biến với mọi công cụ chỉnh, như raw khác của máy. Ảnh", + "second": "giây", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "cùng dòng) không còn mở xanh sáng (issue #148): cân bằng trắng as-shot và mức đen đọc từ", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "sidecar không phải XMP hợp lệ được sao chép trước thành `<name>.xmp.bak-<time>`. Với đặt tên stem mặc định, raw và", + "smart album value|No": "Không", + "smart album value|Yes": "Có", + "smart previews": "xem trước thông minh", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "để không cái nào hiện thiếu. Đổi tên một trong cặp raw + JPEG sao chép `IMG_0001.xmp` dùng chung thay vì lấy", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "khởi động GPU, lần mở sau bắt đầu với kết xuất GPU tắt và hướng dẫn bật lại.", + "starts using it for AI Denoise": "bắt đầu dùng nó cho Khử nhiễu AI", + "starts with": "bắt đầu bằng", + "still saves your queued changes in the closing snapshot.": "vẫn lưu thay đổi đang chờ trong snapshot khi đóng.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "đồng bộ. Nhập → Sao chép giờ kiểm tra mỗi bản sao với hash nội dung lấy khi quét thẻ thay vì", + "terms unclear": "điều khoản không rõ", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG dùng `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "cây thư mục Local và Tự động nhập cũng đọc đĩa trên worker thread. Cửa sổ tiến trình nhập có Hủy.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Ảnh và menu ngữ cảnh ảnh) lấy xem trước full-size và điền metadata máy ảnh mà chúng", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "máy ảnh; điểm lỗi cảm biến được lấp. Tệp `.rwl` và `.raw` cũng được nhập. Ảnh đã", + "the graphics card": "card đồ họa", + "the graphics card is not used": "không dùng card đồ họa", + "the photos in view": "ảnh đang xem", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "cùng tên và \"Ghi đè\" (hoặc Xuất với Previous lặp lại), đường dẫn đầu ra chính xác từ kênh điều khiển", + "the selected photos": "ảnh đã chọn", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "thư mục không được duyệt 30 ngày — tệp và sidecar vẫn còn, duyệt lại thư mục sẽ thấy chúng", + "this photo": "ảnh này", + "this screen's profile": "hồ sơ màn hình này", + "to the camera's own JPEG.": "sang JPEG của máy ảnh.", + "true": "true", + "unknown": "không rõ", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "nguyên — phiên bản cũ không còn đọc một phần như log hỏng. Sau khi phiên bản này mở thư viện,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "dùng (ví dụ bỏ qua adapter phần mềm như llvmpipe) và vì sao lần kết xuất cuối fallback.", + "video": "video", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "ổ (Linux, APFS phân biệt hoa thường) `img_1.JPG` cạnh `IMG_1.JPG` là ảnh khác và ảnh đổi tên nhận", + "weeks": "tuần", + "were missing, without touching anything already set.": "bị thiếu, không đụng gì đã đặt.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "khi trình duyệt có thể đuổi thư viện. Hosting: header cache mẫu không còn đánh dấu tệp (chưa hash)", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "việc bản gốc còn không được kiểm tra trên worker thread (thumbnail lưới, bảng Info, menu ảnh, Thiếu", + "with a counterpart here come along, the rest is listed.": "có bản tương ứng ở đây thì đi theo, phần còn lại được liệt kê.", + "would make this album include itself through other smart albums": "sẽ khiến album này bao gồm chính nó qua album thông minh khác", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "do LightCraft mới hơn ghi bị từ chối với \"thư viện này do phiên bản LightCraft mới hơn ghi\" và giữ", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "do LightCraft mới hơn ghi), LightCraft nói rõ và lý do, và đề xuất Thử lại, Chọn thư viện khác…, Tiếp tục", + "year, 2 digits": "năm, 2 chữ số", + "year, 4 digits": "năm, 4 chữ số", + "years": "năm", + "yellow": "vàng", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} là thời gian chụp; ảnh không có thì dùng thời gian nhập.", + "© 2026 Your Name": "© 2026 Tên của bạn", + "© Your Name": "© Tên của bạn", + "− harder border · + softer border": "− viền cứng hơn · + viền mềm hơn", + "≤ at most": "≤ tối đa", + "≥ at least": "≥ tối thiểu", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-kéo để chỉnh thẳng, ⇧G Chỉnh thẳng có hướng dẫn, lưới khi biến đổi.", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 0d96d2960..a28b07d2c 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -204,23 +204,23 @@ "{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)", ", {} star{}": ",{0} 星{1:.0}", ", {} label": ",{0}标签", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "从 {host} 下载。下载完成并通过校验后,LightCraft 会进行安装,{after}。", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "正在处理器上运行:{}。", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}。保存在图库的“denoise”文件夹中;达到上限时,会先删除没有照片使用的最旧结果。", + "Denoising {name}: {done} of {total} tiles{}": "正在为 {name} 降噪:已完成 {done}/{total} 个图块{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "正在从 {host} 下载:{} / {}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "正在生成降噪结果… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "训练数据:{provenance}", + " · {queued} waiting": " · {queued} 张等待中", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "先显示该模型的条款,然后从 {host} 下载 {},安装并开始使用。除此之外不会上传任何数据。", + "In line for AI Denoise ({ahead} ahead)…": "AI 降噪排队中(前方还有 {ahead} 张)…", + "Running on {}.": "正在{}上运行。", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "放入“{parent}”中", @@ -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}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。", "{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/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2750dd4c8..4a2ebaa7b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -409,6 +409,7 @@ "Detail (D)": "细节 (D)", "Details": "详细信息", "Diagonal": "对角线", + "Dimensions": "尺寸", "Discard Preview Cache": "丢弃预览缓存", "Display P3": "Display P3", "Display name": "显示名称", @@ -1372,6 +1373,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": "显示星级、旗标和编辑标记", @@ -1575,7 +1577,7 @@ "Width": "宽度", "Width (% of photo)": "宽度(照片百分比)", "Width (px)": "宽度 (px)", - "Width × Height": "宽度 × 高度", + "Width & Height": "宽度 & 高度", "Will be replaced": "将替换", "Will be restored": "将恢复", "Window": "窗口", @@ -2179,5 +2181,10 @@ "Checking the GPU…": "正在检查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", - "Checking the GPU for faces": "正在检查用于人脸的 GPU" + "Checking the GPU for faces": "正在检查用于人脸的 GPU", + "Include selected photos": "包含所选照片", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。", + "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", + "Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 7128165f5..119272225 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -249,7 +249,7 @@ "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", "{what} merge cancelled": "已取消{what}合併", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)", "New Folder in “{name}”": "在「{name}」中新增資料夾", "Moved {} photo(s)": "已移動 {} 張照片", "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", @@ -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}", @@ -292,8 +293,9 @@ "{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", - "Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", + "Renames {n} face{} across the library. The files are not changed.": "在整個照片庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", + "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/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 19c7bc338..78d885128 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -44,7 +44,7 @@ "Add to My Photos": "加入我的照片", "Can't be moved here": "無法移到這裡", "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", - "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在照片庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -280,7 +280,7 @@ "Move Photos": "移動照片", "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", "Don't ask again": "不再詢問", - "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個照片庫:未移動任何內容", "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", @@ -409,6 +409,7 @@ "Detail (D)": "詳細檢視(D)", "Details": "詳細資訊", "Diagonal": "對角線", + "Dimensions": "尺寸", "Discard Preview Cache": "捨棄預覽快取", "Display P3": "Display P3", "Display name": "顯示名稱", @@ -1372,6 +1373,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": "顯示評分、旗標與編輯標記", @@ -1575,7 +1577,7 @@ "Width": "寬度", "Width (% of photo)": "寬度(照片百分比)", "Width (px)": "寬度(像素)", - "Width × Height": "寬度 × 高度", + "Width & Height": "寬度 & 高度", "Will be replaced": "將取代", "Will be restored": "將還原", "Window": "視窗", @@ -1938,7 +1940,7 @@ "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", - "Exporting contact sheet": "正在匯出聯絡表", + "Exporting contact sheet": "正在匯出縮圖目錄", "Reading folder": "正在讀取檔案夾", "Scanning folder": "正在掃描檔案夾", "Building previews": "正在建立預覽", @@ -1953,7 +1955,7 @@ "Looking for changes": "正在尋找變更", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。", - "Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片", + "Albums hold library photos: Add to My Photos first": "相簿只能收錄照片庫中的照片:請先加入我的照片", "Add to My Photos First": "先加入我的照片", "Fill Location from GPS": "根據 GPS 填寫位置", "Download Place Names (GeoNames)": "下載地名資料 (GeoNames)", @@ -1978,14 +1980,14 @@ "No imported folders yet": "還沒有匯入的資料夾", "Show Sidebar Section": "顯示側邊欄區段", "Move Sidebar Section": "移動側邊欄區段", - "New Library…": "新增圖庫…", - "Open Recent Library": "開啟最近的圖庫", - "Recent Libraries": "最近的圖庫", - "Clear Recent Libraries": "清除最近的圖庫", - "No Recent Libraries": "沒有最近的圖庫", - "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置", - "New Library": "新增圖庫", - "Library name": "圖庫名稱", + "New Library…": "新增照片庫…", + "Open Recent Library": "開啟最近的照片庫", + "Recent Libraries": "最近的照片庫", + "Clear Recent Libraries": "清除最近的照片庫", + "No Recent Libraries": "沒有最近的照片庫", + "New Library: Choose Where to Put It": "新增照片庫:選擇存放位置", + "New Library": "新增照片庫", + "Library name": "照片庫名稱", "New Folder on Disk": "在磁碟上新增資料夾", "New Folder on Disk…": "在磁碟上新增資料夾…", "Move to Folder": "移至資料夾", @@ -2004,10 +2006,10 @@ "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", "Lightroom Catalog Import": "Lightroom 目錄匯入", - "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。", - "Show in Library": "在圖庫中顯示", - "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。", - "Destination library": "目標圖庫", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的照片庫並再次匯入目錄以儲存相片。", + "Show in Library": "在照片庫中顯示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的照片庫,不會建立獨立的目錄資料夾。", + "Destination library": "目標照片庫", "Playback": "播放", "Layout": "版面", "Link all margins": "連結所有邊界", @@ -2028,7 +2030,7 @@ "Background colour": "背景顏色", "Colour wash": "顏色漸層", "Background image": "背景影像", - "(not in the library)": "(不在圖庫中)", + "(not in the library)": "(不在照片庫中)", "Use selected photo": "使用所選照片", "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", "Reset layout": "重設版面", diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 914827e5d..835dbcce8 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -172,13 +172,14 @@ pub struct AlbumPicker<'a> { value: &'a mut Option<u64>, entries: &'a [AlbumEntry], width: f32, + empty_text: Option<String>, } impl<'a> AlbumPicker<'a> { /// `id` must be unique among the pickers on screen (it names their widgets and keeps their /// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order. pub fn new(id: impl Into<String>, value: &'a mut Option<u64>, entries: &'a [AlbumEntry]) -> Self { - AlbumPicker { id: id.into(), value, entries, width: 180.0 } + AlbumPicker { id: id.into(), value, entries, width: 180.0, empty_text: None } } /// The button's width (180 by default). @@ -187,13 +188,27 @@ impl<'a> AlbumPicker<'a> { self } + /// Text for an empty choice (already localized by the caller); the default is "Choose an album…". + pub fn empty_text(mut self, text: impl Into<String>) -> Self { + self.empty_text = Some(text.into()); + self + } + /// The button and, while open, the popup. The response is `changed()` when an album was picked. pub fn show(self, ui: &mut Ui) -> Response { - let AlbumPicker { id, value, entries, width } = self; + self.show_with_choices(ui, |_| false) + } + + /// Draw caller-owned choices before the search (e.g. None / New album in the import review). + /// Return true when an extra choice was picked to close the popup. The caller handles those + /// actions; `changed()` still reports only an album picked from the tree or search. + pub fn show_with_choices(self, ui: &mut Ui, choices: impl FnOnce(&mut Ui) -> bool) -> Response { + let AlbumPicker { id, value, entries, width, empty_text } = self; let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder)); - let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c)); + let text = chosen.map_or_else(|| empty_text.unwrap_or_else(|| crate::i18n::tr("Choose an album…").to_string()), |c| path_label(entries, c)); // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) - let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); + let mut button = + ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); register(ui.ctx(), format!("albumPicker:{id}"), button.rect); @@ -206,8 +221,15 @@ impl<'a> AlbumPicker<'a> { let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); let mut picked: Option<u64> = None; egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_min_width(width.max(260.0)); - ui.set_max_width(width.max(260.0) + 120.0); + // A wide popup remembered before a window resize must still fit the current viewport. + let margin = egui::Frame::popup(ui.style()).total_margin().sum().x; + let max_width = (ui.ctx().content_rect().width() - margin).max(1.0); + ui.set_min_width(width.max(260.0).min(max_width)); + ui.set_max_width((width.max(260.0) + 120.0).min(max_width)); + if choices(ui) { + ui.close(); + return; + } let search_box = ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect); @@ -227,7 +249,9 @@ impl<'a> AlbumPicker<'a> { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); - let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label)); + let resp = ui + .add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate()) + .on_hover_text(label); register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect); let resp = match &e.blocked { Some(why) => resp.on_disabled_hover_text(why.as_str()), @@ -390,6 +414,23 @@ mod tests { assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); } + /// Cycles, dangling parents and excessively deep chains finish with bounded paths and depths. + #[test] + fn damaged_parent_chains_are_bounded() { + let mut entries = vec![e(1, "Loop A", Some(2), true, false), e(2, "Loop B", Some(1), true, false), e(3, "Photo", Some(1), false, false)]; + set_depths(&mut entries); + assert!(ancestors(&entries, 3).len() <= 3); + assert_eq!(search(&entries, "photo"), vec![2]); + assert_eq!(visible_tree(&entries, &[1, 2]), vec![0, 1, 2]); + let mut deep: Vec<_> = (1..1000).map(|id| e(id, "Deep", Some(id + 1), true, false)).collect(); + deep.push(e(1000, "End", None, true, false)); + set_depths(&mut deep); + assert!(deep.iter().all(|entry| entry.depth <= 65)); + assert!(ancestors(&deep, 1).len() <= 65); + assert!(path_label(&deep, 1).split(" / ").count() <= 66); + assert_eq!(path_label(&[e(1, "Orphan", Some(99), false, false)], 1), "Orphan"); + } + /// Big libraries stay quick: 20,000 albums in nested folders are laid out and searched in well /// under a second (folder paths are worked out once, not per row by scanning every album). #[test] diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs index 83f6e186b..1d31d44d2 100644 --- a/crates/ui-egui/src/brush_live.rs +++ b/crates/ui-egui/src/brush_live.rs @@ -9,7 +9,7 @@ use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; use lightcraft_geom::Point; -use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures}; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs_counted, push_pressures}; /// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. const MAX_SIDE: usize = 4096; @@ -41,6 +41,8 @@ pub struct LiveStroke { alpha: Vec<f32>, /// Samples stamped so far, and the last one (buffer pixels). placed: usize, + /// Total dabs emitted across every stamp, including the first sample. + emitted: usize, last: Option<Point>, /// The pen pressure of the last stamped sample. last_pressure: f32, @@ -54,7 +56,7 @@ impl LiveStroke { let scale = buffer_scale(params.clip, params.scale); let params = LiveParams { scale, ..params }; let (w, h) = buffer_size(params.clip, scale); - LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, emitted: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } } /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. @@ -86,7 +88,7 @@ impl LiveStroke { for (k, q) in new.iter().enumerate() { let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); let before = dabs.len(); - if push_dabs(&mut dabs, self.last, q, r) { + if push_dabs_counted(&mut dabs, &mut self.emitted, self.last, q, r) { let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0); let from = if self.last.is_some() { self.last_pressure } else { pq }; push_pressures(&mut pressures, dabs.len() - before, from, pq); @@ -253,6 +255,39 @@ mod tests { assert_eq!(once.alpha, steps.alpha); } + /// Crossing the budget in separate frames must match one stamp, including pressure state. + #[test] + fn incremental_stamping_respects_the_whole_stroke_budget() { + use lightcraft_pipeline::masks::MAX_STROKE_DABS; + + let pts = [pos2(20.0, 20.0), pos2(80.0, 20.0), pos2(200.0, 80.0), pos2(21.0, 20.0)]; + let pressure = [0.2, 0.4, 1.0, 0.9]; + let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); + once.emitted = MAX_STROKE_DABS - 3; + once.stamp(&pts, &pressure); + let mut steps = LiveStroke::new(once.params); + steps.emitted = MAX_STROKE_DABS - 3; + for n in 1..=pts.len() { + steps.stamp(&pts[..n], &pressure[..n]); + } + assert_eq!(once.alpha, steps.alpha); + assert_eq!(once.emitted, MAX_STROKE_DABS); + assert_eq!(steps.emitted, once.emitted); + assert_eq!(once.last, Some(Point::new(80.0, 20.0))); + assert_eq!(steps.last, once.last); + assert_eq!(once.last_pressure, pressure[1]); + assert_eq!(steps.last_pressure, once.last_pressure); + assert!(once.coverage(80, 20) > 0.0); + assert_eq!(once.coverage(200, 80), 0.0); + let alpha = steps.alpha.clone(); + let more = [pts[0], pts[1], pts[2], pts[3], pos2(300.0, 100.0)]; + steps.stamp(&more, &[1.0; 5]); + assert_eq!(steps.alpha, alpha, "later frames cannot paint or reconnect discarded samples"); + assert_eq!(steps.last, once.last); + assert_eq!(steps.last_pressure, once.last_pressure); + assert_eq!(steps.emitted, MAX_STROKE_DABS); + } + #[test] fn hostile_input_paints_nothing_and_does_not_panic() { let mut s = LiveStroke::new(params()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 097b13c4d..efb3ea3ac 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -438,7 +438,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest Outcome::Screenshot { path: s("path").map(str::to_string), headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) } } "ui.render" => { - let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or(app.session.active()); + let id = match p.get("id").filter(|v| !v.is_null()) { + Some(v) => match v.as_u64() { + Some(n) => Some(lightcraft_catalog::PhotoId(n)), + None => return err("`id` must be a photo id (a whole number)"), + }, + None => app.session.active(), + }; let Some(id) = id else { return err("no photo") }; if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) { return err(e); @@ -484,13 +490,16 @@ pub fn default_export_dir() -> String { pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { use lightcraft_engine::export::{Destination, ExportOptions, export_batch}; let p = &app.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; - if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) { - let ext = path.rsplit_once('.').map_or("", |(_, e)| e); - opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format); - } - 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(), + // the format follows `path` (JPEG when it names none), and `bitDepth` is checked against it + let opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; + let ids: Vec<_> = match p.get("ids").filter(|v| !v.is_null()) { + // `ids` that aren't photo ids are an error, not "export the selection" + Some(v) => v + .as_array() + .ok_or("app.export: `ids` must be a list of photo ids")? + .iter() + .map(|v| v.as_u64().map(lightcraft_catalog::PhotoId).ok_or("app.export: `ids` must be photo ids (whole numbers)")) + .collect::<Result<_, _>>()?, None if !app.session.selection.ids.is_empty() => { // Batch order (and `{seq}`) follows the grid order. let sel: std::collections::HashSet<_> = app.session.selection.ids.iter().copied().collect(); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 364fac08f..e9f51a56f 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -334,6 +334,9 @@ pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { let panel = if id == "panel.presets" && app.ui.presets { Some(Panel::Presets) + } else if id == "app.showInFolders" { + // the sidebar it scrolls, also when it is a tab behind another panel + Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6178990b0..acd513e98 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -838,6 +838,70 @@ mod tests { assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); } + /// #1877: a right-click while another photo's context menu is open closes that menu and + /// opens this photo's, which stays open (it flashed and closed again). + #[test] + fn right_clicking_elsewhere_moves_the_context_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let thumbs: Vec<String> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect(); + let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") }; + let popup = |w: &str| { + let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id"); + egui::Id::new(("cell", id)).with("popup") + }; + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open"); + // A real click: the press and the release reach the UI on different frames, with the + // pointer resting on the photo in between (`ui.clickWidget` sends both on one frame). + let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen"); + let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y); + let button = egui::PointerButton::Secondary; + let modifiers = egui::Modifiers::NONE; + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + // one frame at a time: the menu flashed for a frame or two before it closed + for frame in 0..6 { + h.step(); + assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed"); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open"); + // The same photo again, elsewhere on it (the menu that is open belongs to this very + // widget): the menu moves to the pointer and stays. + let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y); + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + for frame in 0..6 { + h.step(); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open"); + let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})"); + } + /// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. #[test] fn up_and_down_move_through_the_grid_rows() { @@ -1772,6 +1836,84 @@ mod tests { h.settle(SETTLE); } + /// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. + #[test] + fn hovering_the_applied_profile_drops_the_last_hover() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); + h.step(); + assert!(h.app.hover_preview.is_some(), "another profile previews"); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.step(); + assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); + } + + /// The preset applied to a photo is still marked (and its Amount slider back) after visiting + /// another photo, which shows none (issue #2392); an edit to the photo drops the mark. + #[test] + fn applied_preset_is_marked_again_on_returning_to_the_photo() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + let marked = |h: &Headless| { + let r = h.app.widgets.iter().find(|(id, _)| id == "presetApplied").map(|(_, r)| *r)?; + h.app.widgets.iter().find(|(id, wr)| id.starts_with("preset:") && *wr == r).map(|(id, _)| id.clone()) + }; + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(marked(&h).as_deref(), Some("preset:lc.warm-glow")); + assert!(has(&h, "slider:presetAmount")); + for (photo, want) in [(b, None), (a, Some("preset:lc.warm-glow"))] { + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h).as_deref(), want, "on photo {photo:?}"); + assert_eq!(has(&h, "slider:presetAmount"), want.is_some(), "the Amount slider follows the mark"); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h), None, "an edit after the preset drops the mark"); + } + + /// A preset applied while the active photo lies outside the selection changes only the + /// selection, so the active photo isn't marked as having it. + #[test] + fn applied_preset_is_not_marked_on_an_active_photo_outside_the_selection() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + h.app.session.selection.ids = vec![b]; + h.app.session.selection.active = Some(a); + let before = h.app.session.develop_of(a).unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.session.active(), Some(a)); + assert!(*h.app.session.develop_of(a).unwrap() == *before, "the active photo wasn't changed"); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "nor marked"); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [b.0]}}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "the selected photo is"); + } + /// Presets column: resting on a preset previews it in the loupe without a history entry; /// thumbnails are variant renders; Create Preset includes only the checked groups. #[test] @@ -2174,6 +2316,54 @@ mod tests { assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); } + /// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made + /// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, + /// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. + #[test] + fn export_dialog_drops_a_depth_the_format_lacks() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let opened = |h: &mut Headless, last: Value| { + h.app.ui.dialog = None; + h.app.session.last_export = Some(last); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), + _ => panic!("the export dialog is open"), + } + }; + let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(o.bit_depth, None); + lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); + assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); + } + + /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its + /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. + #[test] + fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + for name in ["output", "output.xyz"] { + let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], false, "{name}: {r}"); + assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); + } + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still JPEG + let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); + assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); + let _ = std::fs::remove_dir_all(&out); + } + #[test] fn export_dialog_runs_in_the_background() { let mut h = demo([1200.0, 900.0]); @@ -2629,12 +2819,14 @@ mod tests { h.request("ui.key", json!({"key": "O", "shift": true}), t); assert_eq!(h.app.ui.crop_overlay_orient, 1); assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some(); - assert!(!locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); assert!(locked(&h)); h.request("ui.key", json!({"key": "A"}), t); assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); assert!(!h.app.ui.visualize_spots); } @@ -2816,15 +3008,56 @@ mod tests { assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); assert!(dir.is_dir(), "the folder itself is untouched"); + // a second hidden place: the row lists them, and each comes back on its own (issue #2114: + // the row used to bring every hidden place back at once) + let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir2).unwrap(); + let path2 = dir2.to_string_lossy().to_string(); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let menu = |h: &mut Headless| -> Vec<String> { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() + }; + assert!(menu(&mut h).is_empty(), "the list opens from the row"); assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty()); + let items = menu(&mut h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); + assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); + // two hidden again: Restore All brings every one back + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); if !hidden_before { // (with Home hidden for this test, the folder is back inside Home's tree instead) assert!(ids(&mut h).contains(&format!("source:local:{path}"))); } let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); } /// Local: a kept folder stays a root while its subfolders (and other locations) are @@ -3228,6 +3461,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: + /// untouched Local photos are only forgotten from folders not browsed for the set number of days. + #[test] + fn browsing_from_the_ui_records_the_folder() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); + let day = dir.join("2026/10/14"); + std::fs::create_dir_all(&day).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let day_s = day.to_string_lossy().to_string(); + assert!(h.app.session.catalog.last_browsed(&root).is_none()); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); + assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by /// another app, as `library.browse` does (issue #1392). #[test] @@ -4292,6 +4549,68 @@ mod tests { h.settle(SETTLE); } + /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. + #[test] + fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + if locale != Locale::En { + assert_ne!(expected, key, "this locale must translate the tested message"); + } + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } + } + + /// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts + /// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). + #[test] + fn view_options_choose_the_info_overlay_lines() { + use crate::state::{LoupeInfo, LoupeInfoField as F}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] @@ -4334,6 +4653,43 @@ mod tests { h.settle(SETTLE); } + /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the + /// loupe render is, not a preview-level render the GPU shrinks. + #[test] + fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); + } + + /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of + /// rising to the preview level. + #[test] + fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); + } + #[test] fn trackpad_navigation_keeps_the_image_cursor_between_events() { let mut h = demo([1000.0, 700.0]); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9ba6c5462..158a00664 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -130,6 +130,43 @@ language_table! { Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); + Sv, "sv", "Svenska", "Latn", include_str!("../locales/sv.json"); + Fi, "fi", "Suomi", "Latn", include_str!("../locales/fi.json"); + Id, "id", "Bahasa Indonesia", "Latn", include_str!("../locales/id.json"); + Vi, "vi", "Tiếng Việt", "Latn", include_str!("../locales/vi.json"); +} + +impl Locale { + /// Every language in the order the Settings row and Edit ▸ Language list them: English first + /// (the source language), then the rest by their own name with accents folded, so Čeština + /// sits under C. Names in other scripts follow the Latin ones in code point order: Cyrillic, + /// Georgian, then the CJK languages. + pub fn for_picker() -> Vec<Self> { + let mut rest: Vec<Self> = Self::ALL.iter().copied().filter(|l| *l != Self::En).collect(); + rest.sort_by_cached_key(|l| name_sort_key(l.name())); + std::iter::once(Self::En).chain(rest).collect() + } +} + +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -149,11 +186,33 @@ impl<'de> Deserialize<'de> for Locale { thread_local! { static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) }; + /// The host's answer for the "Auto" language ([`set_default_language`]). + static DEFAULT_LANGUAGE: std::cell::Cell<Option<Locale>> = const { std::cell::Cell::new(None) }; } -/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. +/// Install the language the "Auto" setting resolves to when nothing is stored. The browser host +/// calls this once at startup with the browser's own languages ([`locale_from_tags`]); the +/// desktop host doesn't: there Auto keeps following the environment. Must run before the UI state +/// is first constructed. +pub fn set_default_language(language: Locale) { + DEFAULT_LANGUAGE.with(|slot| slot.set(Some(language))); +} + +/// The first of `tags` (BCP-47, most preferred first, as `navigator.languages` lists them) that +/// names a shipped language; None when none does. +pub fn locale_from_tags(tags: &[String]) -> Option<Locale> { + tags.iter().find_map(|tag| Locale::parse_tag(tag)) +} + +/// The UI language "Auto" resolves to: the environment's (`LIGHTCRAFT_LANGUAGE=zh-hans`, for +/// headless runs), or the host-installed default ([`set_default_language`], the browser's languages +/// on the web), else English. pub fn default_language() -> Locale { - std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) + std::env::var("LIGHTCRAFT_LANGUAGE") + .ok() + .and_then(|value| Locale::parse_tag(&value)) + .or_else(|| DEFAULT_LANGUAGE.with(std::cell::Cell::get)) + .unwrap_or(Locale::En) } pub fn set_language(language: Locale) { @@ -534,6 +593,45 @@ mod tests { set_language(Locale::En); } + #[test] + fn indonesian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.indonesian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Id); + assert_eq!(Locale::Id.name(), "Bahasa Indonesia"); + assert_eq!(Locale::Id.script(), "Latn"); + assert_eq!(crate::menubar::checked(&app, "app.language.indonesian"), Some(true)); + assert_eq!(tr("Exposure"), "Eksposur"); + assert_eq!(tr("My Photos"), "Foto saya"); + assert_eq!(date_group_label("2026-09-20", false), "Minggu, 20/09/2026"); + assert_eq!(date_group_label("2026-09-20", true), "Minggu, 20"); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Minggu, 20/09/2026 16:04:05"); + // Indonesian keeps a noun unchanged after a count ("1 foto", "22 foto"), so the English + // plural suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 22] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("{n} foto diimpor")); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("22 foto ditambahkan ke “{name}”")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"id""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Id); + let ctx = egui::Context::default(); + for tag in ["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Id)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Id); + } + set_language(Locale::En); + } + #[test] fn sentence_case_keeps_georgian_lowercase() { assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); @@ -543,6 +641,46 @@ mod tests { assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); } + #[test] + fn swedish_switches_persists_and_formats_without_changing_user_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.swedish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Sv); + assert_eq!(Locale::Sv.name(), "Svenska"); + assert_eq!(Locale::Sv.script(), "Latn"); + assert_eq!(crate::menubar::checked(&app, "app.language.swedish"), Some(true)); + assert_eq!(tr("File"), "Fil"); + assert_eq!(tr("Develop"), "Framkalla"); + assert_eq!(tr("Exposure"), "Exponering"); + assert_eq!(date_group_label("2026", false), "2026"); + assert!(date_group_label("2026-09-20", false).starts_with("Söndag 20 "), "{}", date_group_label("2026-09-20", false)); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"sv\""), "{saved}"); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Sv); + let ctx = egui::Context::default(); + for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Sv), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Sv); + } + let text = painted_text(&ctx, &mut app, Locale::Sv); + assert!(text.contains("Fil") && text.contains("Exponering"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "åäöÅÄÖé”’".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Swedish glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + #[test] fn every_language_has_a_catalog_covering_the_source() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); @@ -633,9 +771,26 @@ mod tests { for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); } + for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}"); + } assert_eq!(Locale::parse_tag("xx"), None); } + /// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first + /// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English + /// ([`default_language`] fallback). + #[test] + fn locale_from_tags_takes_the_first_shipped_language() { + assert_eq!(locale_from_tags(&[]), None); + assert_eq!(locale_from_tags(&["xx-XX".into()]), None); + assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); + assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); + assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); + assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); + assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); + } + #[test] fn switching_language_translates_and_restores() { set_language(Locale::ZhHans); @@ -749,6 +904,12 @@ mod tests { }); } + /// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped + /// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, + /// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads + /// the same and every face has it. + const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; + #[test] fn translated_catalogs_do_not_use_missing_menu_triangles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); @@ -759,7 +920,9 @@ mod tests { } let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); for (source, text) in messages { - assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display()); + for bad in UNPAINTABLE { + assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); + } } } } @@ -782,6 +945,10 @@ mod tests { ("app.language.czech", Locale::Cs), ("app.language.italian", Locale::It), ("app.language.georgian", Locale::Ka), + ("app.language.swedish", Locale::Sv), + ("app.language.finnish", Locale::Fi), + ("app.language.indonesian", Locale::Id), + ("app.language.vietnamese", Locale::Vi), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -1164,6 +1331,110 @@ mod tests { set_language(Locale::En); } + /// Finnish's coverage of every catalog message and display label, reported like Italian's. + #[test] + fn finnish_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::Fi.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("fi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("fi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn finnish_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.finnish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Fi); + assert_eq!(crate::menubar::checked(&app, "app.language.finnish"), Some(true)); + assert_eq!(tr("Light"), "Valo"); + assert_eq!(tr("Landscape"), "Maisema"); + assert_eq!(tr("Home"), "Kotikansio"); + // Finnish dates have no leading zeros and a lower-case weekday; times are 24-hour with colons. + assert_eq!(date_group_label("2026-09-20", false), "sunnuntai 20.9.2026"); + assert_eq!(date_group_label("2026-09-20", true), "sunnuntai 20."); + assert_eq!(date_group_label("2026-09", false), "9/2026"); + assert_eq!(date_group_label("2026-09", true), "9"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "sunnuntai 20.9.2026 16:04:05"); + // A noun after a numeral is in the partitive singular (1 kuva, 2 kuvaa), which the English + // plural suffix cannot choose, so counts follow a label; the suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Tuotuja kuvia: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Tiedostoja: {n}, kansioita: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + let expected = if n == 1 { "1 välimuistissa oleva kuva".to_string() } else { format!("{n} välimuistissa olevaa kuvaa") }; + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Kohinanpoisto IMG.CR3: 1 / 4 ruutua · odottaa: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Lisättyjä kuvia kohteeseen ”{name}”: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Lämmin hehku"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"fi""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Fi); + let ctx = egui::Context::default(); + for code in ["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::Fi)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Fi); + } + set_language(Locale::En); + } + + #[test] + fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains("Omat kuvat") && text.contains("Kaikki kuvat"), "{text}"); + for (command, title) in [("app.about", "Tietoja LightCraftista"), ("app.shortcuts", "Pikanäppäimet"), ("app.settings", "Asetukset")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "äöåÄÖÅ”–" + .chars() + .chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// catalog can translate. What a language lacks is reported, not failed (like /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every @@ -1305,7 +1576,7 @@ mod tests { } #[test] - fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() { fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { match shape { egui::epaint::Shape::Text(shape) => { @@ -1315,7 +1586,7 @@ mod tests { _ => {} } } - for language in [Locale::De, Locale::Uk, Locale::It] { + for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] { let ctx = fonts_ctx(&[]); let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); @@ -1592,4 +1863,210 @@ mod tests { assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); set_language(Locale::En); } + + /// English catalog values Vietnamese keeps on purpose. Grouped by reason: + /// standards/acronyms (`ISO`, `HDR`, …), product names (`LightCraft`, `PhotoCraft`), + /// file-type tokens (`Raw`/`raw`, `video`), look names left as established labels + /// (`Bleach Bypass`, `Vintage`), and machine ids that must stay byte-identical + /// (`publicDomain`, `denoiseModel.accept`). `Album` stays as the common loanword. + /// Every other required label must differ from its English source. + const VI_INTENTIONAL_ENGLISH: &[&str] = &[ + "OK", + "ISO", + "RGB", + "HDR", + "JPEG", + "PNG", + "TIFF", + "WebP", + "AVIF", + "HEIF", + "HEIC", + "DNG", + "ICC", + "EXIF", + "XMP", + "GPU", + "MCP", + "SAM", + "RAW", + "Raw", + "raw", + "B&W", + "LUT", + "LightCraft", + "ArtCraft", + "GitHub", + "Discord", + "English", + "日本語", + "繁體中文(台灣)", + "Display P3", + "Bleach Bypass", + "Cross Process", + "HDR…", + "video", + "Video", + "true", + "false", + "Album", + "Vintage", + "publicDomain", + "denoiseModel.accept", + "Panorama", + "Panorama…", + "PhotoCraft", + "SDR", + "Matrix/TRC", + "LUT-based", + ]; + + /// Vietnamese's coverage of every catalog message and display label, reported like Italian's; the + /// messages it has are never left in English. + #[test] + fn vietnamese_covers_catalogs_commands_controls_and_rules() { + let catalog = Locale::Vi.catalog(); + assert!(!catalog.is_empty()); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("vi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { + labels.push(*word); + } + for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] { + labels.push(day); + } + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("vi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + + for (key, value) in catalog { + if key.contains('|') { + continue; + } + if VI_INTENTIONAL_ENGLISH.contains(&key.as_str()) { + continue; + } + assert!(!value.is_empty(), "vi empty for {key:?}"); + // Identical values outside the allowlist are accidental English fallbacks. + if value == key && key.chars().any(|ch| ch.is_ascii_alphabetic()) && key.len() > 1 { + // Single-letter ops / symbols may match; longer shared English must be listed. + assert!( + key.len() <= 2 || key.chars().all(|ch| !ch.is_ascii_alphabetic()), + "vi keeps English for {key:?}; add it to VI_INTENTIONAL_ENGLISH if intentional" + ); + } + } + } + + #[test] + fn vietnamese_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.vietnamese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Vi); + assert_eq!(crate::menubar::checked(&app, "app.language.vietnamese"), Some(true)); + assert_eq!(tr("Light"), "Ánh sáng"); + assert_eq!(tr("Exposure"), "Phơi sáng"); + assert_eq!(Locale::Vi.name(), "Tiếng Việt"); + assert_eq!(tr("Settings"), "Cài đặt"); + assert_eq!(tr("Appearance"), "Giao diện"); + assert_eq!(tr("My Photos"), "Ảnh của tôi"); + assert_eq!(tr("Candidate"), "Ảnh ứng viên"); + assert_eq!(tr("Subject"), "Chủ thể"); + assert_eq!(date_group_label("2026-09-20", false), "Chủ Nhật, 20/9/2026"); + assert_eq!(date_group_label("2026-09", false), "Tháng 9 năm 2026"); + assert_eq!(date_group_label("2026-09", true), "Tháng 9"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Chủ Nhật, 20/9/2026 16:04:05"); + for n in [0, 1, 2, 5, 22] { + let text = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(text.contains(&n.to_string()) && text.contains("ảnh"), "{n}: {text}"); + assert!(!text.contains("photos") && !text.contains("photo"), "{n}: {text}"); + } + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Đã thêm 22 ảnh vào «{name}»")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_ne!(builtin_label("Warm Glow", true), "Warm Glow"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"vi""#), "{saved}"); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Vi); + let ctx = egui::Context::default(); + for code in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::Vi)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Vi); + } + set_language(Locale::En); + } + + #[test] + fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains("Ảnh của tôi") && text.contains("Tất cả ảnh"), "{text}"); + for (command, title) in [("app.about", "Giới thiệu LightCraft"), ("app.shortcuts", "Phím tắt"), ("app.settings", "Cài đặt")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain( + Locale::Vi + .catalog() + .values() + .flat_map(|text| text.chars()) + .filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)), + ) { + assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}"); + } + } + }); + set_language(Locale::En); + } +} + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = Locale::for_picker(); + assert_eq!(order.first(), Some(&Locale::En)); + assert_eq!(order.len(), Locale::ALL.len()); + let keys: Vec<String> = order[1..].iter().map(|l| name_sort_key(l.name())).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + assert_eq!(name_sort_key("Čeština"), "cestina"); + } + + #[test] + fn language_menu_lists_the_picker_order() { + let menu: Vec<&str> = crate::menus::LANGUAGE_COMMANDS.iter().map(|c| c.1).collect(); + let picker: Vec<&str> = Locale::for_picker().into_iter().map(Locale::name).collect(); + assert_eq!(menu, picker, "Edit ▸ Language and the Settings row list the languages the same way"); + } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 15434237a..0ef3e0448 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -37,6 +37,9 @@ pub enum Icon { StarFilled, Circle, FlagPick, + /// The Pick flag a photo carries: pennant filled, so a pick reads at a glance (#1013). The + /// outlined [`Icon::FlagPick`] stays for buttons and filters. + FlagPickFilled, FlagReject, Gear, Filmstrip, @@ -387,6 +390,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); } + FlagPickFilled => { + pen.line(&[(5.0, 17.0), (5.0, 3.5)]); + // the notched pennant is concave: two convex halves meet at the notch, overlapping + // along the pole side so their anti-aliased edges leave no seam + pen.fill(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (5.0, 7.5)], color); + pen.fill(&[(5.0, 6.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)], color); + pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); + } FlagReject => { pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index e851463d1..323d8d902 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -584,6 +584,12 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.session.import_probes = out.probes; if task.browse { + // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so + // their untouched Local photos aren't forgotten while the folder is in use + if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { + let shown: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.note_browsed(&root, &shown); + } // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, // as `library.browse` forgets them (issue #1392) if let Some(b) = app.session.browse.clone() { @@ -1238,40 +1244,49 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { ); }); } - let albums: Vec<(u64, String)> = { - let mut v: Vec<(u64, String)> = - app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); - v.sort_by_key(|(_, n)| n.to_lowercase()); - v - }; + let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); + albums.retain(|a| a.folder || !a.smart); + // Catalog changes while the review is open must not leave a hidden, unselectable destination. + d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); field(ui, "Album", |ui| { - let cur = match d.album { - Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(), - None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(), - None => crate::i18n::tr("None").into(), - }; - crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { - d.album = None; + ui.vertical(|ui| { + let mut chosen = d.album; + let mut extra = None; + let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); + let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) + .width(ui.available_width()) + .empty_text(empty) + .show_with_choices(ui, |ui| { + let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); + register(ui.ctx(), "importAlbum:none", none.rect); + if none.clicked() { + extra = Some(false); + } + let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); + register(ui.ctx(), "importAlbum:new", new.rect); + if new.clicked() { + extra = Some(true); + } + ui.separator(); + extra.is_some() + }); + if response.changed() { + d.album = chosen; d.new_album.clear(); } - if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() { + if let Some(new) = extra { d.album = None; - if d.new_album.is_empty() { + if !new { + d.new_album.clear(); + } else if d.new_album.is_empty() { d.new_album = crate::i18n::tr("Imported Photos").into(); } } - for (id, name) in &albums { - if ui.selectable_label(d.album == Some(*id), name).clicked() { - d.album = Some(*id); - d.new_album.clear(); - } + if d.album.is_none() && !d.new_album.is_empty() { + let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); + register(ui.ctx(), "field:importAlbumName", r.rect); } }); - if d.album.is_none() && !d.new_album.is_empty() { - let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); - register(ui.ctx(), "field:importAlbumName", r.rect); - } }); field(ui, "Preset", |ui| { let cur = app diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..1b2517907 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod pick; pub mod print_view; pub mod recheck; pub mod region; +pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; @@ -98,6 +99,8 @@ mod tests_grading; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_guided_guides; +#[cfg(test)] mod tests_info_dialogs; #[cfg(test)] mod tests_keymap; @@ -140,6 +143,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_profile_menu; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; @@ -148,6 +153,8 @@ mod tests_recheck; #[cfg(test)] mod tests_rename_person; #[cfg(test)] +mod tests_rules_editor; +#[cfg(test)] mod tests_sam_setup; #[cfg(test)] mod tests_scroll; @@ -638,6 +645,11 @@ impl LightcraftApp { // Keep it hidden after release; O / the overlay eye can show it again. self.ui.mask_overlay = false; } + if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") { + // A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider + // drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800). + self.ui.mask_overlay = true; + } if r.is_ok() { // These commands finish synchronously, so their completion would otherwise // be invisible once the activity stack is gone. Never show success on error. @@ -696,7 +708,10 @@ impl LightcraftApp { if d.crop != lightcraft_develop::Crop::default() { return d.crop.aspect; } - match self.ui.crop_default_aspect.as_ref()? { + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { Value::String(s) if s == "original" => { let p = self.session.catalog.photo(photo)?; let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); @@ -715,11 +730,13 @@ impl LightcraftApp { /// Called only after a crop gesture begins, inside its undo transaction. pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); if self.session.interaction.is_some() && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && let Some(aspect) = self.ui.crop_default_aspect.clone() && aspect != serde_json::json!("free") - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) { // A remembered value the command refuses (a hand-edited settings file) would refuse // every crop drag on an untouched photo: forget it and let this drag go ahead. @@ -823,6 +840,8 @@ impl LightcraftApp { ctx.request_repaint(); } if let Some(mask) = polled.refine { + // a new selection from a click or a description: show it, as `run` does for new masks + self.ui.mask_overlay = true; self.ui.detail_due = Some((now + 0.3, mask)); } if let Some(e) = polled.messages.into_iter().last() { @@ -1749,6 +1768,10 @@ impl Caches { self.folder_tree = Some((cat.revision, t.clone())); t } + /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). + pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option<std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>> { + self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) + } /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> { let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index d7ea85122..2bda28cf4 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -228,13 +228,16 @@ fn spawn_finalize( unsupported_wasm().map(|_| ()) } +/// What `status` and the result window show for an inspected catalog: counts, missing originals, +/// warnings and skipped images. #[cfg(not(target_arch = "wasm32"))] fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value { 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| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(), + "missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::<Vec<_>>(), "warnings": data.warnings, + "skipped": data.skipped, }) } @@ -298,6 +301,8 @@ fn show_result(app: &mut LightcraftApp) { } } +/// The lasting result window for an inspect or import: counts, failures, missing originals, the +/// skipped images (one line, in the UI language) and at most eight warnings. fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { let report = value.get("report").unwrap_or(value); let mut details = Vec::new(); @@ -319,6 +324,15 @@ fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { missing = missing )); } + // images whose catalog records are incomplete come as data (`skipped`), said here in the UI + // language; their ids are in the report for the CLI and agents + let skipped = report["skipped"].as_array().map_or(0, Vec::len); + if skipped > 0 { + details.push(crate::i18n::tr_format!( + "{skipped} catalog images were not imported because their records in the catalog are incomplete.", + skipped = skipped + )); + } if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) { details.push(warning.to_string()); } @@ -669,6 +683,40 @@ mod tests { assert!(h.app.lightroom_result.is_none()); } + #[test] + fn skipped_images_are_one_line_in_the_ui_language() { + let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [ + {"image": 7, "reason": "missingFile"}, + {"image": 8, "reason": "missingRoot"}, + {"image": 9, "reason": "masterSkipped"}, + ]}}); + let details = |locale| { + crate::i18n::set_language(locale); + let dialog = result_dialog(&value, "done"); + crate::i18n::set_language(crate::i18n::Locale::En); + let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") }; + details + }; + let en = details(crate::i18n::Locale::En); + assert_eq!(en.len(), 4, "{en:?}"); + assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete."); + assert_eq!(&en[2..], ["w1", "w2"]); + let es = details(crate::i18n::Locale::Es); + assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos."); + } + + #[test] + fn skipped_images_do_not_take_the_warning_slots() { + let warnings: Vec<_> = (0..10).map(|i| format!("w{i}")).collect(); + let skipped: Vec<_> = (0..500).map(|i| json!({"image": i, "reason": "missingFile"})).collect(); + let value = json!({"report": {"warnings": warnings, "skipped": skipped}}); + let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") }; + // one line for the skipped images, then the usual eight warnings and the rest counted + assert_eq!(details.len(), 10, "{details:?}"); + assert!(details[0].starts_with("500 catalog images were not imported")); + assert_eq!(details[9], "2 more warnings."); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { @@ -678,6 +726,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: vec!["warning".into()], + skipped: Vec::new(), }); assert_eq!(report["photos"], 0); assert_eq!(report["collections"], 0); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index c5e4e45c7..1ac3e9926 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -178,6 +178,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "compare.swap", "compare.makeSelect", + "---", + "view.viewOptions", ], ), ( @@ -212,6 +214,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.reload", "photo.readFocusPoints", "app.showInFinder", + "app.showInFolders", "dialog.rename", "dialog.captureTime", "photo.tagFromTracklog", @@ -962,9 +965,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 } if let Some((id, params)) = clicked { let r = run_item(app, &id, params); - // an export that can't start (e.g. no folder) says why instead of doing nothing + // an export that can't start (e.g. no folder), or a backup / restore the host refuses + // (changes still unsaved, nothing stored in this browser), says why instead of doing nothing if let Err(e) = r - && matches!(id.as_str(), "app.export" | "app.exportPrevious") + && matches!(id.as_str(), "app.export" | "app.exportPrevious" | "file.backupLibrary" | "file.restoreLibrary") { app.toast(ui.ctx(), e); } @@ -1044,6 +1048,7 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti b = b.shortcut_text(shortcut_text(sc, mac)); } let r = ui.add_enabled(*enabled, b); + crate::widgets::register(ui.ctx(), format!("menuitem:{id}"), r.rect); if *checked == Some(true) { let t = crate::theme::Tokens::get(ui.ctx()); let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); @@ -1390,6 +1395,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from + /// the menu, as a refused export does; the refusal used to reach only the control channel. + #[test] + fn a_refused_backup_from_the_menu_shows_why() { + // the host refuses, as the web one does while changes are still unsaved (issue #1508) + let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; + let services = crate::Services { + backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + let t = std::time::Duration::from_secs(30); + h.settle(t); + for id in ["menu:File", "menuitem:file.backupLibrary"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + } + // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); + } + /// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search /// failures as errors instead of returning `Ok(null)`. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..04d43e0d3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -15,19 +15,25 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static /// has a matching menu command (also listed through the control channel). Tests check coverage. pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), - ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), - ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), - ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), - ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), - ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + // The picker order (`Locale::for_picker`): alphabetical by the language's own name after + // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. + ("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"), + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), + ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), + ("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"), + ("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"), + ("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), - ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), - ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), - ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -54,6 +60,10 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.czech" => Some(crate::i18n::Locale::Cs), "app.language.italian" => Some(crate::i18n::Locale::It), "app.language.georgian" => Some(crate::i18n::Locale::Ka), + "app.language.swedish" => Some(crate::i18n::Locale::Sv), + "app.language.finnish" => Some(crate::i18n::Locale::Fi), + "app.language.indonesian" => Some(crate::i18n::Locale::Id), + "app.language.vietnamese" => Some(crate::i18n::Locale::Vi), _ => None, } } @@ -84,6 +94,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), + ("view.rightPanel", "Right Panel", None, "View"), ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), @@ -129,6 +140,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), @@ -257,6 +269,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.showInFolders", "Show in Folders", None, "Photo"), ("app.openSamModelFolder", "Show in File Manager", None, ""), ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), ("dialog.labelNames", "Edit Color Label Names…", None, ""), @@ -417,6 +430,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re let ctx = app.tasks.repaint.clone().unwrap_or_default(); return Some(crate::lightroom_import::command(app, id, p, &ctx)); } + if id == "photo.reload" { + // the files are read in the background (a raw's decode takes a second or more) + return crate::reload::command(app, p); + } if let Some(language) = language_from_command(id) { app.ui.language = language; // Immediately, not on the next frame: the reply and anything else run this frame @@ -583,12 +600,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re "view.reference" => { // the reference: the one set before, else the active photo (the next one becomes active) let vis = app.session.visible_cloned(); - let reference = app - .ui - .reference - .filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some()) - .or_else(|| app.session.active().map(|a| a.0)); - let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) }; + let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id)); + let active = app.session.active().map(|a| a.0); + let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a))); + let Some(r) = reference else { + return Some(Err(match active { + Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"), + None => "select a photo to use as the reference".into(), + })); + }; app.ui.reference = Some(r); if app.session.active().is_none_or(|a| a.0 == r) && let Some(i) = vis.iter().position(|x| x.0 == r) @@ -600,8 +620,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) } "photo.setReference" => { - let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0)); - app.ui.reference = id; + // only an absent (or null) `id` means the active photo; any other non-id is refused + let id = match p.get("id").filter(|v| !v.is_null()) { + Some(v) => match v.as_u64() { + Some(id) => Some(id), + None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))), + }, + None => app.session.active().map(|a| a.0), + }; + let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) }; + match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) { + None => return Some(Err(format!("no photo {id}"))), + Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))), + Some(_) => {} + } + app.ui.reference = Some(id); Ok(json!({"reference": id})) } "compare.swap" => crate::panels::compare::swap(app), @@ -632,7 +665,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid @@ -705,9 +738,30 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re crate::state::InfoOverlay::Basic => "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, label); + app.toast(&ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } + "view.viewOptions" => { + // `info1` / `info2` set the lines for an agent; without them the window opens + let mut info = app.ui.settings.loupe_info; + let mut set = false; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); + } + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info})) + } "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.trim().to_string(), @@ -789,6 +843,31 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.left_panel = !app.ui.left_panel; Ok(Value::Null) } + "view.rightPanel" => { + // Classic's F8: hide the right panel, and bring back the same one next time (Edit when + // none was hidden). Hiding a tool ends it, as closing its panel does. + if app.ui.right != RightPanel::None && crate::docking::exposed(app, crate::docking::Panel::Tools) { + app.ui.right_hidden = app.ui.right; + app.ui.right = RightPanel::None; + if app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); + } else { + let p = match std::mem::take(&mut app.ui.right_hidden) { + RightPanel::None => RightPanel::Edit, + p => p, + }; + app.ui.right = p; + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + if p == RightPanel::Remove && app.ui.tool.is_empty() { + app.ui.tool = "remove".into(); + } + } + Ok(json!({"open": app.ui.right != RightPanel::None})) + } "view.beforeAfter" => { app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; Ok(Value::Null) @@ -1410,7 +1489,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re let prev = app.session.last_export.clone().unwrap_or_default(); let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); - let opts = lightcraft_engine::export::ExportOptions::from_json(&prev); + // stored settings: a depth the format lacks (saved before #1952) opens at the format's own + let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev)); // no previous export: 2048 px long edge; a previous full-size export: full size let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); let resize = opts.resize.unwrap_or_default(); @@ -2106,6 +2186,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } "app.export" => crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), + "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), "app.openLogFolder" => open_log_folder(app), "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); @@ -2144,12 +2225,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "photo.openOriginalInExternal" => { + // only a file type LightCraft reads can be handed over (#2135); the extension alone + // decides, so no disk or share is touched while a menu is drawn app.services.open_with.is_some() - && app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + && app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source { + lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)), + _ => false, + }) } "app.showInFinder" => { app.services.reveal.is_some() @@ -2159,6 +2241,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .and_then(|id| app.session.catalog.photo(id)) .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } + "app.showInFolders" => show_in_folders_enabled(app, None), "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), @@ -2288,6 +2371,39 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> { Ok(json!({"path": path})) } +/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a +/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed +/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. +fn photo_folder(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Option<String> { + let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; + let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) +} + +/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its +/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to +/// look in, the folder alone decides, and the command still checks. +pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> bool { + let Some(dir) = photo_folder(app, id) else { return false }; + app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` +/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its +/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the +/// section and the folders above it open, and it scrolls there (`reveal_folder`). +fn show_in_folders(app: &mut LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Result<Value, String> { + let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; + let tree = app.caches.folder_tree(&app.session.catalog); + // the row's own spelling (forward slashes): what its click hands `library.source`, and its id + let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; + app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; + app.ui.left_panel = true; + app.ui.reveal_folder = Some(path.clone()); + Ok(json!({"path": path})) +} + /// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) /// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the /// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, @@ -2324,8 +2440,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: if let Some(id) = id { app.ui.external_edits.push(*id); } - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) { - failed = Some(e); + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } } } match (failed, copies.as_slice()) { @@ -2377,11 +2500,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { let mut files = Vec::new(); let mut skipped = Vec::new(); for id in ids { - match app.session.catalog.photo(id).map(|ph| &ph.source) { - Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), - Some(lightcraft_engine::catalog::Source::File { path }) => { - skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) - } + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d4c9a0bc6..61485a271 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -108,8 +108,6 @@ pub struct MergeState { pub last_result: Option<Value>, /// Photos the dialog merges (the selection when it opened). pub ids: Vec<PhotoId>, - /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. - pub last: std::collections::HashMap<String, MergeDialog>, /// HDR for Each Bracket Set: the sets still to merge after the running one. pub batch: std::collections::VecDeque<Vec<PhotoId>>, } @@ -148,7 +146,15 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } -/// Open the merge dialog for the selection. +/// The options of the last merge of kind `command` started (kept in the library preferences), or +/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their +/// defaults; options that don't parse at all fall back to the defaults. +pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog { + let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::<MergeDialog>(v.clone()).ok()); + MergeDialog { command: command.into(), ..saved.unwrap_or_default() } +} + +/// Open the merge dialog for the selection, with the options of the last merge of this kind. pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { let ids = app.session.selected(); if ids.len() < 2 { @@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } - app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) }); Ok(json!({"photos": app.merge.ids.len()})) } @@ -180,7 +186,10 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value, guard.progress(0, 1000); task.guard = Some(guard); app.merge.final_task = Some(task); - app.merge.last.insert(opts.command.clone(), opts.clone()); + app.session.last_merge.insert(opts.command.clone(), opts.params()); + if let Err(e) = app.session.save_prefs() { + log::warn!("merge: {e}"); + } Ok(json!({"started": true, "photos": ids.len()})) } @@ -192,7 +201,7 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin return Err("select at least 2 photos to merge".into()); } app.merge.ids = ids; - let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command)); + let opts = last_options(app, command); start_final(app, &opts) } @@ -217,7 +226,7 @@ pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> { let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) }; app.merge.ids = ids; - let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr")); + let opts = last_options(app, "merge.hdr"); start_final(app, &opts).map(|_| ()) } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 62cbe8968..69b76bcb8 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -132,6 +132,8 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } else { t.icon }; + // the Pick button shows the flag the photo carries: filled while it is a pick + let icon = if on && f == Flag::Pick { Icon::FlagPickFilled } else { icon }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 6c1b5ba18..bb7937640 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -23,11 +23,15 @@ pub fn culling(app: &LightcraftApp) -> bool { /// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next /// selected photo, else against its neighbour in the view. +/// A photo that can be shown or pinned: in the catalog and not in Recently Deleted. +pub fn live(app: &LightcraftApp, id: PhotoId) -> bool { + app.session.catalog.photo(id).is_some_and(|p| !p.deleted) +} + pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> { - let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted); if let Some((a, b)) = app.ui.compare { let (a, b) = (PhotoId(a), PhotoId(b)); - if a != b && exists(app, a) && exists(app, b) { + if a != b && live(app, a) && live(app, b) { return Some((a, b)); } } @@ -317,7 +321,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo super::detail::loupe_pill(ui, img_area, over, None, &format!("compare:rendering:{i}"), crate::i18n::tr("Rendering…"), false); } } - // caption: label · file name · stars · flag + // caption: label · file name · stars · flag · colour label // caption right under the image (at the bottom when zoomed in) let cap_top = (display.bottom() + 2.0).min(img_area.bottom()); let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h)); @@ -345,10 +349,18 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo x += 5.0 * 13.0 + 4.0; let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); match photo.flag { - Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPickFilled, t.pick), Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), _ => {} } + // the colour label as a dot, so labelling while culling shows on the photo (#2398) + let lr = Rect::from_min_size(pos2(fr.right() + 6.0, cap.center().y - 5.0), vec2(10.0, 10.0)); + if let Some(l) = photo.label + && lr.right() < cap.right() + { + pt.circle_filled(lr.center(), 5.0, super::grid::label_color(l)); + register(ui.ctx(), format!("cull:{}:label", id.0), lr); + } register(ui.ctx(), format!("cull:{}", id.0), display); (display, resp) } @@ -450,10 +462,17 @@ pub fn prepare(app: &mut LightcraftApp) { app.renderer.release(Slot::Region); app.renderer.release(Slot::RegionBefore); } - if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { + // Reference with no live pin draws no panes: release them as other views do. + let reference_shown = app.ui.reference.is_some_and(|r| live(app, PhotoId(r))); + if app.ui.view == ViewMode::Compare || (app.ui.view == ViewMode::Reference && reference_shown) { app.session.media.set_full_capacity(WINDOW_PANES as usize); return; } + if app.ui.view == ViewMode::Reference { + // the reference (pane 0) and the active photo beside it (pane 1) + app.renderer.release(Slot::Compare(0)); + app.renderer.release(Slot::Compare(1)); + } app.session.media.set_full_capacity(1); for i in 0..WINDOW_PANES { if app.compare_windows[i as usize].take().is_some() || app.renderer.textures.contains_key(&Slot::CompareRegion(i)) { @@ -494,7 +513,7 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.double_clicked() { app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100.0) } else { Zoom::Fit }; } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr("Swap")).clicked() { let _ = swap(app); } @@ -561,7 +580,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"})); } } - resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); + crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id)); } let n = photos.len(); let msg = if app.session.selection.ids.len() > SURVEY_MAX { @@ -578,7 +597,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// the Edit panel works on, so a look can be matched by eye. pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { let canvas = area_and_filmstrip(app, ui); - let reference = app.ui.reference.map(PhotoId).filter(|r| app.session.catalog.photo(*r).is_some()); + let reference = app.ui.reference.map(PhotoId).filter(|r| live(app, *r)); let (Some(r), Some(active)) = (reference, app.session.active()) else { super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)"); return; @@ -591,7 +610,7 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (_, lresp) = photo_tile(app, ui, r, Slot::Compare(0), left, "Reference", zoom); let (img, _) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom); app.image_rect = Some(img); - lresp.context_menu(|ui| { + crate::widgets::context_menu(&lresp, |ui| { if ui.button(crate::i18n::tr("Clear Reference")).clicked() { app.ui.reference = None; app.ui.view = ViewMode::Detail; diff --git a/crates/ui-egui/src/panels/crop_overlay.rs b/crates/ui-egui/src/panels/crop_overlay.rs index 37fe76a5e..763c4b9ab 100644 --- a/crates/ui-egui/src/panels/crop_overlay.rs +++ b/crates/ui-egui/src/panels/crop_overlay.rs @@ -45,12 +45,22 @@ pub fn paths(kind: CropOverlay, orient: u8, w: f32, h: f32) -> Vec<Vec<(f32, f32 } } -/// The golden spiral: squares carved off a `w × h` rectangle (left, top, right, bottom, …), a +/// The golden spiral: squares carved off a golden-ratio rectangle (left, top, right, bottom, …), a /// quarter circle in each, joined into one curve; plus the square boundaries. +/// +/// The squares come off a `PHI × 1` rectangle whatever the crop's shape is, and the normalisation +/// at the end stretches the result to fit. Carving them off the crop itself made the guide depend +/// on the crop: a square crop lost everything after the first arc, because the first subtraction +/// left no rectangle to carve, and other ratios gave different numbers of arcs (#2393). Only a +/// golden-ratio rectangle subdivides into squares forever, so it is the one shape that gives a +/// complete spiral; stretching it distorts the curve on a crop of another ratio, which is the +/// price of the guide staying complete and is what the report asks for. fn spiral(w: f32, h: f32) -> Vec<Vec<(f32, f32)>> { + // A `PHI × 1` rectangle less a `1 × 1` square is `1 × 1/PHI`, the same shape again. + const PHI: f32 = std::f32::consts::GOLDEN_RATIO; // work landscape; transpose back for portrait crops let portrait = h > w; - let (rw, rh) = if portrait { (h, w) } else { (w, h) }; + let (rw, rh) = (PHI, 1.0); let (mut x, mut y, mut cw, mut ch) = (0.0f32, 0.0f32, rw, rh); let mut curve = Vec::new(); let mut squares = Vec::new(); @@ -128,6 +138,27 @@ mod tests { assert_eq!(paths(CropOverlay::Triangle, 1, w, h)[0], vec![(1.0, 0.0), (0.0, 1.0)]); } + /// The spiral is the same complete guide at every crop shape. Carving the squares off the + /// crop itself made it depend on the crop: a square kept one arc of ten, 3:2 kept three and + /// 16:9 four, so changing the crop truncated the guide (#2393). + #[test] + fn the_spiral_is_complete_at_every_aspect_ratio() { + let shapes = [(1.0f32, 1.0f32), (1.618, 1.0), (1.0, 1.618), (3.0, 2.0), (2.0, 3.0), (16.0, 9.0), (9.0, 16.0), (4.0, 5.0)]; + let (squares, points) = { + let p = paths(CropOverlay::Spiral, 0, 1.618, 1.0); + (p.len() - 1, p.last().unwrap().len()) + }; + assert!(squares >= 8, "the golden rectangle itself gives a long spiral: {squares}"); + for (w, h) in shapes { + for o in 0..4 { + let p = paths(CropOverlay::Spiral, o, w, h); + assert_eq!(p.len() - 1, squares, "{w}x{h} orient {o}: {} squares, not {squares}", p.len() - 1); + assert_eq!(p.last().unwrap().len(), points, "{w}x{h} orient {o}: a shorter curve"); + assert!(in_unit(&p), "{w}x{h} orient {o}"); + } + } + } + #[test] fn spiral_is_one_continuous_curve() { for (w, h) in [(1.618, 1.0), (1.0, 1.618), (3.0, 2.0)] { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 44427565e..b95d7b056 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -60,6 +60,13 @@ pub enum Gesture { Guide { a: Point, }, + /// Guided Upright: dragging the endpoint `end` (0 = `guide.0`, 1 = `guide.1`) of guide `i`, + /// from where the pointer went down (`from` — egui forgets the press origin at the release). + GuideEndpoint { + i: usize, + end: usize, + from: Pos2, + }, /// Red Eye: an ellipse being dragged from corner `a` (normalized). Eye { a: Point, @@ -145,6 +152,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) } +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + /// Keep the viewport inside the photo; axes smaller than the viewport stay centred. fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { let bound = |visible: f32, image: f32, centre: f32| { @@ -531,7 +553,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); - let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + let now = ui.input(|i| i.time); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -558,18 +581,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet - let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); + // the drawn size in pixels, clamped first: a NaN or negative float must not become a size + let drawn_px = |points: f32| (points * ppp).round().clamp(0.0, 65_536.0) as usize; + let fit_px = app.size_hold.display_fit(now, navigating, [drawn_px(target_rect.width()), drawn_px(target_rect.height())]); if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } - let want = plan.main_edge; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // At rest the whole frame renders at no less than the preview source level: the source is then + // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near + // the output's limit) and shrunk once, by the Lanczos display fit below. + // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) + let want = if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + }; + // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 + // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along + // one axis only. A box that matches the source to rounding is rendered at exactly its size. + let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1); + let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); - let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; + let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); @@ -587,6 +631,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // once it is ready (nothing is committed) let hover_key = match app.hover_preview.clone() { Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit(fit_px[0], fit_px[1]); let key = job.key; app.renderer.request(Slot::Hover, job, 105); (key, h.label) @@ -625,6 +671,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more @@ -692,7 +740,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - let image_rect = fit_texture_rect(r, tex.size); + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; @@ -703,6 +751,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); } let shown; + // the Before label's pill, painted once the window renders are down (they would cover it) + let mut before_badge: Option<(Rect, std::sync::Arc<egui::Galley>)> = None; + // below what else lives in the canvas' top-left corner: the filter pill, the info overlay + let pill_inset = if !fullscreen && filter_pill_shown(app) { FILTER_PILL_INSET + FILTER_PILL_HEIGHT } else { 0.0 }; + let badge_top_inset = pill_inset.max(info_overlay_extent(app, &p, &photo)); if split { // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the // other view's margins with its whole-frame render, magnified and blurred) @@ -715,14 +768,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); - p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); + // on the photo itself, so it gets the hover label's dark pill to stay readable over light + // pictures; painted after the window renders below, which would cover it zoomed in + let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE); + before_badge = Some((label_badge_rect(img_rect, canvas, g.size(), badge_top_inset), g)); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { let hover_rect = fit_texture_rect(img_rect, tex.size); p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; @@ -773,6 +829,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); } + if let Some((bg, g)) = before_badge { + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + register(ui.ctx(), "canvas:beforeLabel", bg); + } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); @@ -897,7 +958,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { preview_only_pill(ui, canvas, why); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { if ui.button(crate::i18n::tr(label)).clicked() { @@ -910,56 +971,71 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date -/// and size, or with the camera and exposure. +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo +/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label +/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the +/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge. +fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect { + let size = text + vec2(16.0, 8.0); + let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left()); + let corner = pos2(left, photo.top().max(pane.top() + top_inset)); + Rect::from_min_size(corner + vec2(8.0, 8.0), size) +} + +/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for +/// Info 1 or Info 2 (`view.viewOptions`). fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { - use crate::state::InfoOverlay; - let lines: Vec<String> = match app.ui.info_overlay { - InfoOverlay::Off => return, - InfoOverlay::Basic => { - let date = photo.captured.as_deref().unwrap_or(&photo.imported); - vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)] - } - InfoOverlay::Exposure => { - let m = &photo.meta; - let mut exp = Vec::new(); - if !m.shutter.is_empty() { - exp.push(format!("{} s", m.shutter)); - } - if let Some(a) = m.aperture { - exp.push(format!("f/{a:.1}")); - } - if let Some(i) = m.iso { - exp.push(format!("ISO {i}")); - } - if let Some(f) = m.focal_mm { - exp.push(format!("{f:.0} mm")); - } - let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · "); - let mut v = vec![photo.file_name.clone()]; - v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") }); - if !camera.is_empty() { - v.push(camera); - } - v - } - }; + let Some(lines) = info_overlay_lines(app, photo) else { return }; let t = Tokens::get(p.ctx()); - let mut y = canvas.top() + 12.0; + let mut y = canvas.top() + INFO_OVERLAY_TOP; for (i, l) in lines.iter().enumerate() { - let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) }; // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white // layout made the shadow a second white copy (#1690) - let g = p.layout_no_wrap(l.clone(), font, Color32::PLACEHOLDER); + let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER); let at = pos2(canvas.left() + 14.0, y); // a soft shadow keeps the text readable on bright photos p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); p.galley(at, g.clone(), Color32::WHITE); - y += g.size().y + 3.0; + y += g.size().y + INFO_OVERLAY_GAP; } register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } +/// Where the info overlay's text starts below the canvas' top, and the gap after each line. +const INFO_OVERLAY_TOP: f32 = 12.0; +const INFO_OVERLAY_GAP: f32 = 3.0; + +/// The info overlay's font for line `i` (the file name is the first, larger line). +fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { + if i == 0 { t.semibold(15.0) } else { t.font(12.5) } +} + +/// How far below the canvas' top the info overlay's text reaches (0 when it is off), so a label +/// drawn in that corner can sit below it. +fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { + let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; + let t = Tokens::get(p.ctx()); + lines.iter().enumerate().fold(INFO_OVERLAY_TOP, |y, (i, l)| { + y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP + }) +} + +/// The info overlay's lines for `photo`, or `None` when it is off. +fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option<Vec<String>> { + use crate::state::InfoOverlay; + let fields = match app.ui.info_overlay { + InfoOverlay::Off => return None, + InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, + InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, + }; + let lines: Vec<String> = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); + if lines.is_empty() { + return None; + } + Some(lines) +} + /// What the user did to a face box this frame. enum RegionEdit { /// The × was clicked. @@ -1133,6 +1209,16 @@ fn region_overlay( edit } +/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from +/// the left as from the top, so the pill sits in the corner, #1645). +const FILTER_PILL_INSET: f32 = 3.0; +const FILTER_PILL_HEIGHT: f32 = 20.0; + +/// Whether [`filter_pill`] has something to show (a filter is active). +fn filter_pill_shown(app: &LightcraftApp) -> bool { + !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty() +} + /// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous /// follow them, so a filter must never be invisible here. A click clears them all. It sits in the /// canvas margin, so the photo does not move. @@ -1146,10 +1232,9 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { let label = chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · "); let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); - // the same inset from the left as from the top, so the pill sits in the corner (#1645) - let inset = 3.0; + let inset = FILTER_PILL_INSET; let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); - let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, 20.0)); + let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT)); let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr("Filters")); register(ui.ctx(), "loupe:filters", r); @@ -1181,6 +1266,39 @@ fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { ui.interact(r, egui::Id::new("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); } +/// One line of the info overlay for `photo`; `None` when the field is None or the photo has +/// nothing to show for it. +fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option<String> { + use crate::state::LoupeInfoField as F; + let m = &photo.meta; + let text = match field { + F::None => return None, + F::FileName => photo.file_name.clone(), + F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), + F::Dimensions => format!("{} × {}", photo.width, photo.height), + F::Exposure => { + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } + } + F::Camera => m.camera.clone(), + F::Lens => m.lens.clone(), + F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · "), + }; + (!text.is_empty()).then_some(text) +} + /// `2026-09-30T12:00:00` → `2026-09-30 12:00`. fn pretty_date(iso: &str) -> String { let d = iso.replacen('T', " ", 1); @@ -1479,8 +1597,13 @@ fn general_interaction( // ------------------------------------------------------------------------ crop fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) { + // the selected guide belongs to this photo and to the Guided Upright tool, and to a guide + // that still exists (a Delete after the last one leaves the selection behind) + if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) { + app.ui.selected_guide = None; + } if app.ui.tool == "guidedUpright" { - guided_overlay(app, ui, resp, map, frame, d); + guided_overlay(app, ui, resp, map, frame, d, id); return; } if app.ui.tool == "straighten" { @@ -1580,8 +1703,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = app.run("tool.done", json!({})); return; } + // egui reports a drag only once the pointer has moved a few px: the zone is where the button + // went down, where the cursor showed it, not where the pointer is by then if resp.drag_started() - && let Some(q) = resp.interact_pointer_pos() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) { @@ -1633,7 +1758,6 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it angle_readout(ui, at, shown_angle, resp.rect); } - let _ = id; } /// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 @@ -1772,39 +1896,142 @@ fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { /// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are /// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. -fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) { +fn guided_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { let p = ui.painter_at(map.rect.expand(8.0)); let col = Color32::from_rgb(255, 196, 40); - let draw = |a: Pos2, b: Pos2| { - p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120))); - p.line_segment([a, b], Stroke::new(1.5, col)); + // the selected guide: brighter, so the Delete key has a visible target + let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); + let draw = |a: Pos2, b: Pos2, sel: bool| { + let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; + p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); + p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); for q in [a, b] { - p.circle_filled(q, 4.0, col); - p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK)); + p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); + p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); } }; - for (i, (a, b)) in d.geometry.guides.iter().enumerate() { - let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b))); + let on_screen = |guide: &(Point, Point)| { + let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); + (sa, sb) + }; + for (i, guide) in d.geometry.guides.iter().enumerate() { + let (sa, sb) = on_screen(guide); register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); - draw(sa, sb); + draw(sa, sb, selected == Some(i)); } ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.drag_started() - && let Some(q) = resp.interact_pointer_pos() + // A press starts what it means, right away (the pointer's press point, not where a drag + // threshold would have been crossed): on an endpoint it drags that endpoint (the guide is + // selected when it lands), on a line it selects the guide (a press on the selected one + // deselects), anywhere else it starts a new guide from the press point. The primary button + // only, never on the navigator — it pans the photo, it draws no guide — and only where the + // loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer + // (the raw pointer is not), and `layer_id_at` asks the same question at the press point — + // a menu can receive the press and the pointer can leave it within the same frame, so the + // press itself must belong to the loupe's layer. Anything floating above the photo — a + // context menu, a dropdown, a dialog — owns its own press; the guide under it stays put. + // A slow frame can also swallow the whole press-move-release into one batch, and egui + // clears `press_origin` on the release: then the press position is read back from the + // frame's own events, so the gesture still starts where the pointer went down. + let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r); + if ui.input(|i| i.pointer.primary_pressed()) + && let Some(q) = ui + .input(|i| { + i.pointer.press_origin().or_else(|| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), + _ => None, + }) + }) + }) + .or(resp.interact_pointer_pos()) + && resp.contains_pointer() + && ui.ctx().layer_id_at(q) == Some(resp.layer_id) + && resp.rect.contains(q) + && !nav.is_some_and(|n| n.expand(5.0).contains(q)) { - app.gesture = Some(Gesture::Guide { a: map.norm(q) }); + let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| { + let (a, b) = on_screen(guide); + if a.distance(q) <= 7.0 { + Some((i, 0)) + } else if b.distance(q) <= 7.0 { + Some((i, 1)) + } else { + None + } + }); + let line = d.geometry.guides.iter().position(|guide| { + let (a, b) = on_screen(guide); + point_segment_dist(q, a, b) <= 6.0 + }); + match (endpoint, line) { + (Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }), + (None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) }, + // drawing a new guide leaves nothing selected + (None, None) => { + app.ui.selected_guide = None; + app.gesture = Some(Gesture::Guide { a: map.norm(q) }); + } + } } - if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) { - draw(map.screen(*a), q); + let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); + if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) { + draw(map.screen(*a), q, false); } - if resp.drag_stopped() - && let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + // the endpoint being dragged: the guide follows the pointer, bright + if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q) + && let Some(guide) = d.geometry.guides.get(*i) { - app.gesture = None; - let b = map.norm(q); - if a.dist(b) > 0.02 { - let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); - let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); + let fixed = if *end == 0 { guide.1 } else { guide.0 }; + draw(map.screen(frame.corrected_to_transformed(fixed)), q, true); + } + if ui.input(|i| i.pointer.any_released()) { + match app.gesture.take() { + // the endpoint lands: the guide stays (one undo step) and is selected. A press + // without a real drag only selects — no no-op undo entry for a click. + Some(Gesture::GuideEndpoint { i, end, from }) => { + app.ui.selected_guide = Some((id.0, i)); + if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) { + let fixed_t = if end == 0 { guide.1 } else { guide.0 }; + let new_t = frame.transformed_to_corrected(map.norm(q)); + let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) }; + let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b)); + // a press that only selected (it landed within the endpoint's grab radius + // and never moved) writes no guide: compare against where it went down + let moved = from.distance(q) > 2.0; + if moved && na.dist(nb) > 0.02 { + let all: Vec<[f64; 4]> = d + .geometry + .guides + .iter() + .enumerate() + .map(|(j, g)| { + let (x, y) = if j == i { (a, b) } else { *g }; + [x.x, x.y, y.x, y.y] + }) + .collect(); + let _ = app.run("geometry.guides", json!({"guides": all})); + } + } + } + Some(Gesture::Guide { a }) => { + if let Some(q) = q + && map.norm(q).dist(a) > 0.02 + { + let b = map.norm(q); + let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); + let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); + } + } + other => app.gesture = other, } } } @@ -1817,6 +2044,13 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { Point::new(r.x / w, r.y / h) } +/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. +fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(q) +} + /// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) /// in the order they appear on screen when the photo is shown flipped. fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { @@ -2641,13 +2875,27 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { film_badges(p, &t, fr, ph); } if let Some(st) = app.session.catalog.stack_of(*id) { + let collapsed = st.collapsed; let text = - if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); - p.rect_filled(br, 7.5, Color32::from_black_alpha(170)); + // like the grid's badge: a click expands / collapses the stack + let badge = ui.interact(br, egui::Id::new(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand" + } else { + "Stack — click to collapse" + }); + crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); + register(ui.ctx(), format!("filmstack:{}", id.0), br); + let p = ui.painter(); + let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; + p.rect_filled(br, 7.5, fill); crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if badge.clicked() { + super::grid::toggle_stack(app, ui.ctx(), *id); + } } } if resp.clicked() { @@ -2674,7 +2922,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } } // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) - resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); + crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id)); } }); } @@ -2696,7 +2944,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: } x += ph.rating as f32 * 9.0 + 2.0; match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -2707,10 +2955,55 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: #[cfg(test)] mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect}; + use super::{fit_rect, fit_texture_rect, label_badge_rect}; use crate::state::Zoom; use egui::{Rect, pos2, vec2}; + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, pane, text, 0.0); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + + /// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner + /// instead of leaving with it (issue #2267), on whichever axes the photo reaches past. + #[test] + fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); + let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0); + assert_eq!(both.min, pos2(108.0, 58.0)); + assert!(pane.contains_rect(both)); + let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0); + assert_eq!(left_only.min, pos2(108.0, 128.0)); + assert!(pane.contains_rect(left_only)); + } + + /// With a filter active, the pill in the canvas' corner has that corner: the label sits below + /// it, whether the photo is past the pane or just under the pill. A photo lower down is left + /// alone. A wide label near the pane's right edge stays inside the pane. + #[test] + fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); + let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; + let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); + assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); + let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); + assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too"); + let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset); + assert_eq!(lower.min, pos2(208.0, 208.0)); + let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0); + assert!(pane.contains_rect(wide), "{wide:?}"); + assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset"); + } + #[test] fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); @@ -3050,6 +3343,27 @@ mod pen_tests { } } +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; @@ -3109,4 +3423,43 @@ mod info_overlay_tests { assert_eq!(copies[1], Color32::WHITE, "the text on top"); assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); } + + /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). + fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec<String> { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + let mut lines = Vec::new(); + for c in &out.shapes { + if let egui::epaint::Shape::Text(t) = &c.shape + && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE + { + lines.push(t.galley.job.text.clone()); + } + } + lines + } + + /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that + /// order, without the lines set to None. + #[test] + fn the_info_overlay_shows_the_lines_chosen_in_view_options() { + use crate::state::{InfoOverlay, LoupeInfoField as F}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let size = format!("{} × {}", photo.width, photo.height); + let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); + app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; + app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); + app.ui.settings.loupe_info.info1 = [F::None; 3]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), Vec::<String>::new(), "nothing chosen, nothing shown"); + } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 5562e94d1..894ad2bd5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -273,6 +273,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } => "Download the SAM 3 Model?", Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", + Dialog::ViewOptions => "View Options", } .to_string(); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); @@ -1315,6 +1316,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, &t), Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); @@ -1366,6 +1368,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + Dialog::SynchronizeFolder { .. } if informational => "Close", Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -1935,7 +1938,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { + Ok(serde_json::Value::Null) + } } } @@ -2013,12 +2018,13 @@ const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240 /// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { use lightcraft_engine::export::ResizeMode as R; - const MODES: [(R, &str); 7] = [ + const MODES: [(R, &str); 8] = [ (R::LongEdge, "Long Edge"), (R::ShortEdge, "Short Edge"), (R::Width, "Width"), (R::Height, "Height"), - (R::Dimensions, "Width × Height"), + (R::WidthHeight, "Width & Height"), + (R::Dimensions, "Dimensions"), (R::Megapixels, "Megapixels"), (R::Percent, "Percentage"), ]; @@ -2042,7 +2048,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, _ => r.value, }; - if r.mode == R::Dimensions && r.height == 0 { + if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 { r.height = r.value as u32; } } @@ -2051,13 +2057,13 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex let spec = match r.mode { R::Megapixels => &SIZE_MP, R::Percent => &SIZE_PCT, - R::Dimensions => &SIZE_W, + R::Dimensions | R::WidthHeight => &SIZE_W, _ => &SIZE_PX, }; if num(ui, spec, &mut v) { r.value = v as f32; } - if r.mode == R::Dimensions { + if matches!(r.mode, R::Dimensions | R::WidthHeight) { let mut h = r.height as f64; if num(ui, &SIZE_H, &mut h) { r.height = h as u32; @@ -2304,7 +2310,8 @@ pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { } } -/// A dialog that only shows something: one Close, nothing to confirm or cancel. +/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize +/// Folder is one when its folder turned out up to date.) fn only_shows(dlg: &Dialog) -> bool { matches!( dlg, @@ -2315,5 +2322,6 @@ fn only_shows(dlg: &Dialog) -> bool { | Dialog::AllMetadata { .. } | Dialog::WhatsNew | Dialog::ManageEffects { .. } - ) + | Dialog::ViewOptions + ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ded1c0bd0..08f70bd02 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // content, and the sliders below would run past its edge let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); - egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + let below = r.rect.bottom(); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( ui, @@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); @@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for g in profile_groups() { let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } // imported LUT profile groups let mut lut_groups: Vec<String> = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); @@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } ui.separator(); if let Some((name, _)) = app.session.profile_info(cur) { @@ -544,7 +561,7 @@ fn section( app.toast(ui.ctx(), e); } } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let mut solo = app.ui.single_panel; let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); register(ui.ctx(), "sectionMenu:solo", item.rect); @@ -967,7 +984,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); register(ui.ctx(), "curve", r); - resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); + crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); p.rect_filled(r, 2.0, t.canvas); if let Some(hist) = preview_histogram(app, id) { @@ -1179,7 +1196,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.close(); } if !p.builtin { - r.context_menu(|ui| { + crate::widgets::context_menu(&r, |ui| { if ui.button(crate::i18n::tr("Delete Preset")).clicked() { let _ = app.run("curve.deletePreset", json!({"name": p.name})); ui.close(); @@ -1799,13 +1816,16 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { } /// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can -/// decode, whose drive was offline when the library opened), and say what came of it. +/// decode, whose drive was offline when the library opened) in the background; a toast says what +/// came of it once it's done (`crate::reload`). pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { - let r = app.run("photo.reload", json!({"ids": [id.0]})); - let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()); - match r { + match app.run("photo.reload", json!({"ids": [id.0]})) { Err(e) => app.toast_error(ctx, e), - Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), } } diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index b35335cc9..8365a1ff8 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -162,6 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Flag::Reject if on => t.reject, _ => Color32::TRANSPARENT, }; + let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon }; let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); if resp.clicked() { filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); @@ -291,7 +292,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if r.clicked() { let _ = app.run("filter.applyPreset", json!({"name": n})); } - r.context_menu(|ui| { + crate::widgets::context_menu(&r, |ui| { if ui.button(crate::i18n::tr("Delete Preset")).clicked() { let _ = app.run("filter.deletePreset", json!({"name": n})); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..a1184892c 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -42,6 +42,9 @@ pub struct GridStats { pub layout_builds: u64, /// Per-view data (date runs, row index) built. pub view_builds: u64, + /// Photos whose date and shape were read from the catalog (every photo of a list read whole; + /// only the photos an edit touched of one followed: [`GridCache`]). + pub photos_read: u64, /// Stack indexes built. pub stack_index_builds: u64, /// Wall-clock time of the last [`show`]. @@ -62,6 +65,10 @@ type StackIndex = HashMap<PhotoId, (StackId, usize)>; /// (`None`: no headers) and the grouping. type RunsKey = (u64, Option<SortKey>, GroupBy); +/// The most photos an edit may touch for the grid to read only those again (each costs a lookup +/// and a date parse; past this, reading the whole list is as quick). +const MAX_FOLLOWED: usize = 4096; + /// What the grid derives from the visible photos, kept across frames. Everything is keyed by the /// session's visible-list generation ([`lightcraft_engine::Session::visible_shared`]), which /// changes whenever the list is recomputed (any catalog change: crops, orientation, dates, stacks; @@ -75,21 +82,165 @@ pub struct GridCache { stacks: Option<(u64, Arc<StackIndex>)>, /// How many of the photos are browsed (Local) ones, by generation. local: Option<(u64, usize)>, + /// What was read of each photo of the list last shown. + read: Option<ListRead>, +} + +/// What the grid read of each photo of a list, position by position: its own date group (a +/// stack's members take their first photo's when the runs are made) and its cell's shape. A new +/// list that differs from this one by the photos an edit touched (an import's batch, a capture +/// time, a crop) is read again only for those: at 180,000 photos, reading every photo again took +/// most of a 100 ms frame for each batch an import added. +struct ListRead { + ids: Arc<[PhotoId]>, + /// The catalog revision it was read at. + revision: u64, + /// The date and grouping of the groups (`None`: the list has no date runs, no group is read). + dates: Option<(SortKey, GroupBy)>, + /// Each photo's group, an index into `names`. + groups: Vec<u32>, + names: Vec<String>, + named: HashMap<String, u32>, + /// Each photo's shape; `None` for the square grid, whose cells are all alike. + shapes: Option<Vec<f32>>, +} + +impl ListRead { + fn new(ids: Arc<[PhotoId]>, revision: u64, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead { + let n = ids.len(); + let groups = Vec::with_capacity(if dates.is_some() { n } else { 0 }); + ListRead { ids, revision, dates, groups, names: Vec::new(), named: HashMap::new(), shapes: shaped.then(|| Vec::with_capacity(n)) } + } + + /// Read photo `id` from the catalog and put it next. + fn read(&mut self, cat: &Catalog, id: PhotoId) { + if let Some((key, by)) = self.dates { + let name = cat.date_group(id, key, by); + let next = u32::try_from(self.names.len()).unwrap_or(u32::MAX); + let g = match self.named.get(&name) { + Some(g) => *g, + None => { + self.named.insert(name.clone(), next); + self.names.push(name); + next + } + }; + self.groups.push(g); + } + if let Some(shapes) = self.shapes.as_mut() { + shapes.push(shape_of(cat, id)); + } + } + + /// Read every photo of `ids`. + fn whole(stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead { + let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped); + if r.dates.is_some() || shaped { + for id in ids.iter() { + r.read(cat, *id); + } + stats.photos_read += ids.len() as u64; + } + r + } + + /// `ids` from what was read of the list before it: the photos the catalog says changed since + /// are read again, the others are taken over. `None` when the list is not this one with + /// those photos changed, added or removed (another view, order or grouping; an edit to more + /// than photos of its own, such as a stack): read it whole then. + fn follow(self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> Option<ListRead> { + if self.dates != dates || self.shapes.is_some() != shaped { + // (a switch between the photo grid and the square grid reads every photo once, on + // purpose: the other mode's data was never read, so there is nothing to follow) + return None; + } + let touched: HashSet<PhotoId> = cat.touched_since(self.revision)?.into_iter().collect(); + if touched.len() > MAX_FOLLOWED { + return None; + } + let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped); + // (the groups' names go on, unpruned: pruning would renumber every photo's group; they are + // only reset by a whole read, and grow by a short string per day or month an edit puts a + // photo in that no photo was in before) + r.names = self.names; + r.named = self.named; + let mut was = 0; + let mut fresh = 0u64; + for id in ids.iter() { + if touched.contains(id) { + r.read(cat, *id); + fresh += 1; + continue; + } + // (the photos touched are read again wherever they are now) + while self.ids.get(was).is_some_and(|w| touched.contains(w)) { + was += 1; + } + // an untouched photo is where it was, among the others: else this is another list + if self.ids.get(was) != Some(id) { + return None; + } + if dates.is_some() { + r.groups.push(*self.groups.get(was)?); + } + if let (Some(to), Some(from)) = (r.shapes.as_mut(), self.shapes.as_ref()) { + to.push(*from.get(was)?); + } + was += 1; + } + // no untouched photo left the list + if self.ids.get(was..).is_some_and(|rest| rest.iter().any(|w| !touched.contains(w))) { + return None; + } + stats.photos_read += fresh; + Some(r) + } } impl GridCache { - fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &[PhotoId], key: RunsKey) -> Arc<Vec<DateRun>> { + /// The date runs of `ids`, reading what has to be read of them (`square`: the cells' shapes + /// are not wanted). + fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, key: RunsKey, square: bool) -> Arc<Vec<DateRun>> { + let shaped = !square; match &self.runs { - Some((k, r)) if *k == key => r.clone(), + Some((k, r)) if *k == key && self.read.as_ref().is_some_and(|read| read.shapes.is_some() == shaped) => r.clone(), _ => { stats.view_builds += 1; - let r = Arc::new(key.1.map(|sort| cat.date_runs(ids, sort, key.2)).unwrap_or_default()); + let dates = key.1.map(|sort| (sort, key.2)).filter(|(sort, by)| lightcraft_catalog::dates::groups_by_date(*sort, *by)); + let read = match self.read.take() { + // (the same list, read at this revision) + Some(r) if Arc::ptr_eq(&r.ids, ids) && r.revision == cat.revision && r.dates == dates && r.shapes.is_some() == shaped => r, + Some(r) => r.follow(stats, cat, ids, dates, shaped).unwrap_or_else(|| ListRead::whole(stats, cat, ids, dates, shaped)), + None => ListRead::whole(stats, cat, ids, dates, shaped), + }; + let r = Arc::new(match dates { + Some(_) if !ids.is_empty() => { + let stacks = self.stacks(stats, cat, key.0); + let group = |i: usize, _: PhotoId| read.groups.get(i).and_then(|g| read.names.get(*g as usize)).map_or("", String::as_str); + lightcraft_catalog::dates::date_runs_of(ids, &stacks, group) + } + _ => Vec::new(), + }); + self.read = Some(read); self.runs = Some((key, r.clone())); r } } } + /// The shape of each cell of the list last shown (width / height), in list order; empty for + /// the square grid. + fn shapes(&self) -> &[f32] { + self.read.as_ref().and_then(|r| r.shapes.as_deref()).unwrap_or(&[]) + } + + /// The date runs and the cell shapes the grid shows now (tests compare them with a grid made + /// afresh). + #[cfg(test)] + pub(crate) fn runs_and_shapes(&self) -> Option<(Arc<Vec<DateRun>>, Vec<f32>)> { + Some((self.runs.as_ref()?.1.clone(), self.shapes().to_vec())) + } + fn stacks(&mut self, stats: &mut GridStats, cat: &Catalog, generation: u64) -> Arc<StackIndex> { match &self.stacks { Some((g, s)) if *g == generation => s.clone(), @@ -114,18 +265,14 @@ impl GridCache { } } -/// Width / height of each photo's cell: its cropped, oriented shape. -fn aspects(cat: &Catalog, ids: &[PhotoId]) -> Vec<f32> { - ids.iter() - .map(|id| { - let p = cat.photo(*id); - let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); - let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); - let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); - let (w, h) = if swap { (h, w) } else { (w, h) }; - (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) - }) - .collect() +/// Width / height of a photo's cell: its cropped, oriented shape. +pub(crate) fn shape_of(cat: &Catalog, id: PhotoId) -> f32 { + let p = cat.photo(id); + let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); + let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); + let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); + let (w, h) = if swap { (h, w) } else { (w, h) }; + (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) } /// The rows (of a layout's `rows`, top to bottom) that meet the y range `top..=bottom`, found by @@ -211,7 +358,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; let runs_key = (generation, group_key, by); let stats = &mut app.caches.grid_stats; - let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key); + let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key, square); // the layout only changes with the photos (and their shapes), the grouping, the width and // the thumbnail size let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square); @@ -219,9 +366,15 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { Some((k, l)) if *k == lay_key => l.clone(), _ => { stats.layout_builds += 1; - let aspects = if square { vec![1.0; ids.len()] } else { aspects(&app.session.catalog, &ids) }; + let ones; + let shapes = if square { + ones = vec![1.0; ids.len()]; + &ones[..] + } else { + app.caches.grid.shapes() + }; let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); - let l = Arc::new(layout(&aspects, &spans, avail_w, target, square)); + let l = Arc::new(layout(shapes, &spans, avail_w, target, square)); app.caches.grid.layout = Some((lay_key, l.clone())); l } @@ -554,7 +707,7 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority // has arrived there is nothing left to ask for. Without this the grid would rebuild the render // job — and its cache key — on every frame, which is what the embedded-thumbnail record settles. if let Some(photo) = app.session.catalog.photo(id) - && (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket)) + && (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket, priority)) { return; } @@ -709,7 +862,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } x += photo.rating as f32 * 13.0 + 4.0; match photo.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -790,7 +943,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect()); } - resp.context_menu(|ui| context_menu(app, ui, id)); + crate::widgets::context_menu(&resp, |ui| context_menu(app, ui, id)); } /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), @@ -970,7 +1123,9 @@ pub fn toast_skipped(app: &mut LightcraftApp, ctx: &egui::Context, r: &serde_jso pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label); if let Some(l) = label_items(app, ui, current, "photoLabel") { - let _ = app.run("photo.label", json!({"label": label_param(l)})); + let mut params = json!({"label": label_param(l)}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.label", params); ui.close(); } } @@ -1010,7 +1165,7 @@ pub fn label_param(l: Option<ColorLabel>) -> String { } /// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the -/// members of an expanded stack. Clicking it expands/collapses the stack. +/// members of an expanded stack. Clicking or double-clicking it expands/collapses the stack. pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.catalog.stack(sid) else { return }; @@ -1026,6 +1181,7 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } else { "Stack — click to collapse (S)" }); + crate::widgets::announce(&resp, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); register(ui.ctx(), format!("stack:{}", id.0), br); let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) }; p.rect_filled(br, 10.0, fill); @@ -1034,8 +1190,17 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if resp.clicked() { - let _ = app.run("stack.toggle", json!({"ids": [id.0]})); + // a double-click (Lightroom's way) toggles once: its second click would undo the first + if resp.clicked() && !resp.double_clicked() { + toggle_stack(app, ui.ctx(), id); + } +} + +/// A click on a stack badge (grid or filmstrip): expand / collapse that photo's stack, selected or +/// not. The selection stays as it is (Compare keeps its pair). +pub fn toggle_stack(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + if let Err(e) = app.run("stack.toggle", json!({"ids": [id.0]})) { + app.toast(ctx, e); } } @@ -1081,8 +1246,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { Err(e) => app.toast(ui.ctx(), e), } } - if ui.button(crate::i18n::tr("Set as Reference Photo")).clicked() { - let _ = app.run("photo.setReference", json!({"id": id.0})); + let live = super::compare::live(app, id); + let set_reference = ui + .add_enabled(live, egui::Button::new(crate::i18n::tr("Set as Reference Photo"))) + .on_disabled_hover_text(crate::i18n::tr("Restore the photo to use it as the reference")); + if set_reference.clicked() + && let Err(e) = app.run("photo.setReference", json!({"id": id.0})) + { + app.toast(ui.ctx(), e); } ui.separator(); crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { @@ -1162,7 +1333,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { - let _ = app.run("photo.reload", json!({})); + // the files are read in the background (activity row `reload`) + if let Err(e) = app.run("photo.reload", json!({})) { + app.toast_error(ui.ctx(), e); + } } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); @@ -1230,6 +1404,11 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr(crate::menus::reveal_label()))).clicked() { let _ = app.run("app.showInFinder", json!({})); } + // the photo's folder in the sidebar's Folders section, as Lightroom's Go to Folder in Library + // (the photo right-clicked, which a multi-selection may leave other than the active one) + if ui.add_enabled(crate::menus::show_in_folders_enabled(app, Some(id)), egui::Button::new(crate::i18n::tr("Show in Folders"))).clicked() { + let _ = app.run("app.showInFolders", json!({"id": id.0})); + } if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index d42aa1ff5..1ec97d1c9 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -272,7 +272,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if resp.double_clicked() { app.ui.dialog = Some(edit_dialog(app, &r.path)); } - resp.context_menu(|ui| menu(app, ui, &r.path, selection)); + crate::widgets::context_menu(&resp, |ui| menu(app, ui, &r.path, selection)); } /// A row while a keyword or photos are dragged: outlined under the pointer when the drop would @@ -621,13 +621,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 14, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index bad2a2f43..a6f53562d 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -225,7 +225,7 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo body.on_hover_text(format!("{full}\n{n}")) }; let c = c.clone(); - body.context_menu(|ui| menu(app, ui, &c)); + crate::widgets::context_menu(&body, |ui| menu(app, ui, &c)); } /// When a list is shortened, only this many chips are measured for it: more than the lines it diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3bb8deba2..2c7d2642c 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -28,6 +28,10 @@ const ROW_HEIGHT: f32 = 29.0; /// this frame: set before the sections are drawn, cleared after. const SCROLL_TO_ROW: &str = "left-scroll-to-row"; +/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): +/// removed, they open again. +const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; + /// A row says how wide it needs to be. fn note_width(ui: &egui::Ui, w: f32) { ui.data_mut(|d| { @@ -36,6 +40,53 @@ fn note_width(ui: &egui::Ui, w: f32) { }); } +/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. +pub(crate) const INDENT_STEP: f32 = 16.0; + +/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. +pub(crate) const MIN_INDENT_STEP: f32 = 8.0; + +/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to +/// less than this while a narrower step would still keep it. +const MIN_NAME_ROOM: f32 = 80.0; + +/// Room kept for a count at the end of the deepest row (three digits and their gap). +const COUNT_ROOM: f32 = 40.0; + +/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the +/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. +pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::<f32>(egui::Id::new("left-indent-step"))).unwrap_or(INDENT_STEP) +} + +/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. +fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { + indent / INDENT_STEP * indent_step(ctx) +} + +/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next +/// frame's step. +fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::new("left-deepest-indent-next"), || 0.0); + *m = m.max(indent); + }); + indent_x(ui.ctx(), indent) +} + +/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) +/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a +/// row does not slide sideways at every point the panel is dragged; when even the narrowest step +/// does not fit, the sidebar scrolls sideways as before. +pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { + let levels = deepest / INDENT_STEP; + if levels < 1.0 { + return INDENT_STEP; + } + let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; + [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) +} + fn row( app: &mut LightcraftApp, ui: &mut egui::Ui, @@ -82,6 +133,7 @@ fn row_sensed( ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); + let indent = drawn_indent(ui, indent); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); if scroll_asked(ui, id) { ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); @@ -153,6 +205,7 @@ fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, c } ui.allocate_space(r.size()); register(ui.ctx(), format!("source:{id}"), r); + let indent = drawn_indent(ui, indent); let t = Tokens::get(ui.ctx()); let painter = ui.painter(); let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; @@ -178,7 +231,7 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 /// its response: the caller flips the open state on `clicked()`. fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { let t = Tokens::get(ui.ctx()); - let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y); + let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); @@ -413,7 +466,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned)); app.ui.dragging_section = Some(section); } - resp.context_menu(|ui| sections_menu(app, ui, Some(section))); + crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section))); let open = !app.ui.sidebar.is_collapsed(section); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); // what is visible of the header: its bar and count end at the panel's edge, like a row's @@ -595,7 +648,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); crate::widgets::announce(&title, crate::i18n::tr("My Photos")); register(ui.ctx(), "sidebarTitle", hr); - title.context_menu(|ui| sections_menu(app, ui, None)); + crate::widgets::context_menu(&title, |ui| sections_menu(app, ui, None)); // the open library's name under the title, so nobody edits in the wrong one (issue #902) let library = crate::panels::settings::open_library_name(app); let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y }; @@ -626,6 +679,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.set_max_width(wide); ui.data_mut(|d| { d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32); + d.insert_temp(egui::Id::new("left-deepest-indent-next"), 0.0f32); // where the visible part of the content ends (screen x), for what stays at the edge d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x); }); @@ -670,6 +724,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // …and the sidebar scrolls to it ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase())); } + // the folder Show in Folders went to: Folders is shown and opened, and so are the + // folders above it, also when it was already the one shown and one was folded by hand… + if let Some(path) = app.ui.reveal_folder.take() { + app.ui.sidebar.reveal(SidebarSection::Folders); + ui.data_mut(|d| d.remove::<Option<String>>(egui::Id::new(LIBFOLDER_REVEALED))); + // …and the sidebar scrolls to it + ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase())); + } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); let shown: Vec<SidebarSection> = app.ui.sidebar.shown().collect(); @@ -713,6 +775,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next)); ui.ctx().request_repaint(); } + // …and the deepest row drawn (or passed over out of sight) sets next frame's step + let deepest = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-deepest-indent-next"))).unwrap_or(0.0); + let step = step_for(viewport.width(), deepest); + if step != indent_step(ui.ctx()) { + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-indent-step"), step)); + ui.ctx().request_repaint(); + } }); }); } @@ -738,10 +807,10 @@ fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { for (m, n) in &g.months { let label = crate::i18n::date_group_label(m, true); - if date_row(app, ui, m, &label, *n, 16.0) { + if date_row(app, ui, m, &label, *n, INDENT_STEP) { for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { let label = crate::i18n::date_group_label(d, true); - date_row(app, ui, d, &label, *n, 32.0); + date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); } } } @@ -793,6 +862,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { struct LocalNow { places: LocalPlaces, current: Option<String>, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, } /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, @@ -817,11 +888,14 @@ fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalNow> { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { app.ui.local_browse_root = places.browse_root.clone(); } - Some(LocalNow { places, current }) + Some(LocalNow { places, current, hidden }) } /// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. @@ -846,18 +920,43 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { } } } - let hidden = app.ui.hidden_locations.len(); + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")) - .clicked() - { - let _ = app.run("local.restoreHidden", json!({})); + let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); + if let [(_, only)] = now.hidden.as_slice() { + if resp.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); } } } +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui.ctx(), "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + /// Whether two paths name the same folder, however they are spelled (separators, trailing /// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). pub(crate) fn same_folder(a: &str, b: &str) -> bool { @@ -1061,7 +1160,7 @@ fn folder_tree( { app.toast(ui.ctx(), e); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { // the library actions of the Folders tree, when this folder (or one inside it) holds library photos let library = app.caches.folder_tree(&app.session.catalog); let held = library_node_for(&library, path); @@ -1112,9 +1211,9 @@ fn folder_tree( synchronize_item(app, ui, &held.0, &held.1); } }); - if open && indent < 16.0 * 8.0 { + if open && indent < INDENT_STEP * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); } } } @@ -1151,7 +1250,7 @@ type AlbumKids = std::collections::HashMap<Option<AlbumId>, Vec<Album>>; /// `open_to`: folders forced open this frame (the way down to an album that was just made). fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option<AlbumId>, indent: f32, open_to: &[AlbumId]) { // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack - if indent > 64.0 * 16.0 { + if indent > 64.0 * INDENT_STEP { return; } let Some(kids) = all.get(&parent) else { return }; @@ -1188,7 +1287,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare } folder_menu(app, &resp, a); if draw_open { - albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to); + albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); } } else { let sel = app.session.source == LibrarySource::Album(a.id); @@ -1440,7 +1539,7 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp } Some(AlbumDrop::Place { parent, before, above }) => { let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; - let (x0, x1) = (resp.rect.left() + 18.0 + indent, ui.clip_rect().right() - 8.0); + let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); if released { @@ -1540,7 +1639,7 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response } fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { - resp.context_menu(|ui| { + crate::widgets::context_menu(resp, |ui| { if a.folder { // the same commands as File ▸ New…, aimed at this folder crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { @@ -1713,7 +1812,7 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); - let seen = egui::Id::new("libfolder-revealed"); + let seen = egui::Id::new(LIBFOLDER_REVEALED); let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) { return; @@ -1732,6 +1831,16 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { open_above(ui, tree, &chosen); } +/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that +/// folder. +pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { + // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths + // too, so more than one may), and the tree is at most `MAX_DEPTH` deep + tree.iter() + .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) + .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) +} + fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { for n in nodes { let key = lightcraft_catalog::query::folder_key(&n.path); @@ -1747,7 +1856,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); } if open && !n.children.is_empty() { - folder_rows(app, ui, &n.children, indent + 16.0); + folder_rows(app, ui, &n.children, indent + INDENT_STEP); } } } @@ -1921,7 +2030,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { if !n.volume { folder_menu_for_library(app, resp, n); } else if n.path != "/" { - resp.context_menu(|ui| { + crate::widgets::context_menu(resp, |ui| { folder_label_menu(app, ui, n); locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &n.name); @@ -2020,7 +2129,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); // what dialogs call it: the last two names, so same-named folders are told apart let label = lightcraft_catalog::folders::folder_label(path); - resp.context_menu(|ui| { + crate::widgets::context_menu(resp, |ui| { if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { title: crate::i18n::tr_format!("Rename “{name}”", name = label), @@ -2098,7 +2207,7 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode keyword_row(app, ui, n, &id, indent, open_id, &mut open); } if open { - keyword_rows(app, ui, &n.children, indent + 16.0); + keyword_rows(app, ui, &n.children, indent + INDENT_STEP); } } } @@ -2116,7 +2225,7 @@ fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: browse_all_photos(app, !sel); let _ = app.run("library.filter", json!({"keyword": v})); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let has_sel = app.session.active().is_some(); if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 505dde129..6062d241c 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -249,7 +249,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option if resp.clicked() { chosen = Some(name.clone()); } - resp.context_menu(|ui| row_menu(app, ui, mask, name, fav)); + crate::widgets::context_menu(&resp, |ui| row_menu(app, ui, mask, name, fav)); } }); // resting on an effect previews it on the photo (no history entry) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index e0e9d1a42..b0371c422 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -282,7 +282,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if resp.double_clicked() { app.ui.renaming_mask = Some((m.id, m.name.clone())); } - resp.context_menu(|ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count)); + crate::widgets::context_menu(&resp, |ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count)); if sel { // right under the selected mask: + adds to it, − takes away from it (any mask // type; Describe… opens its field here) @@ -349,7 +349,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if resp.double_clicked() { app.ui.renaming_component = Some((m.id, i, label.clone())); } - resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); + crate::widgets::context_menu(&resp, |ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options")); register(ui.ctx(), format!("button:componentMenu{i}"), more.rect); diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 0ec008c2c..c5cd27b40 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -185,7 +185,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)); let full = person.name.clone(); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let r = ui.button(crate::i18n::tr("Rename Person…")); register(ui.ctx(), "personMenu:rename", r.rect); if r.clicked() { diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index ca0243cc5..a8cf21424 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -35,15 +35,50 @@ impl AppliedPreset { } fn is_current(&self, session: &lightcraft_engine::Session) -> bool { - self.selected == session.selection.ids - && self.active == session.active() - && self.commits == session.commits() + self.selected == session.selection.ids && self.active == session.active() && !self.is_stale(session) + } + + /// A later edit, undo or redo (on any photo) owns the undo step: the slider may never replace it. + fn is_stale(&self, session: &lightcraft_engine::Session) -> bool { + !(self.commits == session.commits() && session.interaction.is_none() && session.redo.is_empty() && session .undo .last() - .is_some_and(|entry| session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name))) + .is_some_and(|entry| session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name)))) + } +} + +/// The preset each photo was given, with its settings right after: the panel marks it while the +/// photo still has exactly those settings, so it is still marked on coming back to the photo +/// (issue #2392) and drops once the photo is edited further. +#[derive(Clone, Default)] +struct AppliedByPhoto(std::collections::HashMap<lightcraft_catalog::PhotoId, (String, Arc<lightcraft_develop::DevelopSettings>)>); + +impl AppliedByPhoto { + const ID: &'static str = "preset-applied-by-photo"; + + /// Remember `preset` on the photos `preset.apply` just changed (the selection, else the active + /// photo; an active photo outside the selection wasn't changed and isn't marked). + fn remember(ctx: &egui::Context, session: &lightcraft_engine::Session, preset: &str) { + let id = egui::Id::new(Self::ID); + let mut map: Self = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); + for photo in session.selected() { + if let Some(d) = session.develop_of(photo) { + map.0.insert(photo, (preset.to_string(), d)); + } + } + ctx.data_mut(|d| d.insert_temp(id, map)); + } + + /// The preset to mark for `photo`: the one it was given, while its settings are unchanged since. + fn of(ctx: &egui::Context, session: &lightcraft_engine::Session, photo: Option<lightcraft_catalog::PhotoId>) -> Option<String> { + let photo = photo?; + let map: Self = ctx.data(|d| d.get_temp(egui::Id::new(Self::ID)))?; + let (preset, then) = map.0.get(&photo)?; + let now = session.develop_of(photo)?; + (Arc::ptr_eq(then, &now) || **then == *now).then(|| preset.clone()) } } @@ -106,6 +141,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { // Only replace the most recent preset; never undo intervening edits. if app.run("preset.apply", json!({"id": &last.id, "amount": v})).is_ok() { + AppliedByPhoto::remember(ui.ctx(), &app.session, &last.id); ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, last.id, v))); } else { let _ = app.run("edit.redo", json!({})); @@ -113,10 +149,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } divider(ui); - } else { + } else if last.is_some_and(|last| last.is_stale(&app.session)) { + // kept across a switch to another photo and back (the slider returns with it); gone once + // a later edit, undo or redo owns the step ui.data_mut(|d| d.remove::<AppliedPreset>(amt_id)); } let active = app.session.active(); + let marked = AppliedByPhoto::of(ui.ctx(), &app.session, active); let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); let thumbs = app.ui.preset_thumbs && active.is_some(); let row_h = if thumbs { 54.0 } else { 26.0 }; @@ -149,7 +188,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr("Export Group…")).clicked() { let _ = app.run("file.exportPresets", json!({"group": g})); } @@ -167,8 +206,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); crate::widgets::announce(&resp, name.clone()); register(ui.ctx(), format!("preset:{pid}"), r); - let is_last = last.as_ref().is_some_and(|l| l.id == pid); + let is_last = marked.as_deref() == Some(pid.as_str()); if is_last { + register(ui.ctx(), "presetApplied", r); ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); @@ -200,12 +240,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } if resp.clicked() && app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { + AppliedByPhoto::remember(ui.ctx(), &app.session, &pid); ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); } let (builtin, group) = (pr.builtin, pr.group.clone()); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() { let _ = app.run("preset.favorite", json!({"id": pid})); } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index dac064d19..8a4b08345 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -188,6 +188,9 @@ fn cell_ui( app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() }); ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string()))); + } else if resp.hovered() && applied { + // resting on the applied profile shows it (no preview), not the last other profile hovered + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); } let resp = resp.on_hover_text(format!( "{} ({})", diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 537330810..76ecc746a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -643,7 +643,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.add_space(8.0); if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { - let _ = app.run("photo.rate", json!({"rating": r})); + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); } ui.add_space(6.0); // colour label: one swatch per label (hover shows its name); clicking the current one clears it @@ -660,7 +662,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l)); if resp.clicked() { - let _ = app.run("photo.label", json!({"label": if on { "none".to_string() } else { key }})); + // in Compare / Survey only the active photo, as the rating keys and the bottom pill do + let mut params = json!({"label": if on { "none".to_string() } else { key }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.label", params); } } if let Some(l) = p.label { @@ -771,7 +776,11 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) - .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) + .on_hover_text(format!( + "{}\n{}", + crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), + crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") + )) .clicked() && let Err(e) = app.run("metadata.reverseGeocode", json!({})) { @@ -1319,25 +1328,29 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } else if resp.clicked() && !current { let _ = app.run("version.restore", json!({"index": i})); } - resp.on_hover_text(crate::i18n::tr(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" })) - .context_menu(|ui| { - if ui.button(crate::i18n::tr("Restore")).clicked() { - let _ = app.run("version.restore", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { - let _ = app.run("version.update", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Rename…")).clicked() { - crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); - } - if ui.button(crate::i18n::tr("Set as Before")).clicked() { - let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Delete")).clicked() { - let _ = app.run("version.delete", json!({"index": i})); - } - }); + let resp = resp.on_hover_text(crate::i18n::tr(if current { + "The photo has these settings" + } else { + "Click to restore · double-click to rename" + })); + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("version.restore", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { + let _ = app.run("version.update", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } + if ui.button(crate::i18n::tr("Set as Before")).clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete")).clicked() { + let _ = app.run("version.delete", json!({"index": i})); + } + }); } }); } @@ -1409,7 +1422,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); } diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 1ec211d51..d69a8fa43 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -247,10 +247,9 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String } ui.separator(); // submenu rows look like the items above them; the one holding the current field stands out - // (a transparent frame, not none: a frameless button drops its padding and hover fill) - let inactive = &mut ui.visuals_mut().widgets.inactive; - inactive.weak_bg_fill = egui::Color32::TRANSPARENT; - inactive.bg_stroke = egui::Stroke::NONE; + // (a transparent frame, not none: a frameless button drops its padding and hover fill; the + // list's style already drops the resting border) + ui.visuals_mut().widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT; for (group, ids) in FIELD_GROUPS { let text = RichText::new(crate::i18n::tr(group)); let text = if ids.contains(&current.as_str()) { text.strong() } else { text }; @@ -276,6 +275,12 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String *value = default_value(field, op); } +/// How wide `ui.small_button(text)` is: its text and the button's padding. +fn small_button_width(ui: &egui::Ui, text: &str) -> f32 { + let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button); + galley.size().x + 2.0 * ui.spacing().button_padding.x +} + /// A rule's problem, shown under it in the caution colour (`ruleProblem:<salt>` for tests and /// agents). fn problem_note(ui: &mut egui::Ui, problem: Option<&Problem>, salt: &str) { @@ -329,22 +334,25 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: } }); value_editor(ui, field, op, value, &rsalt, env); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)")); - register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); - if add.clicked() { - let group = ui.input(|i| i.modifiers.alt) && depth < 3; - insert = Some(( - i + 1, - if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() }, - )); - } - let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule")); - register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect); - if del.clicked() { - remove = Some(i); - } - }); + // − and + at the end of the row, after the value however long it is: a + // right-to-left layout put them over the value when the row had no room left + // (#2353), and the dialog widens to the row instead + let buttons = small_button_width(ui, "−") + ui.spacing().item_spacing.x + small_button_width(ui, "+"); + ui.add_space((ui.available_width() - buttons).max(0.0)); + let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule")); + register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect); + if del.clicked() { + remove = Some(i); + } + let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)")); + register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); + if add.clicked() { + let group = ui.input(|i| i.modifiers.alt) && depth < 3; + insert = Some(( + i + 1, + if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() }, + )); + } }); problem_note(ui, problem, &rsalt); } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 682e2acb2..2f844f4ae 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -106,7 +106,7 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { - let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); + let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); // Thirteen language names do not fit one line in a narrow window: wrap them. ui.horizontal_wrapped(|ui| { choices(ui, "settingsLanguage", &languages, &mut app.ui.language); @@ -685,8 +685,49 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { use crate::state::InfoOverlay as I; - choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay); + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); }); + hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); +} + +/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and +/// which, and the three lines each of Info 1 and Info 2 shows (`view.viewOptions`). Changes apply +/// as they are made. +pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; + heading(ui, t, crate::i18n::tr("Loupe View")); + let mut show = app.ui.info_overlay != I::Off; + if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { + app.ui.info_overlay = if show { I::Basic } else { I::Off }; + } + ui.add_enabled_ui(show, |ui| { + ui.horizontal(|ui| { + choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + }); + for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { + heading(ui, t, crate::i18n::tr(title)); + let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; + for (i, line) in lines.iter_mut().enumerate() { + let id = format!("viewOptionsInfo{n}Line{}", i + 1); + let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { + for f in F::ALL { + let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); + register(ui.ctx(), format!("button:{id}-{key}"), r.rect); + if r.clicked() { + *line = *f; + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + } + let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); + register(ui.ctx(), format!("button:viewOptionsDefaults{n}"), r.rect); + if r.clicked() { + *lines = defaults; + } + } } // ----------------------------------------------------------------------------------- Slideshow @@ -1129,7 +1170,6 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> app.ui.dialog = None; } app.renderer.forget_all(); - app.ui.compare = None; if let Err(err) = opened { let Some(prev) = previous_dir else { return Err(err) }; return match app.session.open_library(&prev, false) { @@ -1137,6 +1177,13 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), }; } + // Photo IDs are local to a catalog: pins cannot follow them into another library (a refused + // or failed switch reopens the same catalog, so its pins stay). + app.ui.reference = None; + app.ui.compare = None; + if app.ui.view == crate::state::ViewMode::Reference { + app.ui.view = crate::state::ViewMode::PhotoGrid; + } app.ui.settings.library_path = path.clone(); if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { remember_library(&mut app.ui.settings.recent_libraries, &prev); @@ -1311,7 +1358,8 @@ mod tests { use serde_json::json; fn app_with_import() -> LightcraftApp { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); app } @@ -1319,17 +1367,21 @@ mod tests { #[test] fn open_library_is_refused_while_an_import_runs() { let mut app = app_with_import(); + let pin = app.ui.reference; let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); assert!(err.contains("import to finish"), "{err}"); assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin"); } #[test] fn new_library_is_refused_while_an_import_runs() { let mut app = app_with_import(); + let pin = app.ui.reference; let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); assert!(err.contains("import to finish"), "{err}"); assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin"); } /// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import. @@ -1349,11 +1401,18 @@ mod tests { fn open_library_is_refused_while_an_import_scan_runs() { let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); + let pin = app.ui.reference; crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap(); assert!(app.scan.is_some()); let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin"); + let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin"); + assert!(app.scan.is_some()); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/panels/slide_templates.rs index 591e3c82a..51c27d224 100644 --- a/crates/ui-egui/src/panels/slide_templates.rs +++ b/crates/ui-egui/src/panels/slide_templates.rs @@ -145,7 +145,7 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te if resp.clicked() { apply(app, &ctx, &tpl.id, &name); } - resp.context_menu(|ui| menu(app, ui, tpl, &name)); + crate::widgets::context_menu(&resp, |ui| menu(app, ui, tpl, &name)); } /// A menu entry registered as `button:{id}`. diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7e40af7d5..e26d1088f 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -10,9 +10,8 @@ use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a /// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32, search_rect: Rect) { +fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: Rect) { use crate::titlebar::{Gesture, WindowState, command_for}; - let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); register(ui.ctx(), "region:titlebar", bar); let handle = egui::Id::new("titlebar-handle"); let resp = ui.interact(bar, handle, Sense::click_and_drag()); @@ -60,18 +59,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - // room for the filter icon and the seven icons on the right - // (the field is centred, so each icon on the right takes its width from both sides) - let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0); + // The whole bar, its margins included. The search field is centred on this and not on + // `full`, which is only what the margins leave: on macOS the left margin holds the + // traffic lights' room, and full screen gives that room back. Centring on `full` moved + // the field, and changed its width, every time that room changed (issue #2365). + let bar = Rect::from_min_max(pos2(full.left() - left as f32, full.top()), pos2(full.right() + 12.0, full.bottom())); + // The field stops short of the filter icon and the seven icons on the right; centred, + // it is twice the room between the window's centre and them. + let mut sw = 640.0f32.min(2.0 * (full.right() - 310.0 - bar.center().x)).max(190.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; - sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0); + sw = sw.min(2.0 * (bar.center().x - menus_right)).max(190.0); } - let search_left = full.center().x - sw / 2.0; - let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); + let search_left = bar.center().x - sw / 2.0; + let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0)); if app.integrated_titlebar { - window_handle(app, ui, full, left as f32, 12.0, sr); + window_handle(app, ui, bar, sr); } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; @@ -226,7 +230,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { let _ = app.run("view.appearance", json!({})); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let settings = ui.button(crate::i18n::tr("Appearance Settings…")); register(ui.ctx(), "menu:appearanceSettings", settings.rect); if settings.clicked() { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 71e836bc8..a18e1f691 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -420,6 +420,19 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"page": {"background": c}})); } }); + if compose::has_page_options(l.style) { + ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label)); + let p = &st.page; + for (id, label, on, key) in [ + ("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"), + ("printPageInfo", "Page Info", p.page_info, "pageInfo"), + ("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"page": {(key): v}})); + } + } + } heading(ui, t, "Print Job"); row(ui, t, "Resolution", |ui| { @@ -607,11 +620,13 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), }; + let mut shown = Vec::new(); for (i, (r, photo)) in cells.iter().enumerate() { let rect = sheet.rect(r); register(&ctx, format!("print:gridcell:{i}"), rect); - paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r)); } + page_options(&painter, st, &sheet, &shown, pu.page + 1); } // footer: page navigation and the selected cell @@ -682,7 +697,7 @@ fn package_cells( pu.drag = None; } let id = c.id; - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { for (how, label) in [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] { @@ -782,7 +797,7 @@ fn paint_cell( photo: Option<PhotoId>, sheet: &Sheet, inches: &layout::Rect, -) { +) -> Option<Rect> { let im = &st.image; if st.guides.show_cells { painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); @@ -794,7 +809,7 @@ fn paint_cell( }; let avail = cell.shrink(border); if avail.width() < 1.0 || avail.height() < 1.0 { - return; + return None; } // the photo's shape: its cropped size, or 3:2 for a placeholder let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { @@ -804,7 +819,7 @@ fn paint_cell( let scale = 8.0; // sub-point resolution for the rounding in `place` let aw = (f64::from(avail.width()) * scale).round() as usize; let ah = (f64::from(avail.height()) * scale).round() as usize; - let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?; let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); let visible = shown.intersect(avail); if border > 0.0 { @@ -856,6 +871,41 @@ fn paint_cell( painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); painter.galley(at, galley, Color32::WHITE); } + Some(visible) +} + +/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each +/// photo, the page number at the bottom right and the print settings at the bottom left. +/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]). +fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) { + if !compose::has_page_options(st.layout.style) { + return; + } + let p = &st.page; + let ink = compose::mark_ink(p.background); + let ink = Color32::from_rgb(ink[0], ink[1], ink[2]); + if p.crop_marks { + let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale); + let stroke = Stroke::new(1.0, ink); + for r in photos { + for (corner, dx, dy) in + [(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)] + { + painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke); + painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke); + } + } + } + let Ok(area) = st.layout.printable() else { return }; + let area = sheet.rect(&area); + let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0)); + let y = (area.bottom() + sheet.page.bottom()) / 2.0; + if p.page_numbers { + painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink); + } + if p.page_info { + painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink); + } } fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 953008e0d..3994a950d 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -109,16 +109,50 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. -const SLOW_DRAGS_KEPT: usize = 256; +/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered +/// longest ago is forgotten. +const VERDICTS_KEPT: usize = 256; + +/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, +/// occluded or stalled: it counts as unknown, like a frame time from no host at all. +const FRAME_MS_KNOWN: f64 = 1000.0; + +/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than +/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size. +const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25; + +/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the +/// machine may only have been busy. Each retry that is slow too doubles the wait, up to +/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size. +const RETRY_AFTER_S: f64 = 30.0; + +/// The longest wait (s) before a slow pair tries full size again. +const RETRY_MAX_S: f64 = 240.0; + +/// What [`DragScale`] remembers about the drags of one control on one photo. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Verdict { + /// One drag was over budget at full size: maybe a busy moment, so the next stays full size. + Suspect, + /// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag + /// tries full size again; `wait` is how long this verdict holds (s). + Slow { until: f64, wait: f64 }, +} /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are -/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of -/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT -/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large -/// draft costs a texture upload too, which only the time a draft took to show tells. +/// far over budget. A drag renders at full size when the GPU draws the view and the same control +/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue +/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile, +/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft +/// costs a texture upload too, which only the time a draft took to show tells. +/// +/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy +/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is +/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after +/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one +/// doubles the wait. A verdict is for its photo only: the same control on another photo (another +/// size, other edits) is timed afresh. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. @@ -130,26 +164,28 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option<f64>, - /// Drags whose full-size drafts went over budget. - slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, + /// Pairs whose full-size drafts went over budget, the one remembered longest ago first. + verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>, } impl DragScale { /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame - /// render on screen (a render from before the drag is not one of its drafts). - pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>) -> Option<f32> { + /// render on screen (a render from before the drag is not one of its drafts); `now` the host's + /// clock (s; egui's `input.time`). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>, now: f64) -> Option<f32> { let Some((photo, control)) = drag else { - self.finish(); + self.finish(now); return None; }; match &self.current { Some(((p, c), s)) if *p == photo && c == control => return Some(*s), - Some(_) => self.finish(), + Some(_) => self.finish(now), None => {} } let key = (photo, control.to_string()); - let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until); + let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE }; self.current = Some((key, s)); self.seen = shown; Some(s) @@ -167,7 +203,7 @@ impl DragScale { return; } if let Some(render) = self.pending.take() { - let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0); if self.samples.len() < 1024 { self.samples.push(render + frame); } @@ -188,21 +224,58 @@ impl DragScale { } } - /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + /// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.slow.insert((photo, control.to_string())); + self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S })); } - /// The drag ended: remember it if its full-size drafts were over budget. - fn finish(&mut self) { - if let Some((key, _)) = self.current.take() - && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) - { - if self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.clear(); + fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option<Verdict> { + self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v) + } + + /// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago + /// when the list is full (not all of them: each forgotten pair costs one more slow drag). + fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option<Verdict>) { + let at = self.verdicts.iter().position(|(k, _)| *k == key); + match (at, verdict) { + (Some(i), Some(v)) => { + if let Some(entry) = self.verdicts.get_mut(i) { + entry.1 = v; + } } - self.slow.insert(key); + (Some(i), None) => { + self.verdicts.remove(i); + } + (None, Some(v)) => { + while self.verdicts.len() >= VERDICTS_KEPT { + self.verdicts.pop_front(); + } + self.verdicts.push_back((key, v)); + } + (None, None) => {} + } + } + + /// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`] + /// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry + /// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it + /// drafted small, or was too short) changes nothing. + fn finish(&mut self, now: f64) { + if let Some((key, _)) = self.current.take() + && let Some(median) = median_after_first(&self.samples) + { + let verdict = if median > VERDICT_MS { + let wait = match self.verdict(&key) { + None => None, + Some(Verdict::Suspect) => Some(RETRY_AFTER_S), + Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)), + }; + Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait })) + } else { + None + }; + self.set_verdict(key, verdict); } self.samples.clear(); self.seen = None; @@ -258,6 +331,7 @@ pub const HOLD_SECS: f64 = 0.25; pub struct SizeHold { photo: Option<lightcraft_catalog::PhotoId>, plan: Option<LoupePlan>, + fit: Option<[usize; 2]>, until: f64, } @@ -266,7 +340,7 @@ impl SizeHold { /// one is running (`gesturing`) and for [`HOLD_SECS`] after. `now` is in seconds. pub fn apply(&mut self, photo: lightcraft_catalog::PhotoId, now: f64, gesturing: bool, fresh: LoupePlan) -> LoupePlan { if self.photo != Some(photo) { - *self = SizeHold { photo: Some(photo), plan: Some(fresh), until: f64::NEG_INFINITY }; + *self = SizeHold { photo: Some(photo), plan: Some(fresh), fit: None, until: f64::NEG_INFINITY }; return fresh; } if gesturing { @@ -281,6 +355,18 @@ impl SizeHold { } } + /// The size the whole frame is shrunk to at rest (the drawn size): the one from before the + /// gesture while sizes are held (call after [`Self::apply`] in the same frame), else `fresh`. + pub fn display_fit(&mut self, now: f64, gesturing: bool, fresh: [usize; 2]) -> [usize; 2] { + match self.fit { + Some(held) if gesturing || now < self.until => held, + _ => { + self.fit = Some(fresh); + fresh + } + } + } + /// Whether sizes are being held at `now` (the caller asks for a repaint when it ends). pub fn holding(&self, now: f64) -> bool { now < self.until @@ -440,10 +526,10 @@ mod tests { fn a_drag_shrinks_the_draft_only_on_the_cpu() { use lightcraft_catalog::PhotoId; let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given a drag that started before the GPU was ready, it keeps its size until it ends: the @@ -453,33 +539,120 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None), Some(1.0)); + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); } - // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, - // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts - // small; other controls and photos keep full size + /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. + fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option<f32> { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale + } + + // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask + // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on + // that photo drafts small; once could be a busy moment. Other controls and photos keep full size #[test] - fn a_drag_over_budget_drafts_small_the_next_time() { + fn a_drag_over_budget_twice_drafts_small_the_next_time() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { d.observe(key, ms, None); d.observe(key, ms, None); // a render is timed once, however many frames show it } - assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); + } + + // Given a pair found slow, it drafts small only for a while: then a drag tries full size + // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict + #[test] + fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); + } + + // Given a drag only just over budget, it doesn't count against the pair (it would flip between + // the two sizes); one clearly over does + #[test] + fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); + } + + // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size + #[test] + fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); + } + + // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it + // can't read tries full size + #[test] + fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } } // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be @@ -489,18 +662,18 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given renders within budget whose frames (the texture upload) take it over, the drag counts @@ -510,50 +683,89 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); - for key in 1..=5 { - d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); } - d.observe(5, 8.0, Some(12.0)); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); // the same renders without the host's frame times (headless) stay within budget let mut headless = DragScale::default(); - headless.scale(drag, true, None); - for key in 1..=6 { - headless.observe(key, 8.0, None); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); } - headless.scale(None, true, None); - assert_eq!(headless.scale(drag, true, None), Some(1.0)); + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); // a hostile frame time counts as nothing, never a panic let mut hostile = DragScale::default(); - hostile.scale(drag, true, None); + hostile.scale(drag, true, None, 0.0); for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { hostile.observe(key, 8.0, Some(frame)); } - hostile.scale(None, true, None); - assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); + } + + // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: + // clearing them all would bring back a slow full-size drag for every pair + #[test] + fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); + } + + // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as + // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such + // drags, since one slow drag only makes the pair suspect + #[test] + fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end, and the next - // drag starts small + // the drag drops to the small draft after a few of them instead of at its end; after a second + // such drag, the next starts small #[test] fn a_drag_far_over_budget_drafts_small_before_it_ends() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); let mut scales = Vec::new(); for key in 1..=8 { d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None)); + scales.push(d.scale(drag, true, None, 0.0)); } // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); } // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is @@ -690,6 +902,34 @@ mod tests { assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); } + // Given a pinch, the display-fit size holds like the render sizes and is retaken after it + #[test] + fn a_pinch_holds_the_display_fit_size() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + hold.apply(p, 0.0, false, fit); + assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); + for k in 1..60 { + let now = k as f64 / 60.0; + hold.apply(p, now, true, zoomed); + assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); + } + // stopped at t = 59/60: still held within the hold time… + hold.apply(p, 1.1, false, zoomed); + assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); + // …then the size it ended at is taken, and kept while nothing moves + hold.apply(p, 1.3, false, zoomed); + assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); + assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); + // another photo forgets the held size + hold.apply(p, 1.5, true, zoomed); + hold.display_fit(1.5, true, [2561, 2049]); + hold.apply(PhotoId(2), 1.6, true, zoomed); + assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); + } + // Given another photo, the held sizes are forgotten #[test] fn another_photo_is_planned_afresh() { diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs new file mode 100644 index 000000000..8748bfd65 --- /dev/null +++ b/crates/ui-egui/src/reload.rs @@ -0,0 +1,80 @@ +//! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a +//! second or more, and a selection of them froze the window), with a row in the activity stack +//! that counts the files and can stop. What came of it is applied back here through the session's +//! command wrapper (one undo step, saved, sidecars auto-written), unless another library was opened +//! meanwhile or a reload started later has already been applied to the photo. Agents get the same with `wait: true`, which runs +//! the engine's command in place. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use lightcraft_engine::EngineError; +use lightcraft_engine::cmd::reload; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read (the menu item's name, already translated). +pub const LABEL: &str = "Reload from Disk"; + +/// `photo.reload` from the app: start reading the targeted files in the background → +/// `{background: true, count}`. `None` hands the command to the engine: with `wait: true`, or when +/// no targeted photo has a file (it then answers at once, refusing an empty selection as usual). +pub fn command(app: &mut LightcraftApp, p: &Value) -> Option<Result<Value, String>> { + if p.get("wait").and_then(Value::as_bool) == Some(true) { + return None; + } + let items = match reload::plan(&mut app.session, p) { + Ok(items) => items, + // named as `Session::execute` names a shared parameter check's refusal + Err(EngineError::BadParams { cmd, msg }) if cmd.is_empty() => { + return Some(Err(EngineError::BadParams { cmd: "photo.reload".into(), msg }.to_string())); + } + Err(e) => return Some(Err(e.to_string())), + }; + if items.is_empty() { + return None; + } + let count = items.len(); + // the photos shown from their embedded preview, to say afterwards whether they decode now + let preview_only: Vec<_> = + items.iter().filter(|i| app.session.catalog.photo(i.id).is_some_and(|ph| ph.preview_only.is_some())).map(|i| i.id).collect(); + let library = app.session.library_generation(); + let probe = reload::file_probe(&app.session); + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let stopped = cancel.clone(); + let work = move |task: lightcraft_engine::activity::TaskHandle| { + let total = items.len() as u64; + task.progress(0, total); + let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total)); + (items, results) + }; + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| { + if app.session.library_generation() != library { + return; + } + // as the command: one undo step, the durable save and the automatic XMP sidecar write + if let Err(e) = app.session.execute_fn("photo.reload", |s| reload::apply(s, items, results).map(|ids| reload::to_json(&ids))) { + return app.toast_error(ctx, e.to_string()); + } + let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count(); + // stopped before the files were read: they weren't tried again, so say nothing about them + // (a raw that did decode before the stop is still reported) + if stopped.load(std::sync::atomic::Ordering::Relaxed) && cleared == 0 { + return; + } + let message = match (count, preview_only.len(), cleared) { + (1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(), + (1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(), + (_, _, 0) => return, + (_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(), + (_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n), + }; + app.toast(ctx, message); + }; + if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) { + return Some(Err(e)); + } + Some(Ok(json!({"background": true, "count": count}))) +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 64ab067b7..1ed1071c2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -97,6 +97,12 @@ struct Finished { color: Option<Arc<egui::ColorImage>>, } +/// Immutable identity captured when a request is submitted, not when its result is collected. +struct Requested { + id: u64, + source: Option<lightcraft_catalog::Source>, +} + struct Queued { slot: Slot, priority: u32, @@ -183,7 +189,9 @@ const VARIANT_PRIORITY: u32 = 6; pub struct Renderer { #[cfg(test)] pub(crate) thumb_jobs_built: usize, - request_ids: HashMap<Slot, u64>, + requests: HashMap<Slot, Requested>, + #[cfg(test)] + failure_messages: Vec<String>, /// Request id of the pixels each interactive view (loupe, before, hover) shows: a draft that /// finishes after a newer request was made still replaces older pixels, so a long slider drag /// keeps updating the loupe instead of waiting for the drag to pause. @@ -272,7 +280,9 @@ impl Renderer { Renderer { #[cfg(test)] thumb_jobs_built: 0, - request_ids: HashMap::new(), + requests: HashMap::new(), + #[cfg(test)] + failure_messages: Vec::new(), shown_ids: HashMap::new(), epoch: 0, preview_generation: None, @@ -318,8 +328,13 @@ impl Renderer { if quick && !self.textures.contains_key(&slot) { // Stand-in path (`grid::request_thumb`): the embedded preview is still on its way, or // the real thumbnail is requested at the background priority whatever the caller's. - return self.pending.get(&Slot::ThumbQuick(photo.id)).is_some_and(|p| p.0 == key) - || !self.request_needed(slot, key, crate::panels::grid::BACKGROUND_THUMB_PRIORITY); + let quick_slot = Slot::ThumbQuick(photo.id); + if let Some(&(k, p)) = self.pending.get(&quick_slot) + && k == key + { + return p == priority.saturating_add(1) || !self.is_queued(quick_slot); + } + return !self.request_needed(slot, key, crate::panels::grid::BACKGROUND_THUMB_PRIORITY); } !self.request_needed(slot, key, priority) } @@ -334,17 +349,31 @@ impl Renderer { old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority) } - /// Whether this photo's embedded-preview thumbnail is being fetched right now: the grid has - /// asked, and neither the picture nor a render behind it is owed. - pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize) -> bool { - let Some((old, size, _, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; - old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && *quick && self.pending.contains_key(&Slot::ThumbQuick(photo.id)) + /// Whether this photo's embedded-preview thumbnail is pending at the requested priority, or + /// already running. A queued priority change must reach the grid's stand-in request. + pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool { + let Some((old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; + let slot = Slot::ThumbQuick(photo.id); + old.as_ptr() == Arc::as_ptr(photo) + && *size == bucket + && *quick + && self.pending.contains_key(&slot) + && !self.request_needed(slot, *key, priority.saturating_add(1)) } /// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's /// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`] /// compares against). pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64) { + let slot = Slot::Thumb(photo.id); + if self.pending.get(&slot).is_some_and(|(pending_key, _)| *pending_key != key) { + // An edit can still be rendering when Undo restores this embedded-final thumbnail. + // Retire its full-render identity before it can replace the correct stand-in. + self.pending.remove(&slot); + self.requests.remove(&slot); + self.pool.reprioritize(|s, p| if *s == slot { None } else { Some(p) }); + self.queue.retain(|q| q.slot != slot); + } self.remember_thumb(photo, bucket, key, true); self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key)); } @@ -459,7 +488,7 @@ impl Renderer { } self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); - self.request_ids.insert(slot, job.request_id); + self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) }); // (an offload keeps its own per-view stage caches; the flag tells it to) let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; if self.offload.is_some() { @@ -486,12 +515,33 @@ impl Renderer { /// Request a stand-in for `slot` (once per job key). pub fn request_quick(&mut self, slot: Slot, mut job: QuickJob, priority: u32) { if self.quick_tried.get(&slot) == Some(&job.key) { + if self.pending.get(&slot).is_some_and(|p| p.0 == job.key && p.1 != priority) { + // Scrolling changes a stand-in's priority too. Keep the original closure and + // request identity; running or completed work must never be started again. + let mut queued = false; + self.pool.reprioritize(|s, p| { + if *s == slot { + queued = true; + Some(priority) + } else { + Some(p) + } + }); + if queued && let Some(p) = self.pending.get_mut(&slot) { + p.1 = priority; + } + } return; } self.quick_tried.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); - self.request_ids.insert(slot, job.request_id); + let source = job + .small + .as_ref() + .map(|job| job.origin.clone()) + .or_else(|| job.embedded.as_ref().map(|(path, _, _)| lightcraft_catalog::Source::File { path: path.clone() })); + self.requests.insert(slot, Requested { id: job.request_id, source }); let key = job.key; let display = self.display.clone(); self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); @@ -510,7 +560,7 @@ impl Renderer { } self.pending.insert(slot, (key, priority)); let request_id = lightcraft_preview::next_tick(); - self.request_ids.insert(slot, request_id); + self.requests.insert(slot, Requested { id: request_id, source: None }); let display = self.display.clone(); self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display))); } @@ -524,7 +574,7 @@ impl Renderer { self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); - self.request_ids.insert(slot, job.request_id); + self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) }); let key = job.key; self.pool.submit( slot, @@ -546,7 +596,7 @@ impl Renderer { self.queue.retain(|q| !gone(&q.slot)); // (a render already running finishes unclaimed: its result is dropped) self.pending.retain(|s, _| !gone(s)); - self.request_ids.retain(|s, _| !gone(s)); + self.requests.retain(|s, _| !gone(s)); self.failed.retain(|s, _| !gone(s)); self.slides_live = !keep.is_empty(); } @@ -554,7 +604,7 @@ impl Renderer { /// Is a request for `slot` queued or running? /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { - self.request_ids.clear(); + self.requests.clear(); self.shown_ids.clear(); self.epoch = lightcraft_preview::next_tick(); self.preview_generation = None; @@ -679,14 +729,14 @@ impl Renderer { }); for s in dropped { self.pending.remove(&s); - self.request_ids.remove(&s); + self.requests.remove(&s); } - let request_ids = &mut self.request_ids; + let requests = &mut self.requests; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Variant(k) if stale(used.get(&k)) => { pending.remove(&q.slot); - request_ids.remove(&q.slot); + requests.remove(&q.slot); false } _ => true, @@ -787,9 +837,9 @@ impl Renderer { // an interactive view still takes a superseded draft that is newer than what it // shows, and the source it decoded: a drag supersedes every request before it // finishes, and a source thrown away with them is decoded again for every tick. - if self.request_ids.get(&slot) != Some(&r.request_id) { + let source = if self.requests.get(&slot).map(|request| request.id) != Some(r.request_id) { let newer_draft = slot.is_view() - && self.request_ids.contains_key(&slot) + && self.requests.contains_key(&slot) && r.request_id >= self.epoch && self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown) && r.rendered.is_ok(); @@ -797,10 +847,12 @@ impl Renderer { continue; } session.accept(&r); + None // superseded successful drafts have no failure diagnostic } else { - self.request_ids.remove(&slot); + let source = self.requests.remove(&slot).and_then(|request| request.source); session.accept(&r); - } + source + }; if slot.is_view() { self.shown_ids.insert(slot, r.request_id); } @@ -822,7 +874,10 @@ impl Renderer { } Err(e) => { if !matches!(slot, Slot::Prefetch(_)) { - log::warn!("render {slot:?}: {e}"); + let message = render_failure_message(slot, r.photo, source.as_ref(), &e); + #[cfg(test)] + self.failure_messages.push(message.clone()); + log::warn!("{message}"); self.failed.insert(slot, (r.key, e, self.catalog_rev, r.photo)); } continue; @@ -957,7 +1012,7 @@ impl Renderer { self.pool.reprioritize(|s, p| if matches!(s, Slot::Import(_)) { None } else { Some(p) }); self.queue.retain(|q| !matches!(q.slot, Slot::Import(_))); self.pending.retain(|s, _| !matches!(s, Slot::Import(_))); - self.request_ids.retain(|s, _| !matches!(s, Slot::Import(_))); + self.requests.retain(|s, _| !matches!(s, Slot::Import(_))); self.textures.retain(|s, _| !matches!(s, Slot::Import(_))); self.quick_tried.retain(|s, _| !matches!(s, Slot::Import(_))); } @@ -972,15 +1027,15 @@ impl Renderer { }); for s in dropped { self.pending.remove(&s); - self.request_ids.remove(&s); + self.requests.remove(&s); self.quick_tried.remove(&s); } - let request_ids = &mut self.request_ids; + let requests = &mut self.requests; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Thumb(id) if !keep.contains(&id) && q.priority <= 10 => { pending.remove(&q.slot); - request_ids.remove(&q.slot); + requests.remove(&q.slot); false } _ => true, @@ -994,11 +1049,43 @@ impl Renderer { } } +/// Include the source identity in diagnostics for every catalog-backed render slot (#1890). +/// Debug-format paths so whitespace/control characters cannot forge another log entry. +fn render_failure_message(slot: Slot, photo: PhotoId, source: Option<&lightcraft_catalog::Source>, error: &str) -> String { + match source { + Some(lightcraft_catalog::Source::File { path }) => format!("render {slot:?}, {photo:?}, file {path:?}: {error}"), + Some(lightcraft_catalog::Source::Demo { scene }) => format!("render {slot:?}, {photo:?}, demo scene {scene}: {error}"), + None => format!("render {slot:?}, {photo:?}, source unavailable: {error}"), + } +} + #[cfg(test)] mod thumbnail_tests { use super::*; use lightcraft_catalog::{Catalog, Op, Photo, Source}; + #[test] + fn render_failures_identify_the_file_for_thumbnail_and_view_slots() { + let source = Source::File { path: "/photos/trip/IMG_0042.nef".into() }; + for slot in [Slot::Thumb(PhotoId(42)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let message = render_failure_message(slot, PhotoId(42), Some(&source), "unsupported compression"); + assert!(message.contains("/photos/trip/IMG_0042.nef"), "{message}"); + assert!(message.contains("PhotoId(42)"), "{message}"); + assert!(message.ends_with(": unsupported compression"), "{message}"); + } + } + + #[test] + fn render_failure_paths_are_escaped_and_missing_sources_still_identified() { + let source = Source::File { path: "line\nbreak.nef".into() }; + let message = render_failure_message(Slot::Main, PhotoId(42), Some(&source), "decode failed"); + assert!(!message.contains('\n')); + assert!(message.contains("line\\nbreak.nef")); + assert!(render_failure_message(Slot::Main, PhotoId(42), None, "decode failed").contains("PhotoId(42), source unavailable")); + let demo = Source::Demo { scene: 7 }; + assert!(render_failure_message(Slot::Main, PhotoId(42), Some(&demo), "decode failed").contains("demo scene 7")); + } + struct Blocked; impl RenderOffload for Blocked { fn try_start(&mut self, _: Slot, job: RenderJob) -> Option<RenderJob> { @@ -1066,6 +1153,65 @@ mod thumbnail_tests { r.evict_thumbs(&Default::default(), 600); assert!(r.thumb_inputs.is_empty()); } + fn queued_quick_thumbnail(id: PhotoId) -> (Arc<Photo>, QuickJob) { + let mut session = Session::new(); + session.media.preview_loader = + Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.id = id; + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let p = session.catalog.photo(id).unwrap().clone(); + let job = session.thumb_job(id, 384).unwrap(); + (p, session.quick_thumb_job(&job).unwrap()) + } + + #[test] + fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + // Deterministic queue: no native worker starts. + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true); + r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1 + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1 + let request_id = r.requests[&slot].id; + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1 + assert!(r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); + assert_eq!(r.requests[&slot].id, request_id); + assert_eq!(r.pool.queued(), 2); + r.pool.run_inline(1); + let done = r.pool.try_recv().unwrap(); + assert_eq!(done.slot, slot); + assert_eq!(done.result.result.request_id, request_id); + assert_eq!(done.result.result.quick, Some(QuickSource::Embedded)); + // A finished result awaiting collection also must not start another extraction. + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + assert_eq!(r.pool.queued(), 1); + } + + #[test] + fn queued_standin_demotes_when_it_leaves_the_visible_grid() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true); + r.request_quick(slot, quick.clone(), 11); + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); + assert!(!r.thumb_current(&p, 384, 5)); + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + r.pool.run_inline(1); + assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2))); + } + #[test] fn failures_retry_after_revision_and_quick_only_is_not_final() { let p = photo(); @@ -1128,6 +1274,8 @@ mod thumbnail_tests { image.data.fill([0.3, 0.2, 0.1]); Ok((image, Default::default())) })); + // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) + s.media.reduced_loader = None; s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); let mut app = crate::LightcraftApp::new(s, crate::Services::default()); app.renderer.keep_pixels = true; @@ -1148,6 +1296,107 @@ mod thumbnail_tests { crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); } + fn relink_destination() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lc-render-relinked-{}-{}.nef", std::process::id(), lightcraft_preview::next_tick())); + std::fs::write(&path, b"replacement source").unwrap(); + path + } + + #[test] + fn completed_failure_keeps_requested_source_after_relink() { + let replacement = relink_destination(); + for slot in [Slot::Thumb(PhotoId(1)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, job, 100); + let (submitted, job) = take_job(&work); + assert_eq!(submitted, slot); + let result = job.run(); + assert!(result.rendered.is_err(), "the old request has already failed"); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + assert!( + matches!(&app.session.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path } if path.ends_with(replacement.file_name().unwrap().to_str().unwrap())) + ); + finish(&mut app, &work, &ctx, slot, result); + assert_eq!(app.renderer.failure(slot), Some("simulated decode failure")); + assert!(!app.renderer.is_pending(slot)); + let message = app.renderer.failure_messages.last().expect("the collector emitted a diagnostic"); + assert!(message.contains("audit.jpg"), "{message}"); + assert!(!message.contains("lc-render-relinked-"), "the failed request did not read the replacement: {message}"); + assert!(message.ends_with(": simulated decode failure"), "{message}"); + } + std::fs::remove_file(replacement).unwrap(); + } + + #[test] + fn superseded_failure_does_not_take_the_new_requests_source() { + let replacement = relink_destination(); + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let slot = Slot::Main; + let old_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, old_job, 100); + let (_, old_job) = take_job(&work); + let old_result = old_job.run(); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + let new_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, new_job, 100); + let (_, new_job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, old_result); + assert!(app.renderer.failure_messages.is_empty(), "obsolete failures stay suppressed"); + assert!(app.renderer.is_pending(slot)); + finish(&mut app, &work, &ctx, slot, new_job.run()); + let message = app.renderer.failure_messages.last().expect("new failure is reported"); + assert!(message.contains("lc-render-relinked-"), "{message}"); + assert!(!message.contains("audit.jpg"), "{message}"); + assert!(app.renderer.requests.is_empty(), "completed request identity is released"); + std::fs::remove_file(replacement).unwrap(); + } + + #[test] + fn grid_queued_standin_changes_priority_without_restarting() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline + let id = PhotoId(1); + let slot = Slot::ThumbQuick(id); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + let request_id = app.renderer.requests[&slot].id; + assert_eq!(app.renderer.pending[&slot].1, 6); + assert_eq!(app.renderer.pool.queued(), 1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + let built = app.renderer.thumb_jobs_built; + app.renderer.pool.run_inline(1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 0); + app.renderer.poll(&ctx, &mut app.session); + assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded)); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.thumb_jobs_built, built); + assert_eq!(app.renderer.pool.queued(), 0); + assert!(!app.renderer.is_pending(Slot::Thumb(id))); + assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id))); + assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render"); + } + #[test] fn unchanged_ready_and_running_frames_build_no_jobs() { let (mut app, work, ctx) = fixture(); @@ -1514,6 +1763,74 @@ mod thumbnail_tests { expected.as_bytes() ); } + + #[test] + fn undo_to_embedded_thumb_rejects_late_full_render() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); + let id = PhotoId(1); + let quick_slot = Slot::ThumbQuick(id); + let full_slot = Slot::Thumb(id); + let settings_a = app.session.catalog.photo(id).unwrap().develop.clone(); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + let (key_a, pixels_a) = { + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + (shown.key, shown.pixels.as_ref().unwrap().pixels.clone()) + }; + assert!(app.renderer.textures.contains_key(&quick_slot)); + assert!(!app.renderer.textures.contains_key(&full_slot)); + assert!(work.borrow().jobs.is_empty()); + + let mut settings_b = (*settings_a).clone(); + settings_b.light.exposure += 0.5; + app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap(); + assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + // Manual owns dispatched jobs: hold B until the real Undo command restores A. + let (slot, held_b) = take_job(&work); + assert_eq!(slot, full_slot); + let key_b = held_b.key; + assert_ne!(key_b, key_a); + assert!(app.renderer.is_pending(full_slot)); + + app.run("edit.undo", serde_json::json!({})).unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref()); + assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + { + let restored = app.renderer.thumb(id).unwrap(); + assert_eq!(restored.key, key_a); + assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(restored.quick, Some(QuickSource::Embedded)); + } + assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render"); + assert_eq!(app.renderer.pool.queued(), 0); + + let result_b = held_b.run(); + assert_eq!(result_b.key, key_b); + let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle"); + assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A"); + finish(&mut app, &work, &ctx, slot, result_b); + + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)"); + assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed"); + assert_eq!(quick.key, key_a); + assert_eq!(quick.quick, Some(QuickSource::Embedded)); + assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a); + assert!(!app.renderer.is_pending(full_slot)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b9d1874a1..e7b58a9b5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -203,6 +203,8 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ // Keep existing primary shortcuts and honour custom keymap assignments. ("F6", "view.filmstrip", "{}"), ("F7", "view.leftPanel", "{}"), + // and F8 the right panel + ("F8", "view.rightPanel", "{}"), ("Shift+M", "version.create", "{}"), // grid thumbnail size: Classic's key is `=`; `+` (keypad, or Shift+= reported as Plus) agrees ("Plus", "view.thumbSizeUp", "{}"), @@ -297,12 +299,16 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { }) } -/// Default brackets rate in grids. Remapped brush keys retain their brush action, and a -/// saved assignment of either bracket to another command takes precedence over this default. -fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut: (Modifiers, Key)) -> Option<&'static str> { +/// Default brackets rate in grids, and the default backslash shows / hides the Filter Bar there +/// (Lightroom Classic's Library keys; Detail keeps brush size and Show Original). The full-screen +/// preview keeps the grid's view but shows the photo alone, so backslash stays Show Original there. +/// Remapped brush and Show Original keys retain their action, and a saved assignment of the key to +/// another command takes precedence over this default. +fn grid_key_command(keymap: &Keymap, grid: bool, fullscreen: bool, id: &'static str, shortcut: (Modifiers, Key)) -> Option<&'static str> { let command = match (grid, id, shortcut) { (true, "brush.smaller", (m, Key::OpenBracket)) if m == Modifiers::NONE => "photo.decreaseRating", (true, "brush.larger", (m, Key::CloseBracket)) if m == Modifiers::NONE => "photo.increaseRating", + (true, "view.showOriginal", (m, Key::Backslash)) if m == Modifiers::NONE && !fullscreen => "view.filterBar", _ => return Some(id), }; if keymap.iter().any(|(other, sc)| other != id && find_bindable(other).is_some() && parse(sc) == Some(shortcut)) { None } else { Some(command) } @@ -389,7 +395,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { && !native(sc) && !(k == Key::Escape && popup_open) && matches(i, m, k) - && let Some(id) = grid_bracket_command(keymap, grid, b.id, (m, k)) + && let Some(id) = grid_key_command(keymap, grid, app.ui.fullscreen, b.id, (m, k)) { fire.push(id.to_string()); } @@ -466,6 +472,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, e); } } + let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); for f in fire { // a slideshow: ←/→ step (a cut) if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { @@ -558,6 +565,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } continue; } + R::Crop if app.ui.tool == "guidedUpright" => { + // Delete removes the selected guide alone; with none selected it removes + // nothing (the photo itself is never deleted while the tool is open) + if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) { + if photo == active.0 + && let Some(d) = app.session.develop_of(active) + && i < d.geometry.guides.len() + { + let guides: Vec<[f64; 4]> = + d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); + let _ = app.run("geometry.guides", json!({"guides": guides})); + } + app.ui.selected_guide = None; + } + continue; + } R::RedEye => continue, _ => {} } @@ -587,9 +610,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } continue; } - // X is both reject (library) and swap crop aspect (crop tool) + // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, + // swap once: twice would put the crop straight back (issue #1866). if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { - let _ = app.run("crop.rotateAspect", json!({})); + if !rotate_fires { + let _ = app.run("crop.rotateAspect", json!({})); + } continue; } if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { @@ -807,6 +833,27 @@ mod tests { assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); } + /// Classic's F8 hides the right panel and shows the same one again; the other panels stay. + #[test] + fn classic_f8_hides_the_right_panel_and_brings_the_same_one_back() { + use crate::state::RightPanel; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Keywords; + h.settle(std::time::Duration::from_secs(10)); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Keywords, "the panel F8 hid comes back"); + // a tool ends when F8 hides it; with nothing hidden before, F8 opens Edit + h.app.ui.right = RightPanel::None; + h.app.ui.right_hidden = RightPanel::None; + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Edit); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; @@ -1269,3 +1316,7 @@ mod relative_rating_tests; #[cfg(test)] #[path = "shortcuts/tests_flag_cycle.rs"] mod flag_cycle_tests; + +#[cfg(test)] +#[path = "shortcuts/tests_filter_bar_key.rs"] +mod filter_bar_key_tests; diff --git a/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs b/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs new file mode 100644 index 000000000..41d0b1c2d --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs @@ -0,0 +1,122 @@ +//! Backslash (\) shows / hides the Filter Bar in the library grids, as in Lightroom Classic's Library +//! module; in Detail it keeps showing the original (Develop's Before / After key). +use egui::{Key, Modifiers}; +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::state::{BeforeAfter, ViewMode}; + +fn app() -> lightcraft_engine::Result<LightcraftApp> { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=2 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-filter-bar-key/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-15", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [1]}))?; + Ok(LightcraftApp::new(s, crate::Services { png: None, ..Default::default() })) +} + +fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<Key>, text: Option<&mut String>) { + let events = key + .map(|key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }) + .into_iter() + .collect(); + let mut text = text; + let mut out = ctx.run_ui( + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| { + if let Some(text) = text.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("filter-bar-key-search"))).request_focus(); + } + super::handle(app, ui.ctx()); + }, + ); + out.textures_delta.clear(); +} + +#[test] +fn backslash_toggles_the_filter_bar_in_the_grids() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut app = app().unwrap(); + app.ui.view = view; + let ctx = egui::Context::default(); + assert!(!app.ui.filter_bar); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert!(app.ui.filter_bar, "{view:?}: \\ shows the Filter Bar"); + // the grid's \ is not Show Original: nothing is left switched on for Detail + assert_eq!(app.ui.before_after, BeforeAfter::Off, "{view:?}"); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert!(!app.ui.filter_bar, "{view:?}: \\ hides it again"); + assert_eq!(app.ui.before_after, BeforeAfter::Off, "{view:?}"); + assert_eq!(app.ui.view, view); + } +} + +#[test] +fn backslash_in_detail_still_shows_the_original() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::Detail; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert_eq!(app.ui.before_after, BeforeAfter::Original); + assert!(!app.ui.filter_bar); + assert_eq!(app.ui.view, ViewMode::Detail); +} + +/// F covers the grid with the photo alone: \ there is Show Original (latched), not the hidden Filter Bar. +#[test] +fn backslash_in_the_full_screen_preview_shows_the_original() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut app = app().unwrap(); + app.ui.view = view; + app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(app.ui.fullscreen); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert_eq!(app.ui.before_after, BeforeAfter::Original, "{view:?}: \\ latches the original"); + assert!(!app.ui.filter_bar, "{view:?}: the hidden Filter Bar is left alone"); + assert_eq!(app.ui.view, view, "the grid is the view behind the preview"); + } +} + +#[test] +fn saved_keymap_wins_over_the_grid_backslash() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + // Show Original moved to F2: F2 keeps that meaning in the grid, \ does nothing + super::assign(&mut app.ui.settings.keymap, "view.showOriginal", Some("F2")).unwrap(); + frame(&mut app, &ctx, Some(Key::F2), None); + assert!(!app.ui.filter_bar, "a remapped Show Original key is not the Filter Bar key"); + assert_eq!(app.ui.before_after, BeforeAfter::Original); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert!(!app.ui.filter_bar); + // \ given to another command runs that command + super::reset(&mut app.ui.settings.keymap, "view.showOriginal").unwrap(); + super::assign(&mut app.ui.settings.keymap, "photo.pick", Some("\\")).unwrap(); + frame(&mut app, &ctx, Some(Key::Backslash), None); + assert_eq!(app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick); + assert!(!app.ui.filter_bar); +} + +#[test] +fn backslash_yields_to_a_focused_text_field() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + let mut text = String::new(); + frame(&mut app, &ctx, None, Some(&mut text)); + frame(&mut app, &ctx, Some(Key::Backslash), Some(&mut text)); + assert!(ctx.egui_wants_keyboard_input()); + assert!(!app.ui.filter_bar); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..8cb37d3ef 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -199,6 +199,75 @@ impl InfoOverlay { } } +/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe +/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LoupeInfoField { + #[default] + None, + FileName, + /// The capture date and time (the import date when the photo has none). + CaptureDate, + /// Width × height in pixels. + Dimensions, + /// Shutter speed, aperture, ISO and focal length. + Exposure, + Camera, + Lens, + /// Camera and lens on one line. + CameraAndLens, +} + +impl LoupeInfoField { + /// Every field, in the order View Options lists them. + pub const ALL: &'static [LoupeInfoField] = &[ + LoupeInfoField::None, + LoupeInfoField::FileName, + LoupeInfoField::CaptureDate, + LoupeInfoField::Dimensions, + LoupeInfoField::Exposure, + LoupeInfoField::Camera, + LoupeInfoField::Lens, + LoupeInfoField::CameraAndLens, + ]; + + pub fn label(self) -> &'static str { + match self { + LoupeInfoField::None => "None", + LoupeInfoField::FileName => "File Name", + LoupeInfoField::CaptureDate => "Capture Date", + LoupeInfoField::Dimensions => "Dimensions", + LoupeInfoField::Exposure => "Exposure", + LoupeInfoField::Camera => "Camera", + LoupeInfoField::Lens => "Lens", + LoupeInfoField::CameraAndLens => "Camera & Lens", + } + } +} + +/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and +/// Info 2 ([`InfoOverlay::Exposure`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoupeInfo { + pub info1: [LoupeInfoField; 3], + pub info2: [LoupeInfoField; 3], +} + +impl LoupeInfo { + /// Info 1's lines before they are changed (and after Use Defaults). + pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; + /// Info 2's lines before they are changed (and after Use Defaults). + pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; +} + +impl Default for LoupeInfo { + fn default() -> Self { + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 } + } +} + /// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with /// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the /// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across @@ -391,6 +460,9 @@ pub struct AppSettings { /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point /// when the photo has one (`view.zoomToFocus`). pub zoom_to_focus: bool, + /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. + #[serde(deserialize_with = "lenient")] + pub loupe_info: LoupeInfo, } impl Default for AppSettings { @@ -420,6 +492,7 @@ impl Default for AppSettings { keyword_chips: KeywordChips::default(), focus_point: false, zoom_to_focus: true, + loupe_info: LoupeInfo::default(), } } } @@ -642,6 +715,9 @@ pub struct UiState { pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, + /// The right panel F8 (`view.rightPanel`) hid, which the next F8 shows again. + #[serde(skip)] + pub right_hidden: RightPanel, /// Widths of the left sidebar and of the right panel in points (dragging their inner edge /// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]). pub left_width: f32, @@ -699,7 +775,8 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, - /// Last explicitly chosen aspect, retained across photos and app launches. + /// Last explicitly chosen aspect, retained across photos and app launches; `None` (nothing + /// chosen yet) crops locked to the photo's own shape, as Lightroom starts (#991). pub crop_default_aspect: Option<serde_json::Value>, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · @@ -796,6 +873,10 @@ pub struct UiState { /// A keyword just made from the sidebar: Keywords opens down to it, once. #[serde(skip)] pub reveal_keyword: Option<String>, + /// A folder Photo ▸ Show in Folders went to (its Folders row's path): the sidebar's Folders + /// section opens down to it and scrolls to it, once. + #[serde(skip)] + pub reveal_folder: Option<String>, /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). #[serde(skip)] pub dragging_album: Option<u64>, @@ -814,6 +895,10 @@ pub struct UiState { pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>, /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove". pub tool: String, + /// Guided Upright: the selected guide (photo id + index) while the tool is open. A Delete / + /// Backspace on it removes that guide alone; a click away or leaving the tool clears it. + #[serde(skip)] + pub selected_guide: Option<(u64, usize)>, pub brush_size: f32, pub brush_feather: f32, pub brush_flow: f32, @@ -1238,6 +1323,9 @@ pub enum Dialog { }, About, Shortcuts, + /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply + /// as they are made. + ViewOptions, } fn yes() -> bool { @@ -1260,6 +1348,14 @@ pub struct SyncCounts { pub moved: usize, } +impl SyncCounts { + /// The folder is there and the scan found nothing to bring in, relink, remove or read: the + /// dialog only says so (files it skipped are not changes). + pub fn up_to_date(&self) -> bool { + !self.offline && self.new + self.moved + self.missing + self.metadata == 0 + } +} + impl Default for UiState { fn default() -> Self { UiState { @@ -1274,6 +1370,7 @@ impl Default for UiState { left_width: LEFT_WIDTH.default, right_width: RIGHT_WIDTH.default, right: RightPanel::Edit, + right_hidden: RightPanel::None, presets: false, preset_thumbs: false, filmstrip: true, @@ -1337,6 +1434,7 @@ impl Default for UiState { dragging_photos: None, reveal_album: None, reveal_keyword: None, + reveal_folder: None, dragging_album: None, dragging_section: None, curve_channel: "parametric".into(), @@ -1344,6 +1442,7 @@ impl Default for UiState { grading_mode: "3way".into(), grading_hidden: None, tool: String::new(), + selected_guide: None, brush_size: 0.04, brush_feather: 50.0, brush_flow: 60.0, @@ -1508,3 +1607,28 @@ fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result } Ok(values) } + +#[cfg(test)] +mod loupe_info_tests { + use super::*; + + /// The lines chosen in View Options are saved with the app's settings; a settings file from + /// before they existed, or with a line this build doesn't know, gets the default lines and + /// keeps the rest of its settings. + #[test] + fn view_options_lines_are_saved_with_the_settings() { + let mut settings = AppSettings::default(); + settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert_eq!(read.loupe_info, settings.loupe_info); + + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert_eq!(old.loupe_info, LoupeInfo::default()); + let newer: AppSettings = + serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); + assert_eq!(newer.loupe_info, LoupeInfo::default()); + assert!(!newer.film_names, "the rest of the settings are still read"); + } +} diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index cd4a267af..0473a38fa 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -18,12 +18,32 @@ use crate::theme::Tokens; /// A running scan of the dialog's folder. pub struct SyncTask { path: String, + /// What the dialog calls the folder (its row's detail). + name: String, progress: Arc<SyncProgress>, rx: std::sync::mpsc::Receiver<FolderChanges>, /// Its row in the activity stack; ✕ cancels the scan and closes the dialog. guard: TaskGuard, } +/// What a scan's row shows: (done, of how many, detail). While the folder is listed, which comes +/// first and on a network share can take minutes, a bar that moves on its own and how many +/// folders and files were listed (a count stuck at "0 of 64085" looks like a scan that stopped); +/// then the files and photos checked. +pub(crate) fn scan_row(progress: &SyncProgress, name: &str) -> (u64, u64, String) { + match progress.listing() { + Some((folders, files)) => (0, 0, format!("{} · {name}", listing_text(folders, files))), + None => { + let (done, total) = progress.counts(); + (done as u64, total as u64, name.to_string()) + } + } +} + +fn listing_text(folders: usize, files: usize) -> String { + crate::i18n::tr_format!("Listing folders — folders: {folders}, files: {files}", folders = folders, files = files) +} + /// Open the dialog for folder `path` (called `name` in it) and start scanning. pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> { cancel(app); @@ -40,7 +60,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu job(); let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone())); guard.detail(name); - app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard }); + app.sync = Some(SyncTask { path: path.to_string(), name: name.to_string(), progress, rx, guard }); let d = SyncChoice::default(); app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), @@ -90,8 +110,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = None; return; } - let (done, total) = task.progress.counts(); - task.guard.progress(done as u64, total as u64); + let (done, total, detail) = scan_row(&task.progress, &task.name); + task.guard.progress(done, total); + task.guard.detail(&detail); ctx.request_repaint_after(std::time::Duration::from_millis(100)); let changes = match task.rx.try_recv() { Ok(c) => c, @@ -120,9 +141,21 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } -/// How far the scan is (files probed, of how many). -fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { - app.sync.as_ref().map(|t| t.progress.counts()) +/// How far the scan is: the folders and files listed while it lists the folder, else the files +/// and photos checked (done, of how many). +enum ScanStage { + Listing(usize, usize), + Checking(usize, usize), +} + +fn progress(app: &LightcraftApp) -> Option<ScanStage> { + app.sync.as_ref().map(|t| match t.progress.listing() { + Some((folders, files)) => ScanStage::Listing(folders, files), + None => { + let (done, total) = t.progress.counts(); + ScanStage::Checking(done, total) + } + }) } /// The dialog's body. @@ -138,7 +171,8 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { ui.horizontal(|ui| { ui.spinner(); match progress(app) { - Some((done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), + Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)), + Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), _ => ui.label(crate::i18n::tr("Scanning…")), } }); @@ -153,36 +187,51 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { crate::widgets::register(ui.ctx(), widget, r.rect); }; let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text)); + let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); + let skipped = |ui: &mut egui::Ui| { + if c.duplicates > 0 { + dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates)); + } + if c.unreadable > 0 { + dim(ui, &count("Skipped: files that can't be read", c.unreadable)); + } + }; + if c.up_to_date() { + // nothing to choose: the answer takes the choices' place, and the one button closes + // (`only_shows`) + ui.label(crate::i18n::tr("The library is up to date with this folder.")); + skipped(ui); + return; + } choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew"); choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved"); choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing"); choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata"); ui.add_space(6.0); - let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); - if c.new + c.moved + c.missing + c.metadata == 0 { - dim(ui, crate::i18n::tr("The library is up to date with this folder.")); - } if c.missing > 0 { dim(ui, crate::i18n::tr("Missing photos move to Recently Deleted and can be restored; no file on disk is touched.")); } if c.metadata > 0 { dim(ui, crate::i18n::tr("Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.")); } - if c.duplicates > 0 { - dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates)); - } - if c.unreadable > 0 { - dim(ui, &count("Skipped: files that can't be read", c.unreadable)); - } + skipped(ui); } /// Synchronize with the dialog's choices: the scan the dialog showed is handed to a worker /// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went /// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices. +/// A folder that is up to date has nothing to hand over: confirming it only closes the dialog. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> { - let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { + let Dialog::SynchronizeFolder { path, name, disk, counts: Some(counts), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg + else { return Err("the folder is still being scanned".into()); }; + // (before the busy checks: nothing is imported or written, so a running import or + // synchronize can't clash with it, and the dialog closes either way. Like every result the + // dialog shows, it is the scan's: a file added since is found by the next Synchronize) + if counts.up_to_date() { + return Ok(json!({"upToDate": true})); + } if app.sync_run.is_some() { return Err(crate::i18n::tr("A folder is already being synchronized").to_string()); } diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs index 70abd629a..86dc79381 100644 --- a/crates/ui-egui/src/tasks.rs +++ b/crates/ui-egui/src/tasks.rs @@ -50,6 +50,34 @@ pub fn spawn<T: Send + 'static>( kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); + start(app, label, guard, work, done) +} + +/// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`, +/// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress +/// through the handle it is given. +pub fn spawn_tracked<T: Send + 'static>( + app: &mut LightcraftApp, + label: &str, + kind: &'static str, + cancel: std::sync::Arc<std::sync::atomic::AtomicBool>, + work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel)); + let handle = guard.handle(); + start(app, label, Some(guard), move || work(handle), done) +} + +/// Run `work` on a worker thread and list it in `app.tasks` with its activity row (if any). +fn start<T: Send + 'static>( + app: &mut LightcraftApp, + label: &str, + guard: Option<lightcraft_engine::activity::TaskGuard>, + work: impl FnOnce() -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { let (tx, rx) = channel::<Finish>(); let repaint = app.tasks.repaint.clone(); @@ -64,7 +92,6 @@ pub fn spawn<T: Send + 'static>( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job(); - let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 126b3ef58..bce9d2eb1 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -361,6 +361,39 @@ fn final_merge_shows_a_row_and_cancels() { let _ = std::fs::remove_dir_all(dir); } +/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last +/// Settings" uses them; a kind never merged opens with the defaults. +#[test] +fn merge_dialog_remembers_the_last_settings_per_kind() { + let (mut h, dir) = bracket("merge-last"); + let opts = crate::merge::MergeDialog { + align: false, + deghost: "high".into(), + auto_settings: false, + ..crate::merge::MergeDialog::for_command("merge.hdr") + }; + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + h.app.ui.dialog = None; + crate::merge::start_final(&mut h.app, &opts).unwrap(); + let row = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(row).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &opts); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama")); + h.app.ui.dialog = None; + // older or hand-edited preferences: missing fields take their defaults, junk is ignored + h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"})); + h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk")); + let pano = crate::merge::last_options(&h.app, "merge.panorama"); + assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true)); + assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama")); + let _ = std::fs::remove_dir_all(dir); +} + /// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the /// rest. #[test] diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index f48e150b9..32cc906fe 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -1,5 +1,5 @@ -//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that -//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed. +//! The shared album picker in smart rules and the import review: sidebar hierarchy, path search, +//! keyboard choices, and only valid destinations. Import also retains None and New album. use std::time::Duration; @@ -12,26 +12,31 @@ use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); +/// Dispatch a public command and return its successful engine result. fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { let r = h.request("engine.execute", json!({"command": command, "params": params}), T); assert_eq!(r["ok"], true, "{command}: {r}"); r["result"].clone() } +/// Read the album ID returned by a successful creation command. fn id(v: serde_json::Value) -> u64 { v["id"].as_u64().expect("an id") } +/// Click a registered widget through the public control channel and let frames settle. fn click(h: &mut Headless, id: &str) { let r = h.request("ui.clickWidget", json!({"id": id}), T); assert_eq!(r["ok"], true, "{id}: {r}"); h.settle(SETTLE); } +/// Check the current frame registry for an exact widget ID. fn has(h: &Headless, id: &str) -> bool { h.app.widgets.iter().any(|(w, _)| w == id) } +/// Read the Album rule value from the active smart-rule editor. fn value(h: &Headless) -> serde_json::Value { let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") }; @@ -126,3 +131,343 @@ fn typing_after_a_folder_click_still_searches() { h.settle(SETTLE); assert!(has(&h, &format!("albumPickerItem:{trip}:rules-0")) && !has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "searching for trip"); } + +/// The import review reuses the same tree, while accepting only regular album destinations. +mod import_review { + use super::*; + + struct Review { + h: Headless, + folder: u64, + nested: u64, + album: u64, + other: u64, + smart: u64, + } + + /// Open the standard import review fixture with nested and same-named albums. + fn review() -> Review { + review_at([1300.0, 950.0], 1.0) + } + + /// Build the import review fixture at the requested logical size and display density. + fn review_at(size: [f32; 2], density: f32) -> Review { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, size, density); + let folder = id(exec(&mut h, "album.create", json!({"name": "Travel", "folder": true}))); + let nested = id(exec(&mut h, "album.create", json!({"name": "2026", "folder": true, "parent": folder}))); + let album = id(exec(&mut h, "album.create", json!({"name": "Favorites", "parent": nested, "addSelected": false}))); + let other = id(exec(&mut h, "album.create", json!({"name": "Favorites", "addSelected": false}))); + let smart = id(exec(&mut h, "album.createSmart", json!({"name": "Smart travel", "parent": folder, "rules": {"rating": 4}}))); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) }); + h.settle(SETTLE); + Review { h, folder, nested, album, other, smart } + } + + /// Read the active import review without accepting a silently closed dialog. + fn destination(h: &Headless) -> &crate::import::ImportDialog { + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("import review remains open") }; + opts + } + + /// #2362: the actual import dialog browses nested folders and remembers the chosen full path. + #[test] + fn import_album_picker_preserves_the_sidebar_hierarchy() { + let Review { mut h, folder, nested, album, other, smart } = review(); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerFolder:{folder}:import-album"))); + assert!(has(&h, &format!("albumPickerItem:{other}:import-album"))); + assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")), "nested album starts folded"); + click(&mut h, &format!("albumPickerFolder:{folder}:import-album")); + assert_eq!(destination(&h).album, None, "a folder is for browsing, not a destination"); + assert!(!has(&h, &format!("albumPickerItem:{smart}:import-album")), "smart albums cannot receive imports"); + click(&mut h, &format!("albumPickerFolder:{nested}:import-album")); + click(&mut h, &format!("albumPickerItem:{album}:import-album")); + assert_eq!(destination(&h).album, Some(album)); + assert!(destination(&h).new_album.is_empty()); + assert!(h.painted_text().iter().any(|t| t == "Travel / 2026 / Favorites"), "duplicate album names have their full path"); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "selected album's ancestors reopen"); + } + + /// Type through the public control channel and wait for the search or field to update. + fn text(h: &mut Headless, value: &str) { + let r = h.request("ui.text", json!({"text": value}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + } + + /// Send a public keyboard event and wait for its resulting UI state. + fn key(h: &mut Headless, key: &str) { + let r = h.request("ui.key", json!({"key": key}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + } + + /// Replace the pending new album name using normal field selection and typing. + fn replace_name(h: &mut Headless, name: &str) { + click(h, "field:importAlbumName"); + let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + text(h, name); + } + + /// None, New and an existing album replace each other without retaining a stale pending name. + #[test] + fn import_album_picker_retains_none_new_and_existing_transitions() { + let Review { mut h, album, .. } = review(); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).album, None); + assert_eq!(destination(&h).new_album, "Imported Photos"); + replace_name(&mut h, "Été 東京"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).new_album, "Été 東京", "choosing New again keeps the typed name"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "travel 2026 fav"); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(album)); + assert!(destination(&h).new_album.is_empty(), "existing album replaces a pending new album"); + assert!(!has(&h, "field:importAlbumName")); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert_eq!(destination(&h).album, None); + assert!(destination(&h).new_album.is_empty()); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "Leave no orphan album"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert!(destination(&h).new_album.is_empty(), "None clears a pending name too"); + } + + /// The picker must leave room for a usable name field; a full-width button followed by an + /// inline name field made the resizable dialog grow on every frame, off the native window. + #[test] + fn import_album_picker_keeps_the_new_name_inside_the_dialog() { + let Review { mut h, .. } = review(); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + for _ in 0..20 { + h.step(); + } + let name = h.app.widgets.iter().find(|(id, _)| id == "field:importAlbumName").unwrap().1; + assert!(name.width() >= 160.0, "the name field is usable: {name:?}"); + assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 950.0)).contains_rect(name), "name stays on screen: {name:?}"); + let picker = |h: &Headless| h.app.widgets.iter().find(|(id, _)| id == "albumPicker:import-album").unwrap().1; + let before = picker(&h); + for _ in 0..10 { + h.step(); + } + let after = picker(&h); + assert!( + (before.width() - after.width()).abs() < 0.5 && (before.left() - after.left()).abs() < 0.5, + "the dialog stops growing: {before:?} -> {after:?}" + ); + } + + /// Path search is case-insensitive and Escape preserves review edits and user-provided names. + #[test] + fn import_album_picker_search_escape_and_unicode_names() { + let Review { mut h, folder, album, .. } = review(); + let unicode = id(exec(&mut h, "album.create", json!({"name": "La fête 東京", "parent": folder, "addSelected": false}))); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "Keep this name"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "no such album"); + assert!(h.painted_text().iter().any(|t| t == "No albums match")); + key(&mut h, "Enter"); + assert!(has(&h, "albumPickerSearch:import-album"), "Enter with no result keeps the picker open"); + key(&mut h, "Escape"); + assert!(!has(&h, "albumPickerSearch:import-album")); + assert_eq!(destination(&h).new_album, "Keep this name", "Escape leaves the review's edits intact"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "FÊTE"); + assert!(has(&h, &format!("albumPickerItem:{unicode}:import-album"))); + assert!(!has(&h, &format!("albumPickerItem:{album}:import-album"))); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(unicode)); + assert!(h.painted_text().iter().any(|t| t == "Travel / La fête 東京"), "user names are kept verbatim"); + } + + /// The catalog custom order wins over alphabetical order in the import picker. + #[test] + fn import_album_picker_follows_custom_sidebar_order() { + let Review { mut h, other, .. } = review(); + let last_by_name = id(exec(&mut h, "album.create", json!({"name": "Zebra", "addSelected": false}))); + exec(&mut h, "album.reorder", json!({"id": last_by_name, "before": other})); + click(&mut h, "albumPicker:import-album"); + let top = |id| h.app.widgets.iter().find(|(w, _)| w == &format!("albumPickerItem:{id}:import-album")).unwrap().1.top(); + assert!(top(last_by_name) < top(other), "sidebar order takes priority over alphabetical order"); + } + + /// Match painted text inside the specified control instead of anywhere in the frame. + fn button_text(h: &Headless, id: &str, expected: &str) -> bool { + let rect = h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1; + h.view.painted_shapes().iter().any(|shape| matches!(shape, egui::Shape::Text(t) if t.galley.job.text == expected && rect.contains(t.pos))) + } + + /// Long paths and editable names remain usable across themes, viewport sizes and display densities. + #[test] + fn import_album_picker_fits_long_names_across_themes_sizes_and_densities() { + for (size, density) in [([960.0, 760.0], 1.0), ([1366.0, 900.0], 2.0), ([960.0, 760.0], 2.25)] { + let Review { mut h, folder, .. } = review_at(size, density); + let long = "La fête des photographies – 東京 – ".repeat(12); + let album = id(exec(&mut h, "album.create", json!({"name": long, "parent": folder, "addSelected": false}))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("review") }; + opts.album = Some(album); + for theme in ["charcoal", "midnight", "paper", "silver"] { + exec(&mut h, "view.theme", json!({"theme": theme})); + let r = h.request("ui.set", json!({"language": "de"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let rect = |h: &Headless, name: &str| h.app.widgets.iter().find(|(id, _)| id == name).unwrap().1; + assert!(screen.contains_rect(rect(&h, "albumPicker:import-album")), "{theme} {size:?}: selected long path"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "fête"); + let row = rect(&h, &format!("albumPickerItem:{album}:import-album")); + assert!(screen.contains_rect(row), "{theme} {size:?}: search result {row:?} inside {screen:?}"); + key(&mut h, "Escape"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + let name = rect(&h, "field:importAlbumName"); + assert!(name.width() >= 160.0 && screen.contains_rect(name), "{theme} {size:?}: name {name:?}"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "fête"); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(album)); + } + } + } + + /// Every shipped locale paints translated destination choices and initializes a translated new name. + #[test] + fn import_album_picker_localizes_choices_in_every_shipped_language() { + use crate::i18n::Locale; + let Review { mut h, .. } = review(); + for locale in Locale::ALL { + for source in + ["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"] + { + assert!(!locale.tr(source).is_empty()); + if *locale != Locale::En { + assert_ne!(locale.tr(source), source, "{locale:?}: {source}"); + } + } + let r = h.request("ui.set", json!({"language": locale.code()}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + assert!(button_text(&h, "albumPicker:import-album", locale.tr("None")), "{locale:?}: localized empty destination"); + click(&mut h, "albumPicker:import-album"); + assert!(button_text(&h, "importAlbum:new", locale.tr("New album…")), "{locale:?}: localized new-album option"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).new_album, locale.tr("Imported Photos")); + assert!(button_text(&h, "albumPicker:import-album", locale.tr("New album"))); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + } + } + + /// An empty catalog still offers None and New without inventing an existing destination. + #[test] + fn import_album_picker_handles_an_empty_catalog() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1000.0, 800.0], 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) }); + assert!(h.settle(SETTLE)); + click(&mut h, "albumPicker:import-album"); + assert!(h.painted_text().iter().any(|t| t == "No albums yet")); + click(&mut h, "importAlbum:new"); + assert!(has(&h, "field:importAlbumName")); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert!(destination(&h).new_album.is_empty()); + } + + struct Fixture(std::path::PathBuf); + + impl Drop for Fixture { + /// Remove only the uniquely owned fixture directory after the test session releases it. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Write an original procedural PNG in a unique temporary directory for a real import. + fn fixture() -> Fixture { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("lc-import-tree-{}-{unique}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + let pixels = + lightcraft_raster::Rgba8 { width: 40, height: 30, data: (0..1200).map(|i| [(i % 40 * 6) as u8, 31, (i / 40 * 8) as u8, 255]).collect() }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&pixels), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(path.join("hierarchy.png"), png).unwrap(); + Fixture(path) + } + + /// A real file scan, the real picker, and the real import worker: the intended album receives + /// the photo, duplicate names never redirect it, and the import is still one undo step. + /// A destination deleted while the review is open falls back to no album before confirmation. + #[test] + fn import_album_picker_delivers_to_the_chosen_destination_and_undoes() { + use lightcraft_catalog::PhotoId; + for choice in ["existing", "new", "none", "deleted"] { + let fixture = fixture(); + let Review { mut h, album, other, .. } = review(); + let before: std::collections::HashSet<PhotoId> = h.app.session.catalog.photos().map(|p| p.id).collect(); + h.app.ui.dialog = None; + exec(&mut h, "file.addPhotos", json!({"paths": [fixture.0.to_string_lossy()], "album": album})); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + assert!(h.settle(SETTLE)); + assert_eq!(destination(&h).album, Some(album), "album-context import keeps its preselection"); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "preselection opens the right ancestors"); + match choice { + "existing" => { + text(&mut h, "travel 2026 fav"); + key(&mut h, "Enter"); + } + "new" => { + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "From card"); + } + "deleted" => { + key(&mut h, "Escape"); + exec(&mut h, "album.delete", json!({"id": album})); + assert!(h.settle(SETTLE)); + assert_eq!(destination(&h).album, None, "a deleted destination matches the displayed None choice"); + assert!(button_text(&h, "albumPicker:import-album", "None")); + } + _ => click(&mut h, "importAlbum:none"), + } + let undo = h.app.session.undo.len(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.import.is_none())); + assert!(h.settle(SETTLE)); + let added: Vec<PhotoId> = h.app.session.catalog.photos().filter(|p| !before.contains(&p.id)).map(|p| p.id).collect(); + assert_eq!(added.len(), 1, "{choice}: exactly the scanned photo is imported"); + let receives = |id| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().photos.contains(&added[0]); + assert!(!receives(other), "same-named album outside the chosen folder is untouched"); + match choice { + "existing" => assert!(receives(album)), + "new" => { + let created = h.app.session.catalog.albums().find(|a| a.name == "From card").unwrap(); + assert!(created.photos.contains(&added[0])); + assert!(!receives(album)); + } + _ => assert!(h.app.session.catalog.albums().all(|a| !a.photos.contains(&added[0]))), + } + assert_eq!(h.app.session.undo.len(), undo + 1, "{choice}: one undo step"); + exec(&mut h, "edit.undo", json!({})); + assert!(h.settle(SETTLE)); + assert_eq!(h.app.session.catalog.len(), before.len()); + if choice == "new" { + assert!(h.app.session.catalog.albums().all(|a| a.name != "From card"), "undo removes the new album too"); + } + } + } +} diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests_compare.rs index 0f8d123be..c816b3c67 100644 --- a/crates/ui-egui/src/tests_compare.rs +++ b/crates/ui-egui/src/tests_compare.rs @@ -209,3 +209,112 @@ fn a_compare_pane_says_when_its_render_failed() { assert!(h.step_until(SETTLE, failed), "the failure is said"); assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:"))); } + +#[test] +fn reference_escape_leaves_overlays_then_the_view_without_changing_edits() { + use crate::state::{LightsOut, ViewMode}; + let mut h = demo(); + let reference = h.app.session.visible_cloned()[0]; + h.app.run("library.select", json!({"ids": [reference.0]})).unwrap(); + h.app.run("view.reference", json!({})).unwrap(); + let active = h.app.session.active().unwrap(); + h.app.run("develop.set", json!({"control": "light.exposure", "value": 0.7})).unwrap(); + h.app.ui.lights_out = LightsOut::Dim; + h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!((h.app.ui.view, h.app.ui.lights_out), (ViewMode::Reference, LightsOut::On)); + h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.ui.reference, Some(reference.0), "leaving the view keeps the pinned photo"); + assert_eq!(h.app.session.active(), Some(active)); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.7); + h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); +} + +#[test] +fn invalid_or_deleted_reference_is_refused_without_replacing_the_pin() { + let mut h = demo(); + let ids = h.app.session.visible_cloned(); + h.app.run("library.select", json!({"ids": [ids[0].0]})).unwrap(); + h.app.run("photo.setReference", json!({})).unwrap(); + h.app.run("photo.delete", json!({"ids": [ids[1].0]})).unwrap(); + for id in [u64::MAX, ids[1].0] { + let r = h.app.run("photo.setReference", json!({"id": id})); + assert!(r.is_err(), "unavailable photo {id} must not be pinned"); + assert_eq!(h.app.ui.reference, Some(ids[0].0)); + } + let deleted = h.app.run("photo.setReference", json!({"id": ids[1].0})).unwrap_err(); + assert!(deleted.contains("Recently Deleted"), "the caller learns to restore it: {deleted}"); + // a malformed id is refused, not taken for "the active photo" + h.app.run("library.select", json!({"ids": [ids[2].0]})).unwrap(); + for id in [json!(ids[2].0.to_string()), json!(-1), json!(1.5), json!([ids[2].0])] { + assert!(h.app.run("photo.setReference", json!({"id": id})).is_err(), "{id}"); + assert_eq!(h.app.ui.reference, Some(ids[0].0)); + } + h.app.run("photo.setReference", json!({"id": null})).unwrap(); + assert_eq!(h.app.ui.reference, Some(ids[2].0), "a null id means the active photo"); + h.app.run("photo.setReference", json!({"id": ids[0].0})).unwrap(); + h.app.run("library.select", json!({"ids": []})).unwrap(); + assert!(h.app.run("photo.setReference", json!({})).is_err()); + assert_eq!(h.app.ui.reference, Some(ids[0].0)); +} + +#[test] +fn deleting_the_pinned_reference_stops_drawing_it_and_reentry_uses_a_live_photo() { + let mut h = demo(); + let reference = h.app.session.visible_cloned()[0]; + h.app.run("library.select", json!({"ids": [reference.0]})).unwrap(); + h.app.run("view.reference", json!({})).unwrap(); + h.settle(SETTLE); + let texture = h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).unwrap().tex.id(); + let drawn = |h: &Headless| h.view.painted_shapes().iter().any(|s| matches!(s, egui::epaint::Shape::Mesh(mesh) if mesh.texture_id == texture)); + assert!(drawn(&h), "the live reference is shown before deletion"); + let active = h.app.session.active().unwrap(); + h.app.run("photo.delete", json!({"ids": [reference.0]})).unwrap(); + h.settle(SETTLE); + assert!(!drawn(&h), "Recently Deleted photos must not remain on the reference canvas"); + assert!(!h.app.renderer.textures.contains_key(&crate::render::Slot::Compare(0)), "the deleted reference's pane is released"); + assert!(!h.app.renderer.textures.contains_key(&crate::render::Slot::Compare(1)), "and the active photo's pane beside it"); + assert!(h.app.compare_windows[0].is_none()); + let r = h.app.run("view.reference", json!({})).unwrap(); + assert_eq!(r["reference"], active.0); + assert_eq!(h.app.ui.reference, Some(active.0)); + h.app.run("photo.restore", json!({"ids": [reference.0]})).unwrap(); + h.app.run("photo.setReference", json!({"id": reference.0})).unwrap(); + h.settle(SETTLE); + assert!(h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).is_some_and(|t| t.photo == reference)); +} + +/// Clicking a colour label (or stars) in the Info panel while culling labels the active photo +/// only, as the label keys and the bottom bar's stars and flags do, and the label shows under that +/// photo in Compare (#2398: "Can't assign color to photo when culling"). +#[test] +fn info_panel_label_in_compare_labels_the_active_photo_and_shows_it() { + use lightcraft_catalog::{ColorLabel, PhotoId}; + let mut h = demo(); + let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + for id in &vis[..2] { + h.request("engine.execute", json!({"command": "photo.label", "params": {"label": "none", "ids": [id]}}), T); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 0, "ids": [id]}}), T); + } + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap()); + h.app.ui.right = crate::state::RightPanel::Info; + h.settle(SETTLE); + let photo = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).cloned().expect("photo"); + let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), T)["result"].to_string(); + assert!(!widgets(&mut h).contains(&format!("cull:{cand}:label")), "no label yet"); + let r = h.request("ui.clickWidget", json!({"id": "label:red"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(photo(&h, cand).label, Some(ColorLabel::Red), "the active photo (the candidate) is labelled"); + assert_eq!(photo(&h, sel).label, None, "the select is not labelled with it"); + assert!(widgets(&mut h).contains(&format!("cull:{cand}:label")), "the label shows under the candidate"); + let r = h.request("ui.clickWidget", json!({"id": "star:info:3"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(photo(&h, cand).rating, 3); + assert_eq!(photo(&h, sel).rating, 0, "the select is not rated with it"); +} diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index f19171ffc..848b1b035 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -415,3 +415,42 @@ fn rotating_the_crop_dims_nothing_inside_the_box() { let lum = |c: egui::Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); assert!(lum(pixel(&rotated, corner_px)) < lum(pixel(&plain, corner_px)), "the photo's corner, outside the box, is dimmed"); } + +fn crop_rect(h: &Headless) -> lightcraft_geom::Rect { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.rect +} + +/// Press where the pointer shows the resize cursor for the left edge handle, `dx` px from the +/// edge, and drag on by `step` px per event: the drag must resize, as the cursor said (issue #1930). +fn drag_left_handle(dx: f32, step: f32) -> Headless { + let mut h = crop_tool(); + let img = h.app.image_rect.expect("the photo on screen"); + hover(&mut h, egui::pos2(img.left() + dx, img.center().y)); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal, "the pointer shows a resize"); + let x = |px: f32| px / img.width(); + let events: Vec<_> = std::iter::once(json!({"kind": "down", "x": x(dx), "y": 0.5})) + .chain((1..=4).map(|i| json!({"kind": "drag", "x": x(dx + step * i as f32), "y": 0.5}))) + .collect(); + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// Just outside the box, inside the handle's reach: the drag resizes, it does not rotate. +#[test] +fn a_drag_from_just_outside_a_handle_resizes_not_rotates() { + let h = drag_left_handle(-8.0, -3.0); + assert_eq!(angle(&h), 0.0, "the resize cursor showed, so nothing rotates"); + assert!(!has(&h, "cropAngleReadout"), "no rotation under way"); +} + +/// Just inside the box, inside the handle's reach: the drag moves the left edge, not the box. +#[test] +fn a_drag_from_just_inside_a_handle_resizes_not_moves() { + let h = drag_left_handle(8.0, 3.0); + let r = crop_rect(&h); + assert!(r.x0 > 0.0, "the left edge moved in: {r:?}"); + assert!((r.x1 - 1.0).abs() < 1e-6, "the right edge stays: {r:?}"); +} diff --git a/crates/ui-egui/src/tests_crop_tool.rs b/crates/ui-egui/src/tests_crop_tool.rs index 47b5264e0..16e0bf658 100644 --- a/crates/ui-egui/src/tests_crop_tool.rs +++ b/crates/ui-egui/src/tests_crop_tool.rs @@ -62,3 +62,36 @@ fn escape_in_the_angle_field_only_leaves_the_field() { h.settle(SETTLE); assert_eq!((h.app.ui.view, h.app.ui.right == RightPanel::Crop), (ViewMode::Detail, false)); } + +/// X in the crop tool swaps the crop's orientation once (issue #1866). X is both Reject and Swap +/// Aspect, and both bindings fired: the crop turned portrait and straight back to landscape. +#[test] +fn x_in_the_crop_tool_swaps_the_aspect_once() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let landscape = + |h: &Headless, id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).is_some_and(|p| p.width > p.height && !p.is_edited()); + let id = h.app.session.visible_cloned().into_iter().find(|id| landscape(&h, id)).unwrap(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + h.app.ui.view = ViewMode::Detail; + h.request("engine.execute", json!({"command": "panel.crop"}), T); + let r = h.request("engine.execute", json!({"command": "crop.aspect", "params": {"aspect": "3x4"}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.right, RightPanel::Crop); + let crop = |h: &Headless| { + let p = h.app.session.catalog.photo(id).unwrap(); + let r = p.develop.crop.geometry.rect_px(p.width as f64, p.height as f64); + (r.width() > r.height(), p.develop.crop.aspect) + }; + assert_eq!(crop(&h), (true, Some((400, 300))), "a 3:4 crop follows the landscape photo"); + let undo = h.app.session.undo.len(); + h.request("ui.key", json!({"key": "x"}), T); + h.settle(SETTLE); + assert_eq!(crop(&h), (false, Some((300, 400))), "X turns the crop portrait"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one swap, one undo step"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().flag, lightcraft_catalog::Flag::None, "X doesn't reject while cropping"); + h.request("ui.key", json!({"key": "x"}), T); + h.settle(SETTLE); + assert_eq!(crop(&h), (true, Some((400, 300))), "X again turns it back"); +} diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests_filmstrip.rs index 959c93484..bf572c1d6 100644 --- a/crates/ui-egui/src/tests_filmstrip.rs +++ b/crates/ui-egui/src/tests_filmstrip.rs @@ -1,9 +1,9 @@ -//! Headless test of the filmstrip's file-name labels (#266): a name with multi-byte (CJK) -//! characters is drawn without panicking. +//! Headless tests of the filmstrip: file-name labels (#266) — a name with multi-byte (CJK) +//! characters is drawn without panicking — and its stack badges, which expand / collapse a stack. use std::time::Duration; -use lightcraft_catalog::Op; +use lightcraft_catalog::{Op, Stack, StackId}; use serde_json::json; use crate::headless::Headless; @@ -28,3 +28,33 @@ fn filmstrip_shows_a_cjk_file_name() { let film = format!("film:{}", first.0); assert!(h.app.widgets.iter().any(|(w, _)| *w == film), "{film} drawn in the filmstrip"); } + +#[test] +fn filmstrip_stack_badge_expands_and_collapses_the_stack() { + let mut session = lightcraft_engine::Session::with_demo(); + let vis = session.visible_cloned(); + let (loose, top, member) = (vis[0], vis[1], vis[2]); + let stack = Stack { id: StackId(1), photos: vec![top, member], collapsed: true }; + session.catalog.apply(Op::AddStack { stack }).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // the loose photo is the one in the loupe: the stack isn't selected + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [loose.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!h.app.session.visible_cloned().contains(&member), "collapsed: only the top shows"); + let badge = format!("filmstack:{}", top.0); + let r = h.request("ui.clickWidget", json!({"id": badge}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a click on the badge expands the stack"); + assert!(h.app.session.visible_cloned().contains(&member)); + assert_eq!(h.app.session.selection.active, Some(loose), "the loupe keeps its photo"); + let r = h.request("ui.clickWidget", json!({"id": badge}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a second click collapses it"); + assert!(!h.app.session.visible_cloned().contains(&member)); +} diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs index 9b4cb860a..1b6536242 100644 --- a/crates/ui-egui/src/tests_folder_drop.rs +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -50,8 +50,13 @@ fn widget(h: &Headless, id: &str) -> Option<Rect> { h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) } +/// How the Folders section names `dir` in widget ids: with `/` separators, on Windows too. +fn folder_key(dir: &Path) -> String { + dir.to_string_lossy().replace('\\', "/") +} + fn folder_row(h: &Headless, dir: &Path) -> Option<Rect> { - widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) + widget(h, &format!("source:libfolder:{}", folder_key(dir))) } fn id_of(h: &Headless, file: &str) -> u64 { @@ -180,7 +185,7 @@ fn photos_already_in_the_folder_stay_put() { let (root, trip, home) = folders("here"); let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); // open the folder holding both - let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + let toggle = widget(&h, &format!("libraryFolderToggle:{}", folder_key(&root))).unwrap(); h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); h.app.synthetic.push(egui::Event::PointerButton { pos: toggle.center(), @@ -284,7 +289,7 @@ fn cancelling_the_question_moves_nothing() { let target = if folder_row(&h, &home).is_some() { target } else { - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); folder_row(&h, &home).unwrap() }; let undo = h.app.session.undo.len(); @@ -302,7 +307,7 @@ fn dont_ask_again_moves_the_next_drop_at_once() { let (root, trip, home) = folders("dont-ask"); std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); let target = folder_row(&h, &home).unwrap(); let beach = id_of(&h, "beach.png"); h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); @@ -327,7 +332,7 @@ fn dont_ask_again_moves_the_next_drop_at_once() { fn a_question_left_open_while_another_library_opens_moves_nothing() { let (root, trip, home) = folders("other-library"); let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); let beach = id_of(&h, "beach.png"); let target = folder_row(&h, &home).unwrap(); drop_beach(&mut h, beach, target); diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 7aafb212e..3776821de 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -172,3 +172,209 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() { assert!(!skips_the_real_render(false, true)); assert!(!skips_the_real_render(false, false)); } + +/// Press and release the primary button at `at`, one frame apart from the next click (as a real +/// double-click comes in: one click per frame). +fn click_at(h: &mut Headless, at: egui::Pos2) { + h.events.push(egui::Event::PointerMoved(at)); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); +} + +/// Double-clicking a stack badge expands or collapses the stack, as in Lightroom (issue #2233): its +/// second click used to undo the first, and the stack stayed as it was. A single click still toggles it. +#[test] +fn double_clicking_a_stack_badge_toggles_the_stack_once() { + let mut h = grid(ViewMode::PhotoGrid); + let top = PhotoId(2001); + let sid = StackId(21); + let collapsed = |h: &Headless| h.app.session.catalog.stack(sid).unwrap().collapsed; + select(&mut h, top); + let r = h.request("engine.execute", json!({"command": "stack.toggle", "params": {"ids": [top.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(collapsed(&h)); + for want in [false, true] { + let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center(); + click_at(&mut h, badge); + // the first click re-lays the grid; the second must still land on this badge, or the test + // would pass without the fix (a missed second click toggles nothing) + let moved = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn"); + assert!(moved.contains(badge), "the badge moved from under the pointer: {moved:?}"); + click_at(&mut h, badge); + h.settle(SETTLE); + assert_eq!(collapsed(&h), want, "a double-click toggles the stack once"); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a double-click on the badge doesn't open the photo"); + // the next double-click is apart from this one + for _ in 0..60 { + h.step(); + } + } + let badge = widget(&h, &format!("stack:{}", top.0)).unwrap().center(); + click_at(&mut h, badge); + h.settle(SETTLE); + assert!(!collapsed(&h), "a single click toggles it"); +} + +#[test] +fn grid_stack_badge_toggles_a_stack_that_is_not_selected() { + let mut h = grid(ViewMode::PhotoGrid); + // a stack on screen, and a loose photo as the active one + let top = PhotoId(2001); + select(&mut h, top); + let vis = h.app.session.visible().to_vec(); + let i = vis.iter().position(|p| *p == top).unwrap(); + let loose = vis[i.saturating_sub(1)..].iter().copied().find(|p| h.app.session.catalog.stack_of(*p).is_none()).unwrap(); + select(&mut h, loose); + assert!(!h.app.session.selection.contains(top)); + assert!(widget(&h, &format!("stack:{}", top.0)).is_some(), "badge drawn"); + let r = h.request("ui.clickWidget", json!({"id": format!("stack:{}", top.0)}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.stack_of(top).unwrap().collapsed, "a click on an unselected stack's badge toggles it"); + assert_eq!((h.app.session.selection.ids.clone(), h.app.session.selection.active), (vec![loose], Some(loose)), "and leaves the selection alone"); +} + +/// What the grid shows (its date runs, and each cell's shape) is what a grid made afresh for the +/// same list shows. +fn assert_as_made_afresh(h: &mut Headless, what: &str) { + let vis = h.app.session.visible().to_vec(); + if vis.is_empty() { + // (no photo matches: the grid says so and draws none) + return; + } + let (runs, shapes) = h.app.caches.grid.runs_and_shapes().expect("the grid was drawn"); + let cat = &h.app.session.catalog; + let by = crate::panels::grid::resolve_group(h.app.session.sort.group, h.app.ui.thumb_size); + assert_eq!(*runs, cat.date_runs(&vis, h.app.session.sort.key, by), "date runs {what}"); + // (the square grid's cells are all alike: it reads no shape) + let fresh: Vec<f32> = + if h.app.ui.view == ViewMode::SquareGrid { Vec::new() } else { vis.iter().map(|id| crate::panels::grid::shape_of(cat, *id)).collect() }; + assert_eq!(shapes, fresh, "cell shapes {what}"); +} + +fn photo(h: &mut Headless, n: u64, captured: &str) -> Op { + let id = h.app.session.catalog.alloc_photo_id(); + let (w, h_) = if n.is_multiple_of(3) { (4000, 6000) } else { (6000, 4000) }; + let mut p = Photo::new( + id, + Source::File { path: format!("/lightcraft-grid-test/NEW_{n:05}.jpg") }, + &format!("NEW_{n:05}.jpg"), + "JPEG", + w, + h_, + "2026-01-02T00:00:00", + ); + p.captured = Some(captured.to_string()); + Op::AddPhoto { photo: Box::new(p) } +} + +/// An import lands its photos a batch at a time: the grid reads the batch's photos, not the +/// whole list again (at 180,000 photos that was most of a 100 ms frame per batch). An edit to a +/// photo's date or shape reads that photo. +#[test] +fn a_batch_of_new_photos_or_an_edit_to_one_is_read_photo_by_photo() { + let mut h = grid(ViewMode::PhotoGrid); + let read = h.app.caches.grid_stats.photos_read; + assert_eq!(read, N, "the first frame reads every photo"); + // sixteen photos, on days already shown, a new day, and with no date at all + let ops: Vec<Op> = (0..16) + .map(|k| { + let day = ["2026-12-28T09:30:00", "2026-11-03T10:00:00", "2027-01-05T12:00:00", "nope"][k as usize % 4]; + photo(&mut h, k, day) + }) + .collect(); + h.app.session.commit("Import", Op::Batch { ops }).unwrap(); + h.step(); + assert_eq!(h.app.caches.grid_stats.photos_read - read, 16, "only the batch's photos are read"); + assert!(widget(&h, "group:2027-01-05").is_some(), "the new day heads the grid"); + assert_as_made_afresh(&mut h, "after the batch"); + // a photo taken on another day: it moves there, and only it is read + let read = h.app.caches.grid_stats.photos_read; + h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()) }).unwrap(); + h.step(); + assert_eq!(h.app.caches.grid_stats.photos_read - read, 1, "only the photo edited is read"); + assert!(widget(&h, "group:2027-02-01").is_some(), "its new day heads the grid"); + assert_as_made_afresh(&mut h, "after a capture time"); +} + +/// Whatever the edit, the grid shows what a grid made afresh would: followed photo by photo when +/// the list changed by the photos the edit touched, else read whole. +#[test] +fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() { + for seed in [11u64, 12, 13, 14, 15] { + let mut h = grid(ViewMode::PhotoGrid); + let mut dice = seed; + let mut roll = |sides: u64| { + dice = lightcraft_catalog::mix64(dice); + dice % sides + }; + for step in 0..120 { + let vis = h.app.session.visible().to_vec(); + if vis.is_empty() { + // (no picks shown: back to every photo) + h.app.session.filter.flag = None; + continue; + } + let id = vis[roll(vis.len() as u64) as usize].0; + let n = roll(1000); + match roll(12) { + 0 => { + let ops: Vec<Op> = (0..1 + roll(20)) + .map(|k| photo(&mut h, n * 100 + k, &format!("2026-{:02}-{:02}T10:00:00", 1 + n % 12, 1 + k % 28))) + .collect(); + h.app.session.commit("Import", Op::Batch { ops }).unwrap(); + } + 1 => { + let _ = h.app.session.commit("Delete", Op::SetDeleted { id: PhotoId(id), deleted: true }); + } + 2 => { + let _ = h + .app + .session + .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)) }); + } + 3 => { + h.app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + let r = h.app.session.execute("photo.rotateRight", &json!({})); + assert!(r.is_ok(), "{r:?}"); + } + 4 => { + let mut d = (*h.app.session.catalog.photo(PhotoId(id)).unwrap().develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9 - 0.1 * (n % 4) as f64, 1.0); + h.app + .session + .commit("Crop", Op::SetDevelop { id: PhotoId(id), settings: std::sync::Arc::new(d), label: "Crop".into(), edited: None }) + .unwrap(); + } + 5 => h.app.session.commit("Rating", Op::SetRating { id: PhotoId(id), rating: (n % 6) as u8 }).unwrap(), + 6 => { + let key = ["captureDate", "fileName", "importDate"][n as usize % 3]; + let r = h.app.session.execute("library.sort", &json!({"key": key, "ascending": n.is_multiple_of(2)})); + assert!(r.is_ok(), "{r:?}"); + } + 7 => { + // a stack: an edit to more than its photos, read whole + let other = vis[n as usize % vis.len()].0; + let _ = h.app.session.execute("stack.group", &json!({"ids": [id, other], "top": id, "collapsed": n.is_multiple_of(2)})); + } + 8 => { + let _ = h.app.session.execute("edit.undo", &json!({})); + } + 9 => { + // picks, for the filter below to show some + for k in 0..1 + n % 40 { + let p = vis[(n as usize * 7 + k as usize * 13) % vis.len()]; + h.app.session.commit("Flag", Op::SetFlag { id: p, flag: lightcraft_catalog::Flag::Pick }).unwrap(); + } + } + 10 => h.app.ui.view = if h.app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }, + _ => h.app.session.filter.flag = n.is_multiple_of(2).then_some(lightcraft_catalog::Flag::Pick), + } + h.step(); + assert_as_made_afresh(&mut h, &format!("seed {seed}, after step {step}")); + } + } +} diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests_guided_guides.rs new file mode 100644 index 000000000..d59698350 --- /dev/null +++ b/crates/ui-egui/src/tests_guided_guides.rs @@ -0,0 +1,367 @@ +//! Headless tests of Guided Upright guide selection and Delete (issue: remove one guide): a click +//! near a guide selects it, Delete removes the selected guide alone, a click away clears the +//! selection, and with no selection Delete removes nothing (never the photo). + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// Crop with Guided Upright on and two horizontal guides drawn (in lens-corrected coordinates). +fn guided() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [ + ("ui.set", json!({"view": "detail"})), + ("engine.execute", json!({"command": "tool.guidedUpright"})), + ("engine.execute", json!({"command": "geometry.guides", "params": {"guides": [[0.2, 0.3, 0.8, 0.3], [0.2, 0.7, 0.8, 0.7]]}})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + h +} + +fn guides_len(h: &Headless) -> usize { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.geometry.guides.len() +} + +fn guide_at(h: &Headless, i: usize) -> egui::Pos2 { + guide_rect(h, i).center() +} + +fn guide_rect(h: &Headless, i: usize) -> egui::Rect { + h.app.widgets.iter().rev().find(|(w, _)| *w == format!("uprightGuide:{i}")).map(|(_, r)| *r).expect("the guide is on screen") +} + +/// A click near a guide selects it (Delete has a visible target); the guides stay put. +#[test] +fn a_click_near_a_guide_selects_it() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let id = h.app.session.active().expect("a photo"); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, Some((id.0, 0)), "the clicked guide is selected"); + assert_eq!(guides_len(&h), 2, "selecting removes nothing"); +} + +/// Delete removes the selected guide alone; the other one stays, and nothing stays selected. +#[test] +fn delete_removes_the_selected_guide() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 1).x, "y": guide_at(&h, 1).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide.map(|(_, i)| i), Some(1)); + let r = h.request("ui.key", json!({"key": "Delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 1, "one guide is gone"); + assert_eq!(h.app.ui.selected_guide, None, "and nothing is selected afterwards"); + // the survivor is the first guide: the second one's endpoints are gone + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[0]; + assert!( + (a.x - 0.2).abs() < 1e-6 && (a.y - 0.3).abs() < 1e-6 && (b.x - 0.8).abs() < 1e-6 && (b.y - 0.3).abs() < 1e-6, + "the first guide survived: {a:?}–{b:?}" + ); +} + +/// A click away from every guide clears the selection (and removes nothing). +#[test] +fn a_click_away_clears_the_selection() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.selected_guide.is_some()); + let img = h.app.image_rect.expect("the photo on screen"); + // below both guides, far from them + let r = h.request("ui.click", json!({"x": img.center().x, "y": img.top() + 0.95 * img.height()}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, None); + assert_eq!(guides_len(&h), 2, "and removes nothing"); +} + +/// With nothing selected Delete removes no guide and never deletes the photo: the tool is open. +#[test] +fn delete_with_no_selection_removes_nothing() { + let mut h = guided(); + let undo = h.app.session.undo.len(); + let r = h.request("ui.key", json!({"key": "Delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 2, "no guide is removed"); + assert_eq!(h.app.session.undo.len(), undo, "no edit either"); + assert!(h.app.session.active().is_some(), "the photo is still active"); +} + +/// Drawing a new guide clears the selection first, so Delete after a draw removes the new guide. +#[test] +fn drawing_a_new_guide_clears_the_selection() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.selected_guide.is_some()); + let img = h.app.image_rect.expect("the photo on screen"); + // a drag across the middle of the photo (clear of both guides) draws a third guide + let r = h.request( + "ui.drag", + json!({"x": img.left() + 0.3 * img.width(), "y": img.top() + 0.5 * img.height(), "toX": img.left() + 0.7 * img.width(), "toY": img.top() + 0.5 * img.height()}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 3, "the drawn guide is added"); + assert_eq!(h.app.ui.selected_guide, None, "and the click's selection is gone"); +} + +/// A press on an endpoint drags it: the guide follows the pointer — the other end and the other +/// guide untouched — the moved guide ends up selected, and nothing is added (one edit, on release). +#[test] +fn dragging_a_guide_endpoint_moves_it() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let id = h.app.session.active().expect("a photo"); + let img = h.app.image_rect.expect("the photo on screen"); + let before = guide_rect(&h, 0); + let from = before.left_top(); + let to = egui::pos2(from.x + 24.0, from.y + 60.0); + // ui.pointer speaks normalized image coordinates + let norm = |p: egui::Pos2| (((p.x - img.left()) / img.width()) as f64, ((p.y - img.top()) / img.height()) as f64); + let (fx, fy) = norm(from); + let (tx, ty) = norm(to); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": fx, "y": fy}, {"kind": "drag", "x": tx, "y": ty}, {"kind": "up", "x": tx, "y": ty}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 2, "no guide is added"); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[0]; + assert!((a.x - tx).abs() < 1e-3 && (a.y - ty).abs() < 1e-3, "the pressed end followed the pointer: {a:?} vs ({tx}, {ty})"); + assert!((b.x - 0.8).abs() < 1e-6 && (b.y - 0.3).abs() < 1e-6, "the far end stays: {b:?}"); + assert_eq!(h.app.ui.selected_guide, Some((id.0, 0)), "the moved guide is selected"); +} + +/// A press-release on an endpoint without moving it selects the guide and changes nothing: no +/// coordinate moves and no undo entry appears for a click. +#[test] +fn clicking_an_endpoint_selects_it_without_changing_it() { + let mut h = guided(); + let id = h.app.session.active().expect("a photo"); + let undo = h.app.session.undo.len(); + let img = h.app.image_rect.expect("the photo on screen"); + let at = guide_rect(&h, 1).left_top(); + let x = ((at.x - img.left()) / img.width()) as f64; + let y = ((at.y - img.top()) / img.height()) as f64; + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, Some((id.0, 1)), "the endpoint's guide is selected"); + assert_eq!(guides_len(&h), 2); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[1]; + assert!( + (a.x - 0.2).abs() < 1e-6 && (a.y - 0.7).abs() < 1e-6 && (b.x - 0.8).abs() < 1e-6 && (b.y - 0.7).abs() < 1e-6, + "the guide is untouched: {a:?}–{b:?}" + ); + assert_eq!(h.app.session.undo.len(), undo, "a click is not an edit"); +} + +/// A new guide starts at the press point itself: a press, one far jump, a release — the guide +/// spans exactly those two points (it used to start where the drag threshold was crossed, so the +/// anchor was already at the far end). +#[test] +fn a_new_guide_starts_at_the_press_point() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let img = h.app.image_rect.expect("the photo on screen"); + let p1 = egui::pos2(img.left() + 0.35 * img.width(), img.top() + 0.55 * img.height()); + let p2 = egui::pos2(img.left() + 0.6 * img.width(), img.top() + 0.55 * img.height()); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": 0.35, "y": 0.55}, {"kind": "drag", "x": 0.6, "y": 0.55}, {"kind": "up", "x": 0.6, "y": 0.55}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 3, "the drawn guide is added"); + assert_eq!(h.app.ui.selected_guide, None, "drawing a new guide leaves nothing selected"); + let rect = guide_rect(&h, 2); + assert!(rect.left_top().distance(p1) < 2.0, "the guide starts at the press point: {rect:?} vs {p1:?}"); + assert!(rect.right_top().distance(p2) < 2.0, "and ends at the release point: {rect:?} vs {p2:?}"); +} + +/// A slow frame can swallow press, move and release into a single batch: egui then reports +/// `primary_pressed` with `press_origin` already cleared by the release, and the gesture must +/// still start at the press point — the guide spans the drag, not a zero-length stub at the +/// release. The control channel spreads pointer events one per frame, so the batch is pushed +/// raw into one frame here. +#[test] +fn a_drag_whose_press_and_release_share_one_frame_still_draws() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let undo = h.app.session.undo.len(); + let img = h.app.image_rect.expect("the photo on screen"); + let p1 = egui::pos2(img.left() + 0.35 * img.width(), img.top() + 0.55 * img.height()); + let p2 = egui::pos2(img.left() + 0.6 * img.width(), img.top() + 0.55 * img.height()); + h.events.push(egui::Event::PointerMoved(p1)); + h.events.push(egui::Event::PointerButton { pos: p1, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.events.push(egui::Event::PointerMoved(p2)); + h.events.push(egui::Event::PointerButton { pos: p2, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 3, "the drag that shared one frame still draws its guide"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one edit was written"); + let rect = guide_rect(&h, 2); + assert!(rect.left_top().distance(p1) < 2.0, "the guide starts at the press point: {rect:?} vs {p1:?}"); + assert!(rect.right_top().distance(p2) < 2.0, "and ends at the release point: {rect:?} vs {p2:?}"); +} + +/// The right button is the pan / context menu's, never the guides': on a guide it selects +/// nothing and draws nothing. +#[test] +fn a_right_click_on_a_guide_selects_nothing() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, None, "the right button selects nothing"); + assert_eq!(guides_len(&h), 2, "and draws nothing"); +} + +/// The navigator pans the zoomed photo: a press on it never reaches the guides — it neither +/// selects one under it nor starts drawing a new guide from it. +#[test] +fn a_press_on_the_navigator_draws_no_guide() { + let mut h = guided(); + let r = h.request("engine.execute", json!({"command": "view.zoom100"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let nav = h.app.widgets.iter().rev().find(|(w, _)| *w == "canvas:navigator").map(|(_, r)| *r).expect("navigator shown when zoomed"); + let img = h.app.image_rect.expect("the photo on screen"); + let c = nav.center(); + let (nx, ny) = ((c.x - img.left()) / img.width(), (c.y - img.top()) / img.height()); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": nx, "y": ny}, {"kind": "drag", "x": nx + 0.05, "y": ny}, {"kind": "up", "x": nx + 0.05, "y": ny}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 2, "the press on the navigator drew no guide"); + assert_eq!(h.app.ui.selected_guide, None, "and selected nothing"); +} + +/// The context menu (anything floating above the photo) owns its own press: a left press on the +/// menu that sits over a guide line selects nothing under it and starts no guide gesture — the +/// canvas has to actually receive the press, not merely lie under the pointer. +#[test] +fn a_press_on_a_menu_over_a_guide_selects_nothing() { + let mut h = guided(); + let id = h.app.session.active().expect("a photo"); + let undo = h.app.session.undo.len(); + let line = guide_at(&h, 0); + // right-click 30px up-left of that point: the context menu opens with its top-left corner + // at the pointer (gap 0), so its frame covers the guide from 30px inside every edge + let r = h.request("ui.click", json!({"x": line.x - 30.0, "y": line.y - 30.0, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.view.ctx.any_popup_open(), "the context menu is open over the photo"); + let img = h.app.image_rect.expect("the photo on screen"); + let (lx, ly) = ((line.x - img.left()) / img.width(), (line.y - img.top()) / img.height()); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": lx, "y": ly}, {"kind": "up", "x": lx, "y": ly}]}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, None, "the press under the menu selects nothing"); + assert!(h.app.gesture.is_none(), "and starts no gesture"); + assert_eq!(guides_len(&h), 2, "and draws nothing"); + assert_eq!(h.app.session.undo.len(), undo, "no edit was written"); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[0]; + assert!( + (a.x - 0.2).abs() < 1e-6 && (a.y - 0.3).abs() < 1e-6 && (b.x - 0.8).abs() < 1e-6 && (b.y - 0.3).abs() < 1e-6, + "the guide is untouched: {a:?}–{b:?}" + ); +} + +/// Same for an endpoint under the menu: a press-drag on it moves nothing and selects nothing — +/// the endpoint-drag gesture never starts because the menu, not the canvas, took the press. +#[test] +fn a_press_on_a_menu_over_an_endpoint_moves_nothing() { + let mut h = guided(); + let id = h.app.session.active().expect("a photo"); + let undo = h.app.session.undo.len(); + let end = guide_rect(&h, 0).left_top(); + let r = h.request("ui.click", json!({"x": end.x - 30.0, "y": end.y - 30.0, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.view.ctx.any_popup_open(), "the context menu is open over the photo"); + let img = h.app.image_rect.expect("the photo on screen"); + let (ex, ey) = ((end.x - img.left()) / img.width(), (end.y - img.top()) / img.height()); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": ex, "y": ey}, {"kind": "drag", "x": ex + 0.05, "y": ey + 0.08}, {"kind": "up", "x": ex + 0.05, "y": ey + 0.08}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, None, "the endpoint's press under the menu selects nothing"); + assert!(h.app.gesture.is_none(), "and starts no gesture"); + assert_eq!(guides_len(&h), 2, "and draws nothing"); + assert_eq!(h.app.session.undo.len(), undo, "no edit was written"); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[0]; + assert!( + (a.x - 0.2).abs() < 1e-6 && (a.y - 0.3).abs() < 1e-6 && (b.x - 0.8).abs() < 1e-6 && (b.y - 0.3).abs() < 1e-6, + "the endpoint under the menu did not move: {a:?}–{b:?}" + ); +} + +/// The press can leave the menu within the same frame it started (one batch: down on the menu, +/// pointer onto bare photo, release): containment of the *current* pointer would pass then, but +/// the press belongs to the menu — the press point decides, so no guide is drawn from it. +#[test] +fn a_press_that_leaves_the_menu_in_one_frame_draws_nothing() { + let mut h = guided(); + let undo = h.app.session.undo.len(); + let end = guide_rect(&h, 0).left_top(); + // right-click up-left of the endpoint: the menu opens with its top-left at the pointer + let r = h.request("ui.click", json!({"x": end.x - 30.0, "y": end.y - 30.0, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.view.ctx.any_popup_open(), "the context menu is open over the photo"); + // one batch: go down 1px inside the menu, then straight off it to bare photo, far below + // both guides, and release there — the current pointer never sees the menu again + let img = h.app.image_rect.expect("the photo on screen"); + let menu = end - egui::vec2(29.0, 29.0); + let bare = egui::pos2(img.center().x, img.top() + 0.95 * img.height()); + let rel = |p: egui::Pos2| (((p.x - img.left()) / img.width()) as f64, ((p.y - img.top()) / img.height()) as f64); + let (mx, my) = rel(menu); + let (bx, by) = rel(bare); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": mx, "y": my}, {"kind": "drag", "x": bx, "y": by}, {"kind": "up", "x": bx, "y": by}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 2, "the press that left the menu drew no guide"); + assert_eq!(h.app.ui.selected_guide, None, "and selected nothing"); + assert!(h.app.gesture.is_none(), "and leaves no gesture behind"); + assert_eq!(h.app.session.undo.len(), undo, "no edit was written"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..a1e5846e4 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -192,6 +192,37 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { h.settle(SETTLE); } +/// A slider hides the overlay (above); a new mask, an added part, a duplicate or picking a mask in +/// the list shows it again, so the Overlay menu's mode is what the photo shows (issue #1800). +#[test] +fn a_new_or_picked_mask_shows_the_overlay_hidden_by_a_slider() { + use lightcraft_pipeline::Overlay; + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); + let first = h.app.session.active_mask.unwrap(); + let hide = |h: &mut Headless| { + exec(h, "mask.adjust", json!({"values": {"exposure": 0.5}})); + assert!(!h.app.ui.mask_overlay); + }; + let shown = |h: &Headless| matches!(crate::panels::detail::view_overlay(&h.app, &develop(h)), Overlay::Mask { .. }); + hide(&mut h); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2})); + assert!(shown(&h), "a new mask shows its overlay"); + hide(&mut h); + let r = h.request("ui.clickWidget", json!({"id": format!("mask:{first}")}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.active_mask, Some(first)); + assert!(shown(&h), "picking a mask in the list shows its overlay"); + for (cmd, params) in + [("mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.6, 0.5], "rx": 0.1, "ry": 0.1})), ("mask.duplicate", json!({}))] + { + hide(&mut h); + exec(&mut h, cmd, params); + assert!(shown(&h), "{cmd} shows the overlay"); + } + h.settle(SETTLE); +} + #[test] fn radial_body_drag_moves_rotated_components_and_undoes_once() { let mut h = detail("panel.masking"); @@ -539,6 +570,55 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { assert_eq!(develop(&h), before); } +/// Issue #991: with no aspect ever chosen, a crop starts locked to the photo's own shape (Lightroom's +/// default), so a handle drag keeps the proportions; after the Lock is switched off once, the next +/// photo's crop starts free again. +#[test] +fn a_fresh_crop_starts_locked_to_original_until_the_lock_is_switched_off() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + let before = develop(&h); + assert!(before.crop.aspect.is_none()); + let p = h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().clone(); + let own = (p.width * 100, p.height * 100); + assert_eq!(h.app.crop_tool_aspect(), Some(own), "the Lock shows engaged on an untouched photo"); + let drag = json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}); + let r = h.request("ui.pointer", drag.clone(), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some(own), "the drag kept the photo's proportions"); + assert_eq!(h.app.ui.crop_default_aspect, None, "nothing was chosen explicitly"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); + // Lock off once: remembered as free, and from then on the same drag crops without a ratio + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + h.settle(SETTLE); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.crop_tool_aspect(), None); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None, "and left the ratio free"); + let _ = drag; +} + /// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the /// drag still crops: it must not block every crop of an untouched photo. #[test] @@ -1177,6 +1257,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() { h.settle(SETTLE); } +/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the +/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is +/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling. +#[test] +fn open_original_refuses_anything_that_is_not_a_photo_file() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default(); + let batches: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>> = Default::default(); + let (o, bt) = (opened.clone(), batches.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + open_all_with: Some(Box::new(move |paths: &[String], _: &str| { + bt.lock().unwrap().push(paths.to_vec()); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let mut record = |name: &str| { + let path = dir.join(name); + std::fs::write(&path, b"MZ").unwrap(); + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + // what a crafted catalog can name as a photo's original + let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"]; + let bad: Vec<_> = programs.iter().map(|n| record(n)).collect(); + let good = record("DSC_0001.NEF"); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + + for (id, name) in bad.iter().zip(programs) { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], false, "{name} was opened: {r}"); + assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}"); + // the menu item is greyed out for such a record, so it is never offered either + exec(&mut h, "library.select", json!({"ids": [id.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu"); + } + assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program"); + + // a program selected together with a photo: only the photo is handed over, by absolute path + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(r["skipped"].to_string().contains("payload.exe"), "{r}"); + let handed = batches.lock().unwrap().clone(); + assert_eq!(handed.len(), 1); + assert_eq!(handed[0].len(), 1, "{handed:?}"); + assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); @@ -1518,6 +1662,36 @@ fn a_selected_develop_slider_accepts_wheel_nudges() { } let after = lightcraft_develop::controls::get(&develop(&h), "light.exposure").expect("exposure"); assert!(after > before, "selected exposure did not increase: {before} -> {after}"); + for _ in 0..40 { + h.step(); + } + let before_leaving = develop(&h).light.exposure; + // Leaving the row disarms wheel edits, but must not take keyboard arrows away. + h.request("ui.move", json!({"x": panel.left() - 10.0, "y": track.center().y}), T); + h.request("ui.hoverWidget", json!({"id": name}), T); + // More than a full notch: disarming dropped the wheel remainder, so this alone would edit it. + h.request("ui.scroll", json!({"dy": -240.0}), T); + for _ in 0..40 { + h.step(); + } + assert_eq!(develop(&h).light.exposure, before_leaving, "returning to a slider while scrolling must not edit it (issue #1355)"); + h.request("ui.move", json!({"x": panel.left() - 10.0, "y": track.center().y}), T); + h.request("ui.key", json!({"key": "ArrowUp"}), T); + assert!(develop(&h).light.exposure > before_leaving, "keyboard adjustment survives leaving the slider"); + + // Scroll back to the top, then explicitly select it again to re-enable wheel nudges. + h.request("ui.move", json!({"x": panel.right() - 8.0, "y": panel.center().y}), T); + h.request("ui.scroll", json!({"dy": 600.0}), T); + for _ in 0..40 { + h.step(); + } + click(&mut h, name); + let before_reselecting = develop(&h).light.exposure; + h.request("ui.scroll", json!({"dy": 120.0}), T); + for _ in 0..40 { + h.step(); + } + assert!(develop(&h).light.exposure > before_reselecting, "a new click re-enables wheel adjustment"); } /// Scrolling sideways over a slider adjusts it without selecting it first: a trackpad swipe diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests_menubar.rs index 8fbc13608..758ac595a 100644 --- a/crates/ui-egui/src/tests_menubar.rs +++ b/crates/ui-egui/src/tests_menubar.rs @@ -116,6 +116,7 @@ fn hovering_another_title_switches_the_open_menu() { #[test] fn clicking_another_title_opens_it_in_one_click() { let mut h = app([1280.0, 703.0]); + assert!(h.step_until(SETTLE, |h| widget(h, "menu:Edit").is_some()), "Edit menu title was not registered"); go(&mut h, "ui.clickWidget", "menu:Edit"); assert_eq!(open_menu(&h).as_deref(), Some("menu:Edit")); // move away from the titles first so only the click can be what switches diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 9e0b85b78..5327412d1 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -155,6 +155,11 @@ fn offline_originals_never_block_a_frame() { h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(h.app.session.source, LibrarySource::Missing); frames_while_asleep(&mut h, &nas, 20, "Missing Photos"); + // the probe runs on the background worker: give it time to start on a loaded machine + let asked = std::time::Instant::now(); + while calls.n.load(Ordering::SeqCst) == 0 && asked.elapsed() < std::time::Duration::from_secs(5) { + std::thread::sleep(std::time::Duration::from_millis(10)); + } assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there"); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 8e360f862..96b0a1a9d 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1652,6 +1652,103 @@ fn the_chosen_folder_is_always_in_view() { assert!(!has(&h, "source:libfolder:/pics/trip/day1")); } +/// Make the photo imported from `path` the active one. +fn activate(h: &mut Headless, path: &str) { + let id = h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id; + h.app.run("library.select", json!({"ids": [id.0], "active": id.0})).unwrap(); + h.step(); +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library) shows the active photo's folder +/// as a click on its Folders row does: the sidebar, the section and the folders above the row open, +/// also when the folder was already shown and a row above it was folded by hand. +#[test] +fn show_in_folders_chooses_the_photos_folder_and_opens_the_rows_above_it() { + use crate::sidebar::SidebarSection; + let mut h = folders_app(&["/pics/trip/day1/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]); + activate(&mut h, "/pics/trip/day1/a.jpg"); + assert!(crate::menus::ui_enabled(&h.app, "app.showInFolders")); + click(&mut h, "sidebarSection:folders"); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert!(!h.app.ui.left_panel && !has(&h, "sidebarTitle"), "the sidebar is hidden"); + let r = h.request("ui.menu.invoke", json!({"id": "app.showInFolders"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::LibraryFolder); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/trip/day1")); + assert_eq!(h.app.session.visible().len(), 1, "the folder's photos fill the grid"); + assert_eq!(h.app.session.active(), h.app.session.visible().first().copied(), "the photo stays the active one"); + assert!(h.app.ui.left_panel && !h.app.ui.sidebar.is_collapsed(SidebarSection::Folders), "the sidebar and Folders opened"); + assert!(has(&h, "source:libfolder:/pics/trip/day1") && has(&h, "highlight:libfolder:/pics/trip/day1"), "its row is shown chosen"); + // folded by hand, then asked again: the rows above open again + click(&mut h, "libraryFolderToggle:/pics/trip"); + assert!(!has(&h, "source:libfolder:/pics/trip/day1")); + h.app.run("app.showInFolders", json!({})).unwrap(); + h.step(); + h.step(); + assert!(has(&h, "source:libfolder:/pics/trip/day1"), "opened again"); +} + +/// A folder far down the list is scrolled into view. +#[test] +fn show_in_folders_scrolls_the_folder_into_view() { + let paths: Vec<String> = (0..60).map(|i| format!("/pics/f{i:02}/a.jpg")).collect(); + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_sized(&refs, [1400.0, 500.0]); + activate(&mut h, "/pics/f59/a.jpg"); + h.app.run("app.showInFolders", json!({})).unwrap(); + h.settle(SETTLE); + let row = widget(&h, "source:libfolder:/pics/f59"); + assert!(row.top() >= widget(&h, "sidebarTitle").bottom() && row.bottom() <= 500.0, "in view: {row:?}"); +} + +/// Without an active photo in a library folder there is nothing to show: no photo, a demo scene. +#[test] +fn show_in_folders_needs_a_photo_in_a_library_folder() { + let mut h = folders_app(&["/pics/trip/a.jpg"]); + h.app.run("library.select", json!({"ids": []})).unwrap(); + assert_eq!(h.app.session.active(), None); + assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders")); + let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "app.showInFolders").unwrap(); + assert!(!entry.enabled && entry.menu == ["Photo"], "listed in Photo, disabled"); + assert!(h.app.run("app.showInFolders", json!({})).is_err()); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All, "nothing changed"); + let mut demo = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let scene = demo.session.visible().first().copied().unwrap(); + demo.run("library.select", json!({"ids": [scene.0]})).unwrap(); + assert!(!crate::menus::ui_enabled(&demo, "app.showInFolders"), "a demo scene has no folder"); +} + +/// The grid's menu reveals the photo right-clicked, not the active one a multi-selection keeps. +#[test] +fn show_in_folders_reveals_the_photo_asked_for_and_follows_to_its_folder() { + let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/c.jpg"]); + let id_of = |h: &Headless, path: &str| { + h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id + }; + let (a, c) = (id_of(&h, "/pics/trip/a.jpg"), id_of(&h, "/pics/home/c.jpg")); + h.app.run("library.select", json!({"ids": [a.0, c.0], "active": a.0})).unwrap(); + h.step(); + let r = h.app.run("app.showInFolders", json!({"id": c.0})).unwrap(); + assert_eq!(r["path"], "/pics/home"); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home")); + assert_eq!(h.app.session.active(), Some(c), "the view of that folder shows the photo asked for"); + assert!(crate::menus::show_in_folders_enabled(&h.app, Some(c))); +} + +/// A folder deeper than the tree goes has no row: the entry is disabled rather than failing. +#[test] +fn show_in_folders_is_disabled_for_a_folder_below_the_trees_depth() { + let deep = format!("/pics{}/a.jpg", "/d".repeat(lightcraft_catalog::folders::MAX_DEPTH + 4)); + let mut h = folders_app(&[deep.as_str()]); + activate(&mut h, &deep); + assert!(!h.app.caches.folder_tree(&h.app.session.catalog).is_empty()); + assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders")); + assert!(h.app.run("app.showInFolders", json!({})).is_err()); +} + #[test] fn a_renamed_folder_keeps_its_place_in_the_tree() { /// Removes the folder however the test ends. @@ -1795,21 +1892,57 @@ fn removing_a_disk_from_the_library_asks_first() { assert_eq!(h.app.session.visible().len(), 1, "everything on that disk left the library"); } -/// However deep the folders go, each level is indented one step more than its parent, and the -/// sidebar scrolls sideways to reach them instead of squeezing the nesting. +/// A library with a photo in every folder down `deep` and two folders below it, in a window +/// `size` big, with every level but the last opened. (Every level holds a photo, so it has a row +/// of its own and starts closed, whether or not the sidebar passes over folders that only lead on.) +fn deep_folders_app(deep: &str, size: [f32; 2]) -> Headless { + let mut paths = vec![format!("{deep}/x/1.jpg"), format!("{deep}/y/2.jpg")]; + let mut levels = Vec::new(); + let mut level = String::new(); + for name in deep.split('/').filter(|n| !n.is_empty()) { + level.push('/'); + level.push_str(name); + paths.push(format!("{level}/0.jpg")); + levels.push(level.clone()); + } + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_sized(&paths, size); + // each level is opened once its row is drawn (a slow machine may need more than a frame) + for level in &levels[..levels.len() - 1] { + let toggle = format!("libraryFolderToggle:{level}"); + assert!(h.step_until(T, |h| has(h, &toggle)), "{toggle} is drawn"); + click(&mut h, &toggle); + } + let last = format!("libraryFolderToggle:{}", levels[levels.len() - 1]); + assert!(h.step_until(T, |h| has(h, &last)), "{last} is drawn"); + h.settle(SETTLE); + h +} + +/// The x of each folder's chevron down the chain `path` (`/a/b/c` → `/a`, `/a/b`, `/a/b/c`). +fn chevron_xs(h: &Headless, path: &str) -> Vec<f32> { + let mut xs: Vec<f32> = Vec::new(); + let mut at = String::new(); + for name in path.split('/').filter(|n| !n.is_empty()) { + at.push('/'); + at.push_str(name); + xs.push(widget(h, &format!("libraryFolderToggle:{at}")).center().x); + } + xs +} + +/// However deep the folders go, each level is indented one step more than its parent. The step +/// narrows to make room, never below the narrowest, and past that the sidebar scrolls sideways +/// instead of squeezing the nesting further. #[test] fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { - let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; + let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z"; // (tall enough for the whole chain: Recently Deleted is above it now) - let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); - let mut xs: Vec<f32> = Vec::new(); - let mut path = String::new(); - for name in deep.split('/').filter(|n| !n.is_empty()) { - path.push('/'); - path.push_str(name); - xs.push(widget(&h, &format!("libraryFolderToggle:{path}")).center().x); - } - assert!(xs.len() == 13 && xs.windows(2).all(|w| (w[1] - w[0] - 16.0).abs() < 0.5), "every level steps right by one indent: {xs:?}"); + let mut h = deep_folders_app(deep, [1400.0, 1700.0]); + let xs = chevron_xs(&h, deep); + let step = crate::panels::left::MIN_INDENT_STEP; + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), step, "26 levels do not fit the default panel even at the narrowest step"); + assert!(xs.len() == 26 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "every level steps right by one indent: {xs:?}"); let panel = widget(&h, "panel:left_panel"); assert!(crate::panels::left::content_width(&h.view.ctx) > panel.width(), "the content is wider than the panel: the bar appears"); assert!(widget(&h, "source:all").width() > panel.width(), "the rows are as wide as the widest one, so there is something to scroll to"); @@ -1819,9 +1952,52 @@ fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { } // what a row needs is known before it is drawn off screen, so the plus stays reachable assert!(widget(&h, "icon:albumNew").right() <= panel.right(), "Create Album stays inside the visible panel"); - // a click in the middle of a deep row chooses it; only the little triangle folds it - click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); - assert_eq!(h.app.session.library_folder.as_deref(), Some("/a/b/c/d/e/f/g/h/i/j/k/l"), "the row's middle is the row, not its triangle"); + // a click in the middle of a deep row chooses it; only the little chevron folds it + let chosen = "/a/b/c/d/e/f/g/h/i/j/k/l"; + click(&mut h, &format!("source:libfolder:{chosen}")); + assert_eq!(h.app.session.library_folder.as_deref(), Some(chosen), "the row's middle is the row, not its chevron"); +} + +/// A library below a deep path (issue #2298): in the default panel the levels step right by +/// less than a full step, so the deepest folder's name has room and nothing scrolls sideways; +/// a wide panel goes back to full steps, and a shallow tree never narrows. +#[test] +fn a_deep_library_path_narrows_the_indent_instead_of_cutting_the_names() { + let deep = "/Users/someone/Library/CloudStorage/Nextcloud-cloud.example.com/RAW/Club/2026-09-24 Team/RAW"; + let mut h = deep_folders_app(deep, [1400.0, 1200.0]); + let full = crate::panels::left::INDENT_STEP; + let narrow = crate::panels::left::indent_step(&h.view.ctx); + assert!(narrow < full && narrow >= crate::panels::left::MIN_INDENT_STEP, "the step narrows in the default panel: {narrow}"); + let xs = chevron_xs(&h, deep); + assert!(xs.windows(2).all(|w| (w[1] - w[0] - narrow).abs() < 0.5), "every level still steps right by the same amount: {xs:?}"); + let panel = widget(&h, "panel:left_panel"); + assert!(crate::panels::left::content_width(&h.view.ctx) <= panel.width(), "nothing scrolls sideways"); + let id = format!("libfolder:{deep}"); + let (label, count) = (widget(&h, &format!("label:{id}")), widget(&h, &format!("count:{id}"))); + assert!(label.width() > 20.0 && label.right() + 4.0 <= count.left(), "the deepest name shows, left of its count: {label:?} {count:?}"); + // a wide panel has room for full steps again + let r = h.request("ui.set", json!({"leftWidth": 480.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a wide panel indents by the full step"); + let xs = chevron_xs(&h, deep); + assert!(xs.windows(2).all(|w| (w[1] - w[0] - full).abs() < 0.5), "{xs:?}"); + // a shallow tree keeps the full step in the default panel + let h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]); + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a shallow tree is drawn as before"); +} + +/// The step is chosen in whole bands from the panel's width and the deepest row. +#[test] +fn the_indent_step_narrows_in_bands() { + use crate::panels::left::step_for; + // (room for the steps: the panel less the row's margins, a name's 80 pt and a count's 40) + assert_eq!(step_for(268.0, 0.0), 16.0, "top-level rows only"); + assert_eq!(step_for(268.0, 5.0 * 16.0), 16.0, "five levels fit at full steps"); + assert_eq!(step_for(268.0, 7.0 * 16.0), 12.0, "seven levels at 12 pt"); + assert_eq!(step_for(268.0, 9.0 * 16.0), 8.0, "nine levels at 8 pt"); + assert_eq!(step_for(268.0, 30.0 * 16.0), 8.0, "never below 8 pt: the sidebar scrolls instead"); + assert_eq!(step_for(480.0, 9.0 * 16.0), 16.0, "a wide panel keeps full steps"); } /// A sidebar that fits needs no sideways scrolling. diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 29c214e47..28838f409 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -378,3 +378,32 @@ fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { } assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); } + +#[test] +fn page_options_checkboxes_set_the_page_and_hide_for_packages() { + let mut h = demo(Services::default()); + open_print(&mut h); + let scroll = |h: &mut Headless, dy: f32| { + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": dy}), T); + h.settle(T); + } + }; + // the Page panel is below Page Setup: scroll down to it + scroll(&mut h, -600.0); + for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] { + click(&mut h, id); + let p = &h.app.session.print.page; + assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}"); + } + let p = &h.app.session.print.page; + assert!(p.page_numbers && p.page_info && p.crop_marks); + scroll(&mut h, 600.0); + click(&mut h, "button:printStyle-1"); + assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options"); + // Lightroom's packages have no Page Options + click(&mut h, "button:printStyle-2"); + assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks")); +} diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests_profile_menu.rs new file mode 100644 index 000000000..3bd644467 --- /dev/null +++ b/crates/ui-egui/src/tests_profile_menu.rs @@ -0,0 +1,52 @@ +//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported +//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it +//! could not be reached. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(5); +const SETTLE: Duration = Duration::from_secs(10); +const FOLDER: &str = "Imported"; + +fn widget(h: &Headless, id: &str) -> Option<egui::Rect> { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn go(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +#[test] +fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + for i in 0..80 { + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: format!("lut:imported-{i}"), + name: format!("Imported {i}"), + group: FOLDER.into(), + file: String::new(), + }); + } + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + go(&mut h, "ui.clickWidget", "dropdown:profile"); + let menu = widget(&h, "menu-level:1").expect("the profile menu is open"); + assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y); + go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}")); + let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open"); + assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y); + // Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen. + let rows: Vec<egui::Rect> = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect(); + assert!(!rows.is_empty(), "no imported profile rows drawn"); + assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window"); +} diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs index 88149e464..4d808f736 100644 --- a/crates/ui-egui/src/tests_recheck.rs +++ b/crates/ui-egui/src/tests_recheck.rs @@ -78,13 +78,207 @@ fn recheck_result_is_dropped_after_switching_libraries() { assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); } -/// The notice's Reload from Disk re-reads the file and says what came of it. +/// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack, +/// the window not waiting for the decode) and says what came of it. #[test] fn notice_reload_button_rereads_the_file() { let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); let (mut app, id) = app_with_stale_photo(&dir.0); let ctx = egui::Context::default(); crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + let rows = app.session.activity.list(); + let row = rows.iter().find(|r| r.kind == "reload").unwrap_or_else(|| panic!("no reload row: {rows:?}")); + assert_eq!(row.label, crate::reload::LABEL); + assert!(row.cancellable); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"), "the row goes with the task"); assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); + // one undo step + assert_eq!(app.session.undo.last().map(|u| u.label.as_str()), Some("Reload Changed Files")); +} + +/// Reload from Disk on a selection returns at once and reads the files on a worker thread; `wait` +/// runs it in place; a cancelled reload changes nothing it didn't read. +#[test] +fn reload_of_a_selection_runs_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-sel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.session.selection = lightcraft_engine::Selection { ids: vec![id], active: Some(id) }; + let r = app.run("photo.reload", json!({})).unwrap(); + assert_eq!(r, json!({"background": true, "count": 1})); + assert!(app.tasks.is_running(crate::reload::LABEL)); + assert!(app.session.activity.list().iter().any(|r| r.kind == "reload")); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + + // agents can wait for the result: the engine's reply, nothing left running + let r = app.run("photo.reload", json!({"ids": [id.0], "wait": true})).unwrap(); + assert!(r.get("reloaded").is_some(), "{r}"); + assert!(app.tasks.is_empty()); + + // cancelled before any file is read: nothing changes + let p = app.session.catalog.photo(id).unwrap().clone(); + app.session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + let items = lightcraft_engine::cmd::reload::plan(&mut app.session, &json!({"ids": [id.0]})).unwrap(); + let probe = lightcraft_engine::cmd::reload::file_probe(&app.session); + let cancelled = std::sync::atomic::AtomicBool::new(true); + let results = lightcraft_engine::cmd::reload::probe(probe.as_ref(), &items, &cancelled, &|_| {}); + assert!(lightcraft_engine::cmd::reload::apply(&mut app.session, items, results).unwrap().is_empty()); + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); +} + +/// With no photo selected the command is refused at once, as before (no task, no row). +#[test] +fn reload_with_nothing_selected_starts_nothing() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-none-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + app.session.selection = lightcraft_engine::Selection::default(); + let _ = app.run("photo.reload", json!({})); + assert!(app.tasks.is_empty()); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload")); +} + +/// Two Reloads of the same photo overlap: the first is held after reading the original file, the +/// file is replaced and a second Reload finishes. The first one finishing afterwards must not put +/// back the old dimensions and content hash. +#[test] +fn an_older_reload_does_not_replace_a_newer_one() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Condvar, Mutex}; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-order-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + let native = app.session.media.file_probe.clone().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + // (the first probe has read its file, the first probe may return) + let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); + let (worker_gate, worker_calls) = (gate.clone(), calls.clone()); + app.session.media.file_probe = Some(Arc::new(move |path| { + let info = native(path); + if worker_calls.fetch_add(1, Ordering::SeqCst) == 0 { + let mut state = worker_gate.0.lock().unwrap(); + state.0 = true; + worker_gate.1.notify_all(); + let _ = worker_gate.1.wait_timeout_while(state, Duration::from_secs(10), |s| !s.1).unwrap(); + } + info + })); + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let (state, _) = gate.1.wait_timeout_while(gate.0.lock().unwrap(), Duration::from_secs(5), |s| !s.0).unwrap(); + assert!(state.0, "the first reload read the original file"); + drop(state); + + // an external editor saves new pixels and the user reloads again + let image = lightcraft_raster::Rgba8::from_fn(40, 20, |x, y| [(x * 3) as u8, (y * 5) as u8, 90, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.0.join("files/one.png"), lightcraft_engine::export::encode_image(&image, &o).unwrap()).unwrap(); + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while app.session.catalog.photo(id).is_some_and(|p| (p.width, p.height) != (40, 20)) { + crate::tasks::poll(&mut app, &ctx); + assert!(std::time::Instant::now() < deadline, "the second reload finishes while the first is held"); + std::thread::sleep(Duration::from_millis(5)); + } + let newer_hash = app.session.catalog.photo(id).unwrap().content_hash.clone(); + + // the first reload finishes last + gate.0.lock().unwrap().1 = true; + gate.1.notify_all(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + let after = app.session.catalog.photo(id).unwrap(); + assert_eq!((after.width, after.height), (40, 20), "the older reload put back the old dimensions"); + assert_eq!(after.content_hash, newer_hash); +} + +/// A background Reload commits like the command: with automatic XMP writing on, metadata the file +/// newly gives (here GPS) reaches the sidecar too. +#[test] +fn a_background_reload_writes_the_sidecar_with_auto_write_on() { + use std::sync::Arc; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-xmp-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.run("library.xmpPreferences", json!({"autoWrite": true})).unwrap(); + app.run("photo.rate", json!({"ids": [id.0], "rating": 1})).unwrap(); + let sidecar = dir.0.join("files/one.xmp"); + assert!(!std::fs::read_to_string(&sidecar).unwrap().contains("GPSLatitude")); + let native = app.session.media.file_probe.clone().unwrap(); + app.session.media.file_probe = Some(Arc::new(move |path| { + let mut info = native(path)?; + // GPS the catalog doesn't have yet (as for a photo catalogued by an older version) + info.meta.gps = Some((41.0, 2.0)); + Ok(info) + })); + let r = app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["background"], true); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().meta.gps, Some((41.0, 2.0))); + assert!(std::fs::read_to_string(&sidecar).unwrap().contains("GPSLatitude"), "the sidecar was not written"); +} + +/// Bad `ids` are refused naming the command once, as the engine's own reply does (`wait: true`). +#[test] +fn reload_with_bad_ids_names_the_command() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-bad-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + let background = app.run("photo.reload", json!({"ids": "x"})).unwrap_err(); + let waited = app.run("photo.reload", json!({"ids": "x", "wait": true})).unwrap_err(); + assert_eq!(background, waited); + assert!(background.contains("`photo.reload`"), "{background}"); + assert!(!background.contains("``") && !background.starts_with("photo.reload:"), "{background}"); +} + +/// A Reload stopped from the activity stack before its file was read says nothing (the raw wasn't +/// tried again); one stopped after the file was read still says what came of it. +#[test] +fn a_cancelled_reload_reports_only_what_it_did() { + use std::sync::{Arc, Condvar, Mutex}; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-cancel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + let native = app.session.media.file_probe.clone().unwrap(); + for read in [false, true] { + // (the probe has started, the probe may return) + let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); + let worker_gate = gate.clone(); + let native = native.clone(); + app.session.media.file_probe = Some(Arc::new(move |path| { + let mut state = worker_gate.0.lock().unwrap(); + state.0 = true; + worker_gate.1.notify_all(); + let _ = worker_gate.1.wait_timeout_while(state, Duration::from_secs(10), |s| !s.1).unwrap(); + // a read the cancel stopped comes back as an error, as a file not reached does + if read { native(path) } else { Err(format!("{path}: cancelled")) } + })); + app.ui.toast = None; + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let (state, _) = gate.1.wait_timeout_while(gate.0.lock().unwrap(), Duration::from_secs(5), |s| !s.0).unwrap(); + assert!(state.0, "the reload started reading"); + drop(state); + let row = app.session.activity.list().into_iter().find(|r| r.kind == "reload").unwrap(); + app.session.activity.cancel(row.id).unwrap(); + gate.0.lock().unwrap().1 = true; + gate.1.notify_all(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + if read { + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); + } else { + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); + assert!(app.ui.toast.is_none(), "a cancelled reload reported {:?}", app.ui.toast); + } + } } diff --git a/crates/ui-egui/src/tests_rules_editor.rs b/crates/ui-egui/src/tests_rules_editor.rs new file mode 100644 index 000000000..6ce8b7428 --- /dev/null +++ b/crates/ui-egui/src/tests_rules_editor.rs @@ -0,0 +1,110 @@ +//! The smart-album rule editor's layout: a rule's + and − never cover its value (#2353), so both +//! calendars of a "between" can be clicked (#2352), and hovering an item of the field menu doesn't +//! make it wrap (#2354). + +use std::time::Duration; + +use lightcraft_catalog::rules::{FIELD_GROUPS, TOP_LEVEL_FIELDS}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The rule editor holding `rules`, opened after a narrower dialog: the dialogs share one window, +/// which keeps the width the last one left it at. +fn editor(rules: serde_json::Value) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("engine.execute", json!({"command": "dialog.newSmartAlbum", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.app.ui.dialog = None; + h.settle(SETTLE); + let r = h.request("engine.execute", json!({"command": "dialog.smartAlbum", "params": {"name": "Dates"}}), T); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + *r = serde_json::from_value(json!({"rules": rules})).unwrap(); + h.settle(SETTLE); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn request(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +fn rule_count(h: &Headless) -> usize { + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules.len() +} + +/// A "between" on a date is the longest rule: its + and − follow its second date and calendar +/// instead of being drawn over them, the dialog widens to hold it, and every row's buttons stay +/// lined up at the right. +#[test] +fn add_and_remove_never_cover_a_between_dates_rule() { + let h = editor(json!([ + {"field": "captureDate", "op": "between", "value": ["2026-02-01", "2026-02-28"]}, + {"field": "rating", "op": "gte", "value": 3}, + ])); + let dialog = rect(&h, "dialog:window"); + for b in ["button:ruleAdd-rules-0", "button:ruleRemove-rules-0"] { + let b_rect = rect(&h, b); + assert!(dialog.contains_rect(b_rect), "{b} {b_rect:?} is outside the dialog {dialog:?}"); + for v in ["field:rules-0-a", "datePicker:rules-0-a", "field:rules-0-b", "datePicker:rules-0-b"] { + let v_rect = rect(&h, v); + assert!(dialog.contains_rect(v_rect), "{v} {v_rect:?} is outside the dialog {dialog:?}"); + assert!(!b_rect.intersects(v_rect), "{b} {b_rect:?} covers {v} {v_rect:?}"); + } + } + let (long, short) = (rect(&h, "button:ruleAdd-rules-0"), rect(&h, "button:ruleAdd-rules-1")); + assert!((long.right() - short.right()).abs() < 0.5, "the rows' + don't line up: {long:?} vs {short:?}"); +} + +/// The end date's calendar is there and opens when clicked; the click doesn't land on the + that +/// used to cover it (and add a rule). +#[test] +fn the_end_dates_calendar_opens() { + let mut h = editor(json!([{"field": "captureDate", "op": "between", "value": ["2026-03", "2026-06"]}])); + request(&mut h, "ui.clickWidget", "datePicker:rules-0-b"); + assert!(has(&h, "datePickerCell:2026-06:rules-0-b"), "the end date's calendar opened on its month"); + assert_eq!(rule_count(&h), 1, "no rule was added"); +} + +/// Hovering any field in the field menu, top-level or in a group's submenu, keeps it the size it +/// was: a hovered item used to need 2 points more, so the longest name of a submenu sized to it +/// (Date ▸ Capture Date, Keywords & People ▸ Keyword Count) wrapped onto a second line and the +/// menu jumped. +#[test] +fn hovering_a_field_keeps_its_size() { + let mut h = editor(json!([{"field": "rating", "op": "gte", "value": 3}])); + request(&mut h, "ui.clickWidget", "ruleField:rules-0"); + let hover = |h: &mut Headless, id: &str| { + let item = format!("ruleFieldItem:{id}:rules-0"); + let before = rect(h, &item); + request(h, "ui.hoverWidget", &item); + let after = rect(h, &item); + assert_eq!(after.size(), before.size(), "{id} changed size when hovered: {before:?} → {after:?}"); + }; + for id in TOP_LEVEL_FIELDS { + hover(&mut h, id); + } + for (group, ids) in FIELD_GROUPS { + request(&mut h, "ui.hoverWidget", &format!("ruleFieldGroup:{group}:rules-0")); + for id in *ids { + hover(&mut h, id); + } + } +} diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index d1ad85c96..2ea310b04 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -183,6 +183,40 @@ fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn reference_photo_ids_do_not_cross_library_boundaries() { + let dir = temp("reference-switch"); + let (first, second) = (dir.join("first"), dir.join("second")); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&second, true).unwrap(); + let new_photo = session.visible_cloned()[0]; + session.close_library().unwrap(); + session.open_library(&first, true).unwrap(); + let old_reference = session.visible_cloned()[1]; + let mut app = LightcraftApp::new(session, Services::default()); + app.run("library.select", json!({"ids": [old_reference.0]})).unwrap(); + app.run("photo.setReference", json!({})).unwrap(); + app.run("view.reference", json!({})).unwrap(); + let held = lightcraft_catalog::LibraryLock::acquire(&second, "another program").unwrap(); + assert!(app.run("app.openLibrary", json!({"path": second.to_string_lossy()})).is_err()); + assert_eq!(app.ui.reference, Some(old_reference.0), "a refused switch keeps the live pin"); + assert_eq!(app.ui.view, crate::state::ViewMode::Reference); + drop(held); + app.run("app.openLibrary", json!({"path": second.to_string_lossy()})).unwrap(); + assert!(app.session.catalog.photo(old_reference).is_some(), "the second library reuses this numeric ID"); + assert_eq!(app.ui.reference, None, "the first library's pin must not acquire a different photo"); + assert_eq!(app.ui.view, crate::state::ViewMode::PhotoGrid, "an empty Reference view is not left on screen"); + app.run("library.select", json!({"ids": [new_photo.0]})).unwrap(); + let r = app.run("view.reference", json!({})).unwrap(); + assert_eq!(r["reference"], new_photo.0); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Empty"})).unwrap(); + assert_eq!(app.ui.reference, None, "New Library clears the old reference too"); + assert!(app.run("view.reference", json!({})).is_err(), "an empty library cannot pin a reference"); + assert_eq!(app.ui.reference, None); + app.session.close_library().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// A library another program has open is refused before the current one is closed: the current /// library stays open with its photos, and the window title keeps its name. #[test] diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 9e0db5297..84cda5860 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -10,9 +10,14 @@ //! * Synchronize with the defaults imports the new photos and leaves missing ones alone. //! * Ticking "Remove missing photos" also moves those to Recently Deleted. //! * Cancel leaves the library as it was. +//! * A folder that has nothing to bring in only says so: the dialog has one Close and no +//! choices, and closing it starts nothing. //! * Synchronize hands the work to the background at once: the dialog closes, the app keeps //! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo //! step. +//! * While the folder is listed (minutes, on a network share), the scan's row says how many +//! folders and files it has listed, with a bar that moves on its own, not "0 of 64085"; then it +//! counts the files and photos checked. use std::time::Duration; @@ -59,12 +64,19 @@ fn write_png(path: &str, seed: u8) { /// The app with `trip/a.png` and `trip/b.png` imported, then `c.png` added and `b.png` deleted /// on disk behind its back. fn changed_folder(dir: &Scratch) -> Headless { + imported_folder(dir, || { + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/b.png")).unwrap(); + }) +} + +/// The app with `trip/a.png` and `trip/b.png` imported, and `then` done on disk after. +fn imported_folder(dir: &Scratch, then: impl FnOnce()) -> Headless { write_png(&dir.path("trip/a.png"), 1); write_png(&dir.path("trip/b.png"), 2); let mut session = lightcraft_engine::Session::new().with_fs(); session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); - write_png(&dir.path("trip/c.png"), 3); - std::fs::remove_file(dir.path("trip/b.png")).unwrap(); + then(); let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1400.0, 900.0], 1.0); let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); @@ -84,8 +96,24 @@ fn open_and_scan(h: &mut Headless, folder: &str) { // the rows above it hold one folder each and open by themselves let row = format!("source:libfolder:{folder}"); assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}"); + let before = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + // Off-screen rows are registered too. Preserve the default layout when this row is already visible. + let before_visible = before.intersect(panel); + let before_drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}")); + if !before_drawn || !before_visible.is_positive() || before_visible.height() < before.height() - 0.5 { + crate::tests_panels::scroll_sidebar(h, panel.center().y - before.center().y); + } let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); - let c = rect.center(); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + let visible = rect.intersect(panel); + let c = visible.center(); + let drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}")); + let clips: Vec<_> = h.view.shapes.iter().filter(|s| s.shape.visual_bounding_rect().intersects(rect)).map(|s| s.clip_rect).collect(); + assert!( + drawn && visible.is_positive() && visible.height() >= rect.height() - 0.5, + "folder row must be drawn and vertically in view: before={before:?} row={rect:?} panel={panel:?} visible={visible:?} click={c:?} drawn={drawn} paint_clips={clips:?}" + ); let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -179,6 +207,42 @@ fn cancel_leaves_the_library_as_it_was() { assert!(h.app.session.folder_changes.is_none(), "the scan is let go"); } +fn shown(h: &Headless, widget: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == widget) +} + +#[test] +fn a_folder_with_nothing_to_bring_in_is_only_reported() { + let dir = Scratch::new("uptodate"); + let mut h = imported_folder(&dir, || {}); + open_and_scan(&mut h, &dir.path("trip")); + h.step(); + assert!(shown(&h, "button:dialogOk"), "one button, to close it"); + for gone in ["button:dialogCancel", "syncImportNew", "syncRelinkMoved", "syncRemoveMissing", "syncReadMetadata"] { + assert!(!shown(&h, gone), "nothing to choose: {gone}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "the button closes it"); + assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "and starts nothing"); + assert!(h.app.session.folder_changes.is_none(), "the scan is let go"); + assert_eq!(in_library(&h), vec!["a.png", "b.png"]); +} + +#[test] +fn confirming_a_folder_with_nothing_to_bring_in_starts_nothing() { + let dir = Scratch::new("uptodate-agent"); + let mut h = imported_folder(&dir, || {}); + open_and_scan(&mut h, &dir.path("trip")); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["upToDate"], true, "{r}"); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "no job that does nothing"); + h.step(); + assert!(h.app.session.folder_changes.is_none(), "the scan is let go"); +} + #[test] fn an_agents_scan_is_kept_between_frames() { let dir = Scratch::new("agent"); @@ -247,7 +311,7 @@ fn a_synchronize_and_an_import_never_run_at_once() { path: dir.path("trip"), name: "trip".into(), disk: false, - counts: Some(Default::default()), + counts: Some(crate::state::SyncCounts { new: 1, ..Default::default() }), import_new: true, relink_moved: true, remove_missing: false, @@ -343,3 +407,19 @@ fn a_local_folder_with_library_photos_offers_synchronize() { assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once"); assert!(h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })))); } + +#[test] +fn while_the_folder_is_listed_its_row_says_what_was_listed_and_its_bar_moves_on_its_own() { + use std::sync::atomic::Ordering::Relaxed; + let p = lightcraft_engine::sync::SyncProgress::default(); + p.to_check.store(64085, Relaxed); + p.folders_listed.store(312, Relaxed); + p.files_listed.store(18402, Relaxed); + let (done, total, detail) = crate::sync::scan_row(&p, "photos/Various"); + assert_eq!((done, total), (0, 0), "a bar that moves on its own, not 0 of 64085"); + assert_eq!(detail, "Listing folders — folders: 312, files: 18402 · photos/Various"); + // the listing is over: the files and photos checked, of how many + p.listed.store(true, Relaxed); + p.checked.store(10, Relaxed); + assert_eq!(crate::sync::scan_row(&p, "photos/Various"), (10, 64085, "photos/Various".to_string())); +} diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 68c04f9ec..36598190c 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -130,6 +130,28 @@ fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { assert!(bar.is_none()); } +// Scenario: the search field is centred on the window, whatever room the traffic lights take (#2365) +#[test] +fn the_search_field_stays_centred_on_the_window() { + let mut h = integrated(); + let window_centre = 1200.0 / 2.0; + let windowed = widget(&h, "field:search"); + assert_eq!(windowed.center().x, window_centre, "windowed, the field is centred on the window"); + // the filter icon rides with the field, so it moves whenever the field does + let filter = widget(&h, "icon:filter").center().x; + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(h.app.window_is_fullscreen); + let full_screen = widget(&h, "field:search"); + assert_eq!(full_screen.center().x, window_centre, "full screen leaves it centred"); + assert_eq!(full_screen.width(), windowed.width(), "and the same width"); + assert_eq!(widget(&h, "icon:filter").center().x, filter); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(!h.app.window_is_fullscreen); + assert_eq!(widget(&h, "field:search").center().x, window_centre, "and so does leaving it"); +} + // Scenario: in full screen the traffic lights are gone, so the bar's buttons move into their space #[test] fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests_zoom.rs index b0595d27d..580b70615 100644 --- a/crates/ui-egui/src/tests_zoom.rs +++ b/crates/ui-egui/src/tests_zoom.rs @@ -109,7 +109,58 @@ fn zoomed_before_after_views_stay_in_their_panes() { } } -/// A crop of no size (or a hair wide), from an agent or a sidecar, gave the loupe a NaN aspect: +/// The "Before" label (\ or the Before view) at 100 %: the photo's corner is off screen, and the +/// label used to leave with it (issue #2267). It now sits at the canvas' corner, in view. +#[test] +fn zoomed_in_the_before_label_stays_in_view() { + use crate::state::{BeforeAfter, Zoom}; + let mut h = detail(); + h.app.ui.before_after = BeforeAfter::Original; + let label = |h: &mut Headless| -> egui::Rect { + let w = h.request("ui.widgets", json!({"filter": "canvas:beforeLabel"}), T); + let r = w["result"][0]["rect"].as_array().unwrap_or_else(|| panic!("the Before label is registered: {w}")); + let v: Vec<f32> = r.iter().filter_map(|x| x.as_f64()).map(|x| x as f32).collect(); + egui::Rect::from_min_size(egui::pos2(v[0], v[1]), egui::vec2(v[2], v[3])) + }; + h.app.ui.zoom = Zoom::Fit; + h.settle(SETTLE); + let (img, canvas) = (h.app.image_rect.expect("photo on screen"), h.app.canvas_rect.expect("canvas")); + let fitted = label(&mut h); + assert_eq!(fitted.min, img.min + egui::vec2(8.0, 8.0), "at Fit the label sits on the photo's corner"); + h.app.ui.zoom = Zoom::Percent(100.0); + h.app.ui.pan = (0.3, 0.4); + h.settle(SETTLE); + let img = h.app.image_rect.expect("photo on screen"); + assert!(img.min.x < canvas.min.x && img.min.y < canvas.min.y, "100 % reaches past the canvas ({img:?} in {canvas:?})"); + let zoomed = label(&mut h); + assert!(canvas.contains_rect(zoomed), "the label stays in view: {zoomed:?} in {canvas:?}"); + assert_eq!(zoomed.min, canvas.min + egui::vec2(8.0, 8.0)); +} + +/// With the info overlay on, zoomed in, the Before label sits below the overlay's lines instead +/// of under them (both live in the canvas' top-left corner). +#[test] +fn zoomed_in_the_before_label_keeps_clear_of_the_info_overlay() { + use crate::state::{BeforeAfter, InfoOverlay, Zoom}; + let mut h = detail(); + h.app.ui.before_after = BeforeAfter::Original; + h.app.ui.info_overlay = InfoOverlay::Exposure; + h.app.ui.zoom = Zoom::Percent(100.0); + h.app.ui.pan = (0.3, 0.4); + h.settle(SETTLE); + let rect = |h: &mut Headless, id: &str| -> egui::Rect { + let w = h.request("ui.widgets", json!({"filter": id}), T); + let r = w["result"][0]["rect"].as_array().unwrap_or_else(|| panic!("{id} is registered: {w}")); + let v: Vec<f32> = r.iter().filter_map(|x| x.as_f64()).map(|x| x as f32).collect(); + egui::Rect::from_min_size(egui::pos2(v[0], v[1]), egui::vec2(v[2], v[3])) + }; + let (badge, overlay) = (rect(&mut h, "canvas:beforeLabel"), rect(&mut h, "canvas:infoOverlay")); + let canvas = h.app.canvas_rect.expect("canvas"); + assert!(badge.top() >= overlay.bottom(), "the label {badge:?} is below the overlay {overlay:?}"); + assert!(canvas.contains_rect(badge), "and still in view: {badge:?} in {canvas:?}"); +} + +/// A crop of no size (or a hair wide), from a sidecar, gave the loupe a NaN aspect: /// the photo was laid out at a NaN size, which egui's debug checks stop on (a panic in a debug /// build) and nothing could be drawn or clicked. It now lays out at a finite size, fitted and at /// 100 %. @@ -117,8 +168,8 @@ fn zoomed_before_after_views_stay_in_their_panes() { fn a_crop_of_no_size_lays_out_at_a_finite_size() { let mut h = detail(); for rect in [[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 1e-9, 1.0], [0.0, 0.3, 1.0, 0.3]] { - let r = h.request("engine.execute", json!({"command": "crop.set", "params": {"rect": rect}}), T); - assert_eq!(r["ok"], true, "{r}"); + // as a sidecar can set it: `crop.set` refuses a crop this small + edit(&mut h, |d| d.crop.geometry.rect = lightcraft_geom::Rect::new(rect[0], rect[1], rect[2], rect[3])); for zoom in ["view.zoomFit", "view.zoom100"] { let r = h.request("engine.execute", json!({"command": zoom}), T); assert_eq!(r["ok"], true, "{zoom}: {r}"); diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index 268242ef1..aed99f4d8 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -272,9 +272,9 @@ impl<'a> TextField<'a> { } let menu_was_open = memo.menu_open; // a click on a greyed-out item does nothing, as in a native menu: only a choice or a click - // outside closes it + // outside closes it (not the right-click that moves the menu within the field, #1877) memo.menu_open = egui::Popup::context_menu(&response) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .close_behavior(crate::widgets::context_menu_close(&response, egui::PopupCloseBehavior::CloseOnClickOutside)) .show(|ui| menu(ui, self.widget, id, self.text, &mut memo.action)) .is_some(); @@ -555,6 +555,21 @@ mod tests { assert_eq!(rig.text, "Kyoto"); } + /// #1877: a right-click elsewhere in the field while its menu is open moves the menu there + /// (it flashed at the new spot and closed). + #[test] + fn right_clicking_again_moves_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu"); + let rect = rig.rect(FIELD).unwrap(); + let at = egui::pos2(rect.left() + 10.0, rect.center().y); + rig.click_at(at, PointerButton::Secondary); + let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets)); + assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})"); + assert!(rig.endings.is_empty(), "the edit goes on"); + } + #[test] fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { let mut rig = Rig::new("Lisbon"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a00bca4b2..2db3f885e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -10,9 +10,18 @@ use crate::theme::Tokens; /// Standard dropdown chevrons, including settings, import/export and rule editors. pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox { - egui::ComboBox::from_id_salt(id).icon(|ui, rect, visuals, _| { - paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); - }) + egui::ComboBox::from_id_salt(id) + .icon(|ui, rect, visuals, _| { + paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); + }) + .popup_style(list_style.into()) +} + +/// A dropdown's list and its submenus: items without the resting border of a button, as egui's +/// own menus draw them. An unhovered item gives up its border's width of padding, so with one it +/// grew 2 points when hovered, and the longest name of a list sized to it wrapped (#2354). +fn list_style(style: &mut egui::Style) { + style.visuals.widgets.inactive.bg_stroke = Stroke::NONE; } /// A native button with a vector arrow beside its text; egui reserves the icon's space. @@ -44,6 +53,21 @@ pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, contents egui::InnerResponse::new(inner.map(|r| r.inner), response) } +/// A context menu on `response`, as `Response::context_menu` gives, that a right-click moves +/// (#1877). egui closes a menu on any click once it was open on the previous frame, and judges +/// that by the menu's id: a right-click elsewhere on the same widget while its menu is open +/// reopened the menu at the pointer and closed it again on the same frame, a one-frame flash. +/// The click that opens (or moves) the menu is not the one that closes it. +pub fn context_menu(response: &Response, add_contents: impl FnOnce(&mut Ui)) -> Option<egui::InnerResponse<()>> { + egui::Popup::context_menu(response).close_behavior(context_menu_close(response, egui::PopupCloseBehavior::CloseOnClick)).show(add_contents) +} + +/// How a context menu on `response` closes this frame: `default`, except on the frame of the +/// right-click that opens or moves it, which must not close it (see [`context_menu`]). +pub fn context_menu_close(response: &Response, default: egui::PopupCloseBehavior) -> egui::PopupCloseBehavior { + if response.secondary_clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { default } +} + // ------------------------------------------------------------------ automation registry #[derive(Clone, Default)] @@ -240,16 +264,45 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); let id = ui.id().with(spec.id); let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); - // A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust - // opt-in so hovering an unfocused slider still scrolls the containing panel. - // Tab reaching the slider selects it as a click does: the arrow keys then adjust it + // Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag + // in it (at most one slider is armed: `wheel_slider_key`). + let wheel_id = id.with("wheel-rem"); + let mut wheel_armed = ui.data(|d| d.get_temp::<egui::Id>(wheel_slider_key())) == Some(id); + let pointer_in_row = ui.rect_contains_pointer(row); + // Every move this frame counts, not only where the pointer ended up: a slow frame (a slider + // drag, soft proofing) can merge leaving the row and coming back into one. + let left_row = wheel_armed + && ui.input(|i| { + i.events.iter().any(|e| match e { + egui::Event::PointerMoved(p) => !row.contains(*p), + egui::Event::PointerGone => true, + _ => false, + }) + }); + // Tab reaching the slider selects it for arrow keys, not vertical wheel edits. if enabled && resp.gained_focus() { ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); } - if enabled && (resp.clicked() || resp.drag_started()) { + let selected = enabled && (resp.clicked() || resp.drag_started()); + if selected { resp.request_focus(); ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); } + // A drag that leaves the row disarms too (it keeps dragging: that needs no wheel selection), + // and one that only starts once the pointer is outside doesn't arm it again. + if selected && pointer_in_row && !left_row { + ui.data_mut(|d| { + d.insert_temp(wheel_slider_key(), id); + d.remove::<f32>(wheel_id); + }); + wheel_armed = true; + } else if wheel_armed && (!enabled || !resp.has_focus() || !pointer_in_row || left_row) { + ui.data_mut(|d| { + d.remove::<egui::Id>(wheel_slider_key()); + d.remove::<f32>(wheel_id); + }); + wheel_armed = false; + } // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); @@ -313,8 +366,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); } let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); - // The opening press must complete the readout's original click before the edit continues. - if value_released && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + // A release over the value keeps the field the press opened, however long the button was held + // (a press held past egui's click time ends without a click). A release elsewhere cancels the + // gesture, and a press that scrubs already dropped the field above. + let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| value_rect.contains(p)); + if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { typing = None; ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); @@ -446,7 +502,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }) }); // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus() || inc || dec) { + if enabled && out.value.is_none() && typing.is_none() && (pointer_in_row || resp.has_focus() || inc || dec) { let (up, down, shift) = ui.input_mut(|i| { let shift = i.modifiers.shift; let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; @@ -469,7 +525,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. let side_id = id.with("side-rem"); let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); - if !enabled || typing.is_some() || out.value.is_some() || !ui.rect_contains_pointer(row) || dx.abs() <= dy.abs() { + if !enabled || typing.is_some() || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { ui.data_mut(|m| m.remove::<f32>(side_id)); } else { // the vertical part would scroll the panel and move the slider out from under the pointer @@ -487,10 +543,8 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. // Consume only the vertical component so a trackpad's horizontal scroll still works. - let wheel_id = id.with("wheel-rem"); - if !resp.has_focus() { - ui.data_mut(|m| m.remove::<f32>(wheel_id)); - } else if enabled && typing.is_none() && out.value.is_none() && ui.rect_contains_pointer(row) { + // (`wheel_armed` is cleared above with its remainder whenever focus or the pointer leaves.) + if wheel_armed && enabled && typing.is_none() && out.value.is_none() && pointer_in_row { let delta = ui.input_mut(|i| { let dy = i.smooth_scroll_delta.y; if dy != 0.0 { @@ -1013,8 +1067,13 @@ fn focused_slider_key() -> egui::Id { egui::Id::new("lightcraft-focused-slider") } -/// Whether the widget with keyboard focus is a develop slider a click selected. It owns only the -/// arrow keys (and the wheel): every other shortcut still works, as in Lightroom after a drag. +/// The slider a click or drag armed for vertical wheel edits, if any. +fn wheel_slider_key() -> egui::Id { + egui::Id::new("lightcraft-wheel-slider") +} + +/// Whether the widget with keyboard focus is a develop slider selected by a click, drag or Tab. +/// It owns only the arrow keys: every other shortcut still works. Wheel selection is separate. pub fn slider_has_focus(ctx: &egui::Context) -> bool { let focused = ctx.memory(|m| m.focused()); focused.is_some() && focused == ctx.data(|d| d.get_temp::<egui::Id>(focused_slider_key())) diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 278ff3ba8..eb2e3c9b8 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -176,3 +176,153 @@ fn a_disabled_value_does_not_scrub() { assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started)); assert_eq!(h.value, 0.71); } + +#[test] +fn a_long_click_keeps_the_field_open() { + // A press held past egui's click time (here a second and a half) used to close the field on + // release; it stays open for typing. + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(true)]); + for _ in 0..75 { + h.frame(vec![]); + } + assert_eq!(h.frame(vec![button(false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after a long click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} + +#[test] +fn leaving_a_slider_disarms_wheel_edits_until_it_is_selected_again() { + let mut h = Harness::new(); + let inside = h.track.center(); + let outside = egui::pos2(5.0, 80.0); + let button = |pressed| Event::PointerButton { pos: inside, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let wheel = || Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(true)]); + h.frame(vec![button(false)]); + let selected = h.value; + h.frame(vec![wheel()]); + for _ in 0..40 { + h.frame(vec![]); + } + assert!(h.value > selected, "selecting a slider enables wheel nudges"); + h.frame(vec![Event::PointerMoved(outside)]); + let before_return = h.value; + h.frame(vec![Event::PointerMoved(inside), wheel()]); + for _ in 0..40 { + h.frame(vec![]); + } + assert_eq!(h.value, before_return, "scrolling back over a previously selected slider must not edit it"); + h.frame(vec![Event::PointerMoved(outside)]); + let before_arrow = h.value; + h.key(Key::ArrowUp); + assert!(h.value > before_arrow, "leaving must preserve keyboard arrows"); + // Sideways scrolling remains independent of vertical wheel selection (#1465). + let before_sideways = h.value; + h.frame(vec![ + Event::PointerMoved(inside), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(120.0, 0.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + for _ in 0..40 { + h.frame(vec![]); + } + let after_sideways = h.value; + assert!(after_sideways > before_sideways, "a disarmed slider still accepts sideways nudges"); + h.frame(vec![wheel()]); + for _ in 0..40 { + h.frame(vec![]); + } + assert_eq!(h.value, after_sideways, "sideways nudges do not re-arm vertical wheel edits"); + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(true)]); + h.frame(vec![button(false)]); + let selected_again = h.value; + h.frame(vec![wheel()]); + for _ in 0..40 { + h.frame(vec![]); + } + assert!(h.value > selected_again, "selecting it again restores wheel nudges"); +} + +#[test] +fn leaving_the_row_during_a_drag_keeps_dragging_but_release_disarms_the_wheel() { + let mut h = Harness::new(); + let inside = h.track.center(); + let outside = egui::pos2(h.track.right() - 20.0, 80.0); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(inside, true)]); + let initial = h.value; + h.frame(vec![Event::PointerMoved(outside)]); + assert!(h.value > initial, "an owned drag keeps editing outside the row"); + h.frame(vec![button(outside, false)]); + let released = h.value; + h.frame(vec![ + Event::PointerMoved(inside), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + for _ in 0..40 { + h.frame(vec![]); + } + assert_eq!(h.value, released, "releasing outside disarms subsequent wheel edits"); +} + +#[test] +fn a_drag_that_leaves_and_ends_back_in_the_row_disarms_the_wheel() { + let mut h = Harness::new(); + let inside = h.track.center(); + let outside = egui::pos2(h.track.right() - 20.0, 80.0); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(inside, true)]); + h.frame(vec![Event::PointerMoved(outside)]); + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(inside, false)]); + let released = h.value; + h.frame(vec![Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }]); + for _ in 0..40 { + h.frame(vec![]); + } + assert_eq!(h.value, released, "the drag left the row, so the wheel needs a fresh selection"); +} + +#[test] +fn leaving_and_returning_within_one_frame_still_disarms_the_wheel() { + let wheel = || Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + // a slow frame merges the moves out of the row and back (or out of the window and back) + for away in [Event::PointerMoved(egui::pos2(5.0, 80.0)), Event::PointerGone] { + let mut h = Harness::new(); + let inside = h.track.center(); + let button = |pressed| Event::PointerButton { pos: inside, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![button(true)]); + h.frame(vec![button(false)]); + let selected = h.value; + h.frame(vec![away.clone(), Event::PointerMoved(inside), wheel()]); + for _ in 0..40 { + h.frame(vec![]); + } + assert_eq!(h.value, selected, "the pointer left the row between two frames ({away:?})"); + } +} diff --git a/docs/ai-masks.md b/docs/ai-masks.md index b02912abc..b01fd9e42 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -2,7 +2,8 @@ LightCraft selects objects with **SAM 3** (Segment Anything with Concepts, Meta 2025), run in pure Rust by `crates/segment` on [candle](https://github.com/huggingface/candle): on the GPU -through Metal on macOS, on the CPU elsewhere (for now). +through Metal on macOS, on the CPU elsewhere (for now). If the Mac's GPU can't run it, it runs on +the CPU (see the worker below). **The model is optional.** It is not part of LightCraft, and nothing requires it: without it, Object and Describe offer to download it, every other feature (and every existing mask, see @@ -16,7 +17,10 @@ In the Masking panel (M): to leave it out. Clicks show as green (include) and red (leave out) dots. - **Describe**: type what to select — `sky`, `trees`, `the red car` — and press Return. Every instance the model finds (score above 0.5) joins the mask; if nothing matches, nothing is - created and you are told so. + created and you are told so. Around what it found, the mask follows the model's semantic mask + for the phrase, which is sure inside and runs on where two instances touch (the instances' + own masks alone left a sky or a net about 70–90 % selected and a thin line between two people + side by side). - Several things at once: `car, road` (comma-separated) selects each and merges them. - **+ / −** under the selected mask (and the Add / Subtract / Intersect menus) combine selections: Describe `car`, click the mask, + ▸ Describe… `road`; − ▸ Describe… `people` takes @@ -214,7 +218,7 @@ wait and return the result (when built with the `sam` feature; the default CLI h `NOTICE` — the ported files say so and that they were modified): the 32-layer ViT backbone with 2-D RoPE and windowed attention, the feature pyramids, the SAM 2-style prompt encoder and two-way mask decoder for clicks, and the CLIP text encoder, DETR encoder/decoder (box - relative-position bias, presence token) and pixel decoder for text. The CLIP tokenizer is a + relative-position bias, presence token), pixel decoder and semantic head for text. The CLIP tokenizer is a small BPE in `tokenizer.rs`. The downloader (original code) is the `lightcraft-fetch` crate, shared by any model that is downloaded; `fetch/` here says what SAM 3 needs. - candle is pinned at 0.9.2: later releases make `candle-core` depend on `tokenizers` with the Oniguruma C library, and the product is pure Rust. Weights are read with positional reads (no @@ -225,6 +229,13 @@ wait and return the result (when built with the `sam` feature; the default CLI h encoded photo; the session sends it jobs and receives results over channels, never holding a lock the model holds. Each job runs under `catch_unwind`: a panic inside candle becomes an error and the model is reloaded on the next request; busy counters are reset by drop guards. +- GPU fallback (issue #1974): a job that fails on the GPU (a panic or a candle error, e.g. an + AMD GPU in an Intel Mac whose Metal compiler crashes on a matmul kernel) frees the model and runs + once more on the CPU. If that works, the model stays on the CPU until LightCraft restarts and + the user is told once ("The GPU couldn't run the AI mask model…"); if the CPU fails too, the + error is reported and the next job tries the GPU again. Missing or damaged model files and + remote-worker errors are not retried. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep + the model on the CPU from the start. - Hostile documents: a selection keeps at most 4 detail patches (when read and when rendered), each at most 1024² logits; more than 64 clicks are never sent to the model. - Accuracy: `crates/segment/tests/reference.rs` compares every stage against the Python diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 8ef8789d8..86deac24d 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -60,6 +60,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); the result is committed through `Session::execute_fn` (undo step, save, XMP auto-write), and dropped for a photo a later-started reload has already updated; `wait: true` runs it in place | | Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..7a1235ab3 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,11 +1,10 @@ -# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Epson ERF starting look +# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF, Epson ERF and Minolta MRW starting look -ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and ERF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF, ERF and MRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. Any tone curve (full, partial or black-and-white look) also needs some contrast: the scene luminance of the brightest 1/32 of the pairs must be at least 1.2× that of the darkest 1/32 (`MIN_TONE_RANGE`); below that, the range check refuses the curve, which leaves only nearly uniform frames. A curve under 1.5× (the limit until issue #631) must also render the frame's bright areas, which the fit and the held-out gates leave out (camera JPEG luminance 0.85 and up), no further from the camera JPEG than the neutral fallback does (in luminance for a black-and-white JPEG): fitted to a narrow range, it extrapolates them. A hazy ILCE-7RM4 burst at 1.46 to 1.49 opened with the neutral fallback, 18 L\* darker than its camera JPEG, while the frame at 1.51 got a look at a held-out RMS of 0.013; with 1.2 they all do. Across 135 public Sony files the lowest range is 4.0, so none of them changes. Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). @@ -35,7 +34,9 @@ The wider set needs at least 96 dark samples (JPEG luminance below 0.015), inclu If the original curve cannot be fitted, the wider curve can still be accepted after the sample-count and rank checks. In either case, the completed look must pass its existing held-out gates: a full look needs finite errors, linear-display squared error below 70% of the neutral fallback's and per-channel RMS no greater than 0.10. Partial and monochrome tone-only looks need luminance rank correlation of at least 0.5 and finite squared errors below 70% of the fallback's in both linear and gamma-encoded values. The monochrome comparison uses luminance; the full and partial comparisons use colour channels. Colour-pair selection, colour fitting and the original colour training split are unchanged. -The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 5 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 62 redraws cached renders. +A near-black frame can have fewer than 256 colour pairs, so none of those attempts reaches tone fitting (#1587). After all existing looks fail, a tone-only fallback uses the wider signal floors above. At least 90% of the JPEG proxy must be below luminance 0.015. It excludes clipped sensor pixels from training and refuses invalid samples and JPEG highlights at or above 0.85. Every clipped pixel must stay at least as close to its JPEG luminance as the default curve leaves it. At least 256 usable pairs must show luminance rank correlation of 0.5 or more. Two thirds train a quantile curve; the held-out third must reduce squared luminance error by 30% in both linear and gamma-encoded values. This fallback keeps the source's spectral or neutral colour and learns no matrix, hue/saturation table or chroma curve. Earlier accepted looks keep their result. + +The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 12 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 69 redraws cached renders. No process-version bump is needed under the [rule for changes to source data](process-versions.md#the-rule). The decoded source carries the fitted camera tone in `SourceInfo`; both V1 and V2 use it in `finish::base_tone`. Existing raws edited under V1 or V2 can therefore change when decoded again. Offline smart previews keep their stored pixels and tone until rebuilt from the original. The scan on library opening runs once per look version for each smart previews folder. If an original was offline at that scan, run Build Smart Previews after reconnecting it. @@ -51,6 +52,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho **Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them. +Canon CR2s list their presets by name in the maker note's `ColorData` (`0x4001`), each with Canon's colour temperature for it: for versions -4 and 13–15 the curve is built the same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows (sorted by temperature; Fluorescent, a green light off the locus, and the Kelvin row, which holds the dial's last setting, are left out). Only the PowerShot G1 X Mark III is enabled, checked on a reporter's nine photos, one per white-balance setting: the as-shot levels read 4160 / 4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater, Kelvin 5500) where the camera's own `ColorTempAsShot` says 4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those photos were shot with the white-balance shift 8 steps (of 9) towards blue (`WBShiftAB` −8), which is why a Daylight shot reads about 4600 K and not the Daylight preset's 5651 K: the levels the camera applied are bluer than the preset's, and the camera's own number agrees. + What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. @@ -109,6 +112,21 @@ On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots pl A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists. +## DNG previews with embedded lens corrections + +A DNG with its own colour model and no non-identity profile tone curve uses the existing +`camera_preview::fit_dng_tone` path, including its eligibility and acceptance checks. There is +one preview fit. Files with a look table, a gain table map or an unusable preview keep their +existing behaviour. + +When the file carries lens opcodes, that fit develops its sensor proxy through those opcodes +before reducing it: its camera preview already contains their distortion and vignetting +corrections. This uses the raw decoder's existing opcode implementation, including chained +warps. The separate proxy vignetting multiplier is skipped in this case. The editable source +still has lens corrections removed, so the pipeline applies them once through Enable Profile +Corrections. Developing this proxy requires a full-resolution demosaic; files without such +opcodes keep their binned proxy. + ## Camera profiles (pooled per model) One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. @@ -173,7 +191,11 @@ For 52 camera models LightCraft has colour matrices fitted to the camera's measu (`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from [rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for -the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits, +the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100), and the bodies `SAME_SENSOR` +in `spectral.rs` adds by hand because the table is generated: Fujifilm's 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II, +X-Pro3, X100V, X-S10, X-E4) take the X-T3's matrices. Without them an X-T30 raw whose camera JPEG was shot in Acros +(black and white) got its tone curve on the muted neutral fallback, with a grey sky where the scene had a deep blue +one. Per model the generator fits, under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and `ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the @@ -187,7 +209,7 @@ A raw starts from the first of these that is available (`camera_preview::startin 4. the model's spectral matrices, with LightCraft's default tone curve and no JPEG fit; 5. the neutral fallback. -When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). +When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). If every existing JPEG fit fails, a near-black frame may take the guarded tone-only fallback described above, after its spectral or neutral colour has been chosen. The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 31ecbf8c2..dd41bcdb8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,6 +17,18 @@ callbacks, so the app's loop is paused. Keep the window on screen while you driv headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started (a long command blocks frames) still gets the full 60 s. +Connections close after five minutes without incoming bytes while the server is waiting for +input, including in the middle of an unfinished request line. This allows pauses between +automation commands while releasing abandoned connection slots. An idle read timeout gets an +`ok: false` error saying the connection is closing; incomplete input is never dispatched. +This is a socket read inactivity limit, not a total connection lifetime: a peer that keeps +sending bytes can keep it open. Waiting for an already-dispatched command's reply does not +count as idle reading and retains the request timeout above. + +Reconnect before sending another request after an idle close. MCP connect mode detects closed +connections before sending and reconnects; if a connection is lost after sending a request, +its outcome remains unknown and the client must not blindly replay it. + **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without `method`, invalid UTF-8, or a line longer than 4 MiB — gets one error reply @@ -76,7 +88,7 @@ filmstrip is visible. | `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms | | `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` | | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | -| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | +| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) | | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) | @@ -281,6 +293,22 @@ one pixel. (clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets` (e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build). +## Import album picker + +In the Import Photos review, `albumPicker:import-album` opens the destination picker. +`albumPickerFolder:<folder-id>:import-album` expands or folds a folder; it never chooses a destination. +`albumPickerItem:<album-id>:import-album` selects a regular album. The picker follows sidebar order, +opens the current choice's ancestors and shows its full path on the closed button. Smart albums +are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths; +↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open. +If the chosen album is deleted while the review is open, the destination returns to None before +confirmation instead of submitting the deleted ID. + +`importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps +or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears +the pending name. `ui.dialog.confirm` starts the same import into the selected album ID; engine +`library.import` parameters `album` and `albumName` are unchanged. + ## Panel docking Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. diff --git a/docs/cr3.md b/docs/cr3.md index 79babaea4..b133dfe6a 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -127,6 +127,18 @@ tiles. All ten sensor comparisons pass. R8 HEVC preview tracks are distinguished the primary Bayer RAW; the HEVC preview itself is not decoded. Missing corpus files are reported as skipped by the tests. +In a tile with a neighbour on its right, the vertical high-pass bands of the level-1 and level-2 +low-pass columns (bands 8 and 5) are entropy coded at an odd width: an even band carries one extra +column that synthesis does not use. No other band is extended, including the last level's (band 2). +The decoder takes each band's coded width from this rule. It used to try the synthesis width and up to +two more columns and keep the first that decoded to the exact end of the stream, but two widths can +both do that and give different coefficients (#899). The rule was measured on every C-RAW plane in +`cr3_corpus.rs`: two-tile files at tile widths 2816, 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, +R100, 250D, RP, R, 90D, M6 Mark II), whose left tiles extend bands 5 and 8 exactly where the rule says, +and single-tile `0x100` and `0x200` files, which never extend a band. In every plane, each band decodes +at its coded width and at no other width up to two columns wider, so all sensor hashes are unchanged. +No public file has three or more C-RAW tiles; a middle tile follows the same rule, which is unverified. + The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition diff --git a/docs/denoise.md b/docs/denoise.md index 926b1bee7..7c3f5138f 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -77,7 +77,7 @@ pool. A scalar implementation checks the optimized operators on synthetic networ Tract is not a workspace dependency or a product feature. CPU inference and the GPU model module are feature-gated; ordinary engine/web builds do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`; -lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most +lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 110 megapixels; at most four tile calls run together, chosen within the process-wide working-memory budget. One idle CPU workspace is retained, and cache costs reserve room for the blended picture. diff --git a/docs/fuji-lens-corrections.md b/docs/fuji-lens-corrections.md index ca25e53a2..365c98e98 100644 --- a/docs/fuji-lens-corrections.md +++ b/docs/fuji-lens-corrections.md @@ -6,9 +6,10 @@ LightCraft reads the distortion and vignetting tables that Fujifilm cameras stor path, EXIF orientation handling and DNG export use them like any other embedded correction. Corrected: **distortion** (with the camera's frame-filling zoom) and **vignetting**, for the 59 bodies listed in -`crates/raw/src/vendor/raf_lens.rs` (`VALIDATED`). **Not corrected: lateral chromatic aberration** (the third table, -`0xf00f`); see "Chromatic aberration" below. Bodies without a checked sample, and tables in a layout or normalisation -that was not understood, stay uncorrected. +`crates/raw/src/vendor/raf_lens.rs` (`VALIDATED`); the nine-knot bodies among them other than the X-T4 were checked +with an earlier reading of their tables than the one they now get (see "Knot radius"). **Not corrected: lateral +chromatic aberration** (the third table, `0xf00f`); see "Chromatic aberration" below. Bodies without a checked sample, +and tables in a layout or normalisation that was not understood, stay uncorrected. ## Clean-room evidence @@ -29,10 +30,12 @@ Every table is an array of signed rationals with `n` knots: - `scale × n` is the half diagonal of the recorded image (the RAF crop) in pixels: 3749 for 6240×4160, 4643 for 7728×5152, 2942 for 4896×3264, 7280 for 11648×8736, on 61 of the 65 bodies. Files that store another number (X-S20, X-T30 III and X-M5 write the 40 MP value, 4643; an X-T5 in an in-camera crop) are left uncorrected. -- Knots are positions along the radius. The **last knot is the farthest corner**: `ρ = knot_last · r`, `r` the - distance from the centre in half diagonals. Older bodies use 11 knots `0, 0.1 … 1`, newer ones 9 knots evenly - spaced in `r²` (`√(k/8)`, last `√(9/8) = 1.0607`), GFX 50 bodies 8 (`√(k/8)`, last 1). The distortion zero and the 100 % - illumination at the axis are anchored at `ρ = 0` when the first knot lies above it. +- Knots are **radii in half diagonals**: the farthest corner is at 1, and a table is read at `ρ = r`, the distance + from the centre in half diagonals. Older bodies use 11 knots `0, 0.1 … 1`, newer ones 9 knots evenly spaced in `r²` + (`√(k/8)`), GFX 50 bodies 8 (`√(k/8)`, last 1). In the nine-knot tables the eighth knot is the corner and the ninth, + `√(9/8) = 1.0607`, lies beyond it. In all 1,140 tables of 570 X-T4 raws, taken with three lenses at every focal + length they were shot at, the ninth repeats the eighth's value. The distortion zero and the 100 % illumination at the axis + are anchored at `ρ = 0` when the first knot lies above it. - Distortion values are percent: the bracket `1 + D/100` multiplies the radius. - Vignetting values are percent of the axial illumination at the (source) radius. - The chromatic-aberration table has the layout `[scale, knots, red × n, blue × n, scale]` (fractions of the radius, @@ -43,8 +46,8 @@ Every table is an array of signed rationals with `n` knots: The camera's JPEG samples the raw at ``` -source = z · r · (1 + D(ρ(source)) / 100) ρ = knot_last · source -z = 1 / max over the output border radii of (1 + D(ρ(source)) / 100) +source = z · r · (1 + D(source) / 100) +z = 1 / max over the output border radii of (1 + D(source) / 100) ``` The table is indexed by the **source** (sensor) radius, not the output radius, and the zoom `z` keeps the output @@ -54,11 +57,16 @@ radius is solved per radius by fixed-point iteration, then `source(r) = r (k0 + tables 0.0007, median 0.00012) or folds is rejected. The correction itself is the warp's bracket; the camera's rounding and interpolation between knots are not reproduced beyond linear interpolation between knots. -Vignetting is the gain `100 / V(ρ(r))` at the source radius, fitted by a 5-term minimax polynomial in `r²` +Vignetting is the gain `100 / V(r)` at the source radius, fitted by a 5-term minimax polynomial in `r²` (`FixVignetteRadial`); a fit more than 0.025 off the table is rejected. ## Validation +The tables were first read with the last knot at the corner (`ρ = knot_last · r`). For the 11-knot and GFX 50 tables, +whose last knot is 1, that is the same reading. For the nine-knot tables it reads every value from 6 % further out; +"Knot radius" below has the evidence that changed it. The figures in the rest of this section were measured with the +earlier reading. + Method: LightCraft's uncorrected render of each RAF (1200 px long edge) against the camera JPEG (`camera-jpeg/`, resized to the same size). Per file the high-passed luminance is correlated (NCC) after a warp with a free global scale and shift; the warp is (a) similarity only, (b) the table with its sign flipped, (c) the table, lookup at the output @@ -104,6 +112,35 @@ tables (X-M5, X-T2, X-T3) give a gain of zero in the JPEG. Through the shipped c β at least halves on 30 of them. Seven combinations show only a fraction of the amplitude (S1, SL1000, X-A2, X-A3, X-S1, X-T1, X-Pro3); the camera setting that governs them is not recorded. +### Knot radius + +On the X-T4, the nine-knot tables read at the plain radius match the camera and the earlier reading does not: + +- **Geometry, against the free fit measured above** (`matches_the_measured_x_t4_geometry`, pixls 3913, XF16-55 at 16 mm): + the camera's scale is 1.0300. Read at the plain radius the table gives a zoom of 1.0298 and stays within 0.0008 half + diagonals of the fit at every radius; the earlier reading gave 1.0333 and strayed up to 0.0034 +- **Geometry and gain, against synthetic charts the camera converted.** Copies of the same raw with patches of known + colour, converted by the camera in its USB raw conversion mode. Against the camera's geometry from 445 chart markers, + the distortion table read at the plain radius is at most 1.2 pixels off at 3120 × 2080 out to 0.92 of the half + diagonal (earlier reading 4.2), and against its corner gain from 46,818 flat windows the vignetting table is within + 1.2 % out to 0.9 of the half diagonal (earlier reading up to 6.5 % too bright, 5.9 % at 0.8) +- **On photographs**, 500 of the contributor's own X-T4 raws (XF16-55, XF70-300 and a 13 mm lens), shown only as + aggregates. With the earlier reading the table bends inside the frame, where the ninth knot's repeated value begins + (`1 / 1.0607 = 0.943` of the half diagonal), and on 253 of the 425 zoom-lens raws the warp polynomial failed its fit + checks, so they got no distortion correction at all. Read at the plain radius the bend is at the corner and 424 of + the 425 get their warp. The 75 raws from the 13 mm lens record an all-zero distortion table, so they have nothing to + correct either way. Against the camera's own conversion of each raw (LightCraft's render at 1,200 pixels, compared + at 780 × 520), the correlation of the high-passed luminance went from a median of 0.03 to 0.90 on the XF16-55 (327 + raws) and from 0.02 to 0.83 on the XF70-300 (91), and stayed at 0.96 on the 13 mm. Over flat areas, LightCraft's + corners less its centre, against the camera's, went from +0.56 to +0.21 L* on the XF16-55 and from +0.49 to +0.21 on + the XF70-300. A further 292 raws held back from all of this gave the same: correlation 0.06 to 0.91 and 0.21 to + 0.85, corners +0.62 to +0.26 and +0.35 to +0.14 L* + +The other bodies with nine-knot tables are read at the plain radius too, because their knots have the same layout +(`√(k/8)`, the eighth at the corner), but none of them was measured at it: their place in `VALIDATED` rests on the +figures under "Validation" above, taken with the earlier reading, and is unconfirmed for the plain radius until a camera +JPEG from each is checked. Render cache version 66 redraws thumbnails rendered with the earlier reading. + ### Chromatic aberration (not applied) The CA table is real: on the raw mosaics themselves (uncompressed RAFs, colour planes built by normalised convolution so diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..de7dec822 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (#2052: Thai export glyphs added; text-layout limits remain; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -15,35 +15,35 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices (plus same-sensor bodies, e.g. the X-T30 through the X-T3); others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | | 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | -| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | -| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | +| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-18) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) | +| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping; exports add Thai glyphs only (#2052), with horizontal mark positioning still incomplete and Arabic fallback deferred until joining and bidi exist | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | -| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | +| 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: ⌘L (Enable Filters), Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. + - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export. - 2026-10-11: Sony ILCA-99M2 profile. @@ -77,6 +79,15 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | #2052: Thai glyphs added to shared export text; gap 21 retains Thai mark positioning, Arabic joining and bidi limits. No ranked gap closes; estimates unchanged | +| 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | +| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | +| 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | +| 2026-10-19 | minor | Gap 27: the released 0.9.1 x64 MSI is tested on Windows 11 Pro 24H2 (a fresh install and an upgrade from 0.8.0 end on the completion page, so #790 is fixed; the upgrade keeps a custom folder; Launch starts the app; removal leaves nothing behind); uninstalling from Settings → Apps still ends with no confirmation (#892, #931); hours unchanged | +| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | +| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | +| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | +| 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b74af85a2..b580c243a 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -43,6 +43,8 @@ Overrides (read once at launch): - else `WGPU_BACKEND` (wgpu's own variable, same names) — before #136 only the window honoured it, the compute device always used Vulkan + DX12 (+ Metal). - `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected). +- Both `LIGHTCRAFT_GPU=0` and `LIGHTCRAFT_GPU_BACKEND=off` also keep AI Denoise, the face models + and the AI mask model (SAM 3 on Metal) on the CPU. **DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's @@ -55,6 +57,13 @@ chooses; with `dxc`, put `dxcompiler.dll` *and* `dxil.dll` (DXC ≥ 1.8.2502) ne FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for the GPU test suite on an RTX 4080), off the startup path. +FXC merges stores to neighbouring elements of a storage array into one wider store. NVIDIA's DX12 driver ran +the 16-byte store it made of a denoise convolution's output wrong on a GTX 1070 (every second pixel's channels +shifted; DXC and Vulkan were right), so `nn_conv.wgsl` steps those stores by `1 + zero` from its uniform and FXC +leaves them apart. The transposed convolution's merged store ran right on that card, but it steps the same way. +`nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them` catches the fault on such a card; +`a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them` checks the other store exactly. + **Off the startup path.** The compute device is created on a background thread once the window is up (`gpu::warm_up` from the first frame's settings), and never while GPU rendering is off: with Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens), diff --git a/docs/lens-profiles.md b/docs/lens-profiles.md index 1d4127d47..13d1b2b7a 100644 --- a/docs/lens-profiles.md +++ b/docs/lens-profiles.md @@ -21,6 +21,15 @@ length is used when the camera isn't listed) and the lens model string. Lens nam number (focal lengths, apertures) in the database name must appear in the EXIF name, and the lens maker must agree when the file names one. +Some cameras record no lens model at all, only the lens specification: Nikon DSLRs write the lens's focal range and +maximum apertures in their maker note (tag `0x0084`) and no Exif `LensModel`, whatever lens is mounted. For such a +file the lens is looked up by a name built from the specification, `24-70mm f/2.8` (`18-55mm f/3.5-5.6`, +`85mm f/1.8`), and a profile is used only when one of its names is exactly that (ignoring case and punctuation). +The specification fits every maker's lens with that range (a Nikkor, a Sigma, a Tamron), so neither a partial match +nor a name with a maker in it ("Samyang 85mm f/1.4" for "85mm f/1.4") is taken; a database that should correct such +files names its profile that way, e.g. a lensfun entry with an extra `<model>24-70mm f/2.8</model>` for the one +24-70 f/2.8 the photographer owns. lensfun's own database has no such names, so it doesn't correct these files. + | Correction | Models | Interpolated over | |---|---|---| | Distortion | `poly3`, `poly5`, `ptlens` | focal length | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 73322b2f0..211b00b76 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -16,6 +16,7 @@ Select photos in Photo Grid or Square Grid, then use: | `` ` `` (backtick) | Cycle the flag: unflagged → pick → reject → unflagged | | `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | +| `\` (backslash) | Show / hide the Filter Bar | Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. @@ -39,6 +40,11 @@ step. AutoWrite uses the normal rating metadata path. In Detail, including Maski remapping and disabling the default brush bindings take precedence over their grid bracket actions. The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts. +`\` shows and hides the Filter Bar in Photo Grid and Square Grid, as in Lightroom Classic's Library; in +Detail and in the full-screen preview (`F`) it keeps showing the original (Develop's Before / After key). +View → Filter Bar (`Shift+F`) works in every view. A Show Original moved to another key keeps its meaning +in the grids, and `\` given to another command runs that command. + Flag cycling and raise/lower use each photo's own flag, so a mixed selection steps together without being made uniform. Adobe's shortcut list names the backtick "Cycle flag settings" but does not state the order; unflagged → pick → reject is LightCraft's choice. The key is the logical grave key, so it follows the keyboard layout (on layouts where it is a dead accent key, assign another key in Help → Keyboard Shortcuts to `photo.cycleFlag`). `Home` and `End` make the first or last photo of the view the active, selected one. `Shift` + `←` / diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 5ad4383fd..441becd15 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -28,6 +28,11 @@ settings are imported. Missing originals remain catalogued for relinking, count separately from failures reading files that are present. Known-missing originals are not probed again. Previously browsed Local records join All Photos even when their originals are offline; their promotion is counted once and undone together with the catalog import. +An image whose file, folder or root folder record is gone from the catalog, or whose original path +is empty (Lightroom leaves these behind and ignores them), is skipped together with its virtual +copies instead of failing the whole import. The result lists them as data (`skipped`: each +image id with its reason, `missingFile`, `missingFolder`, `missingRoot`, `incompletePath` or +`masterSkipped`), also in `library.inspectLightroom`, and the desktop result window says how many in the UI language. Virtual copies count as added records too; reimport reports zero additions when all records already exist. Existing LightCraft edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import @@ -44,11 +49,16 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Each native `.lrcat` database and WAL file is independently limited to 1 GiB -(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and -photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows. -The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format -specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. +Native import reads referenced database pages on demand (at most 64 KiB per page), rather than +loading the whole `.lrcat`. Database files are capped at 16 GiB and 16,777,216 pages; the WAL +remains capped at 1 GiB. Tables still have a 256 MiB encoded-payload budget and one million rows, +individual records are capped at 64 MiB, and XMP packets at 16 MiB. These limits can still reject +large catalogs with dense tables; file size alone no longer determines the memory allocation. +The open file and source path are checked again after all tables are read. Close Lightroom before +importing: metadata checks detect ordinary concurrent changes, but are not SQLite snapshot isolation. + +The in-tree, read-only Rust SQLite B-tree reader follows SQLite's public file-format +specification; it introduces no dependency or C runtime. Optional history, keyword and collection tables that exceed limits are skipped with warnings; required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional. @@ -97,3 +107,16 @@ the path relative to the catalog that Lightroom records for root folders on the (`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying a project folder with its catalog inside it therefore keeps its originals linked. Other moved originals stay missing; use LightCraft's missing-photo relinking tools to update their paths. + +Original and root folder paths are resolved with the catalog as the document and are looked at +only when they are on this computer or on the network share the catalog itself is on +(`crates/engine/src/doc_path.rs`). An original on another computer (`\\server\share\…`, +`//server/…`, `file://server/…`), a device path, or one whose folder or file name in the catalog is +an absolute path is not looked at: the import lists it as missing with a warning and leaves the +photo and its virtual copies out of the library. To import originals on a share, open the catalog +from that share. A path that is not a path on this computer (a Windows drive path read on macOS or +Linux) is not looked at either and stays catalogued as missing. + +## Historical local validation + +Validated in a local Linux environment after upstream reconciliation: 47 catalog-focused tests, nine pipeline spot tests, the exact sparse-catalog regression, and all eight `cargo xtask ci` stages passed. The full gate recorded 3,166 passing test executions, zero failures, and 28 ignored tests. An original synthetic sparse catalog with a required image-table page beyond 1 GiB also passed headless application import and selection; its expected missing-original placeholder retained its five-star rating. This snapshot check does not establish native-window coverage or dense-catalog performance. diff --git a/docs/localization-cs.md b/docs/localization-cs.md index 49a4d9859..52fad4542 100644 --- a/docs/localization-cs.md +++ b/docs/localization-cs.md @@ -27,11 +27,13 @@ revizí rozsahu a terminologie. Rodilý mluvčí ani profesionální překladate plurálové pravidlo se nepřidává. - Písmo Inter dodávané s aplikací obsahuje české znaky i uvozovky „…“. Další font není potřeba. -Katalog obsahuje 2 056 běžných položek a 246 formátových šablon. Každý běžný klíč existuje -v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 108 dalších klíčů, +Katalog obsahuje 2 394 běžných položek a 297 formátových šablon. Každý běžný klíč existuje +v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 215 dalších klíčů, všechny s doloženým použitím: popisky příkazů, menu, ovládacích prvků a nastavení, -texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 41 klíčů již existuje -v dalších katalozích a 67 doplňuje současné překladové lookupy. +texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 66 klíčů již existuje +v dalších katalozích a 149 doplňuje současné překladové lookupy. Doplnění z 18. 10. 2026 +přidalo texty novějších funkcí (prezentace, tisk, přednastavení efektů masek, zaostřovací +body) a prošlo stejnou kontrolou. Řádkové fragmenty poznámek k vydáním byly odstraněny, včetně 163 fragmentů převzatých ze sdílených katalogů; text poznámek v dialogu Co je nového používá anglický fallback. diff --git a/docs/localization-fi.md b/docs/localization-fi.md new file mode 100644 index 000000000..d80a2e1cc --- /dev/null +++ b/docs/localization-fi.md @@ -0,0 +1,62 @@ +# Suomi + +Suomen (`fi`, englanniksi **Finnish**, omakielinen nimi **Suomi**) saa käyttöön valitsemalla +**Muokkaa → Kieli → Suomi** tai **Asetukset → Yleiset → Kieli**. Muutos tulee voimaan heti ja +tallentuu seuraavaa käynnistystä varten. Komento on `app.language.finnish`; ohjauskanava +hyväksyy `ui.set {"language":"fi"}` sekä tunnisteet `fi-FI` ja `fi_FI.UTF-8`. Ikkunattomassa +ajossa voi käyttää `LIGHTCRAFT_LANGUAGE=fi`. + +Luettelot `crates/ui-egui/locales/fi.json` ja `fi-formats.json` on laadittu tekoälyavusteisesti +(Claude) englanninkielisten lähdemerkkijonojen merkityksestä, kokonaisina fraaseina. Avainlista +on kaikkien muiden luetteloiden yhdiste. Suomenkielinen äidinkielinen puhuja (@roopeg) tarkistaa +käännöksen; ammattikääntäjä ei ole sitä käynyt läpi. + +## Käytännöt ja laajuus + +- Valikot: Tiedosto, Muokkaa, Näytä, Kuva, Ikkuna, Ohje. +- Library = Kirjasto, Develop = Kehitys, Collection = Kokoelma, Album = Albumi, + Preset = Esiasetus, Catalog = Katalogi (Lightroom-katalogi; keyword and model lists stay + ”luettelo”), Snapshot = Tilannevedos, Version = Versio, Print (module) = Tulostus, the + command Print… = Tulosta…. +- Exposure = Valotus, Contrast = Kontrasti, Clarity = Selkeys, Texture = Tekstuuri, + Dehaze = Sumunpoisto, Vibrance = Eloisuus, Saturation = Värikylläisyys, Hue = Sävy, + Tint = Värisävy, Temperature = Lämpötila, White Balance = Valkotasapaino. +- Highlights = Huippuvalot, Shadows = Varjot, Whites = Valkoiset, Blacks = Mustat. +- Sharpening = Terävöinti, Noise Reduction = Kohinanpoisto, Grain = Rakeisuus, + Vignette = Vinjetti, Color Grading = Väriluokittelu, Tone Curve = Sävykäyrä, + Defringe = Värivirheen korjaus, Feather = Häivytys. +- Crop = Rajaus, Straighten = Suorista, Mask = Maski, Brush = Sivellin, + Radial = Säteittäinen, Linear Gradient = Lineaarinen liukuväri, + Soft Proofing = Vedoksen esikatselu. +- Spot removal: Spot = Täplä, Heal = Paranna, Clone = Kloonaa. Point Color = Pistevärit and + Point Curve = Pistekäyrä keep ”piste”. +- Flag = Lippu, Pick = Valitse (picked photo = Valittu kuva), Reject = Hylätty, Rating = Luokitus (Star rating = + Tähtiluokitus), Label = Merkintä, Color Label = Värimerkintä, Keyword = Avainsana, + Smart Album = Älyalbumi, Filmstrip = Filminauha, Virtual Copy = Virtuaalikopio, + Stack = Pino, Template = Mallipohja (AI Model = Malli), Detection = Havaitseminen, Recently Deleted = Äskettäin poistetut, My Photos = Omat kuvat. +- Päivämäärä on muodossa `20.9.2026` (ilman etunollia), viikonpäivä alkaa pienellä kirjaimella + (`sunnuntai 20.9.2026`), kuukausi–vuosi on `9/2026` ja kellonaika 24-tuntinen `16:04:05`. +- Yksiköt: `kt`, `Mt`, `Gt`; prosentin edessä on väli (`50 %`). +- Määrät kirjoitetaan sanan jälkeen (”Tuotuja kuvia: 22”, ”Odottavia kuvia: 2”), koska + suomessa lukusanan jälkeinen substantiivi on yksikön partitiivissa (1 kuva, 2 kuvaa), eikä + englannin monikkopääte `s` voi valita muotoa. Englannin pääte kulutetaan `{:.0}`-merkinnällä. + Kun lause taipuu yksikössä kaikille luvuille (”{n} kuvalta”, ”{n} kuvassa”), luku voi olla + lauseen sisällä. +- Mukana toimitettu Inter-fontti sisältää ä, ö ja å sekä lainausmerkit ” ”. Lisäfonttia ei + tarvita. + +Mitä uutta -ikkunan versiotiedotteet jäävät englanniksi, kuten useimmissa luetteloissa. Alemman +tason tekniset virheilmoitukset ja osa käännöskerroksen ulkopuolisista viesteistä jäävät +englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähdemerkkijonoja. + +## Testaus + +`i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta, +kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja +suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa. + +Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading), +”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing), +”Älyalbumi”, ”Pino” (Stack), ”Pistevärit” (Point Color), ”Tekoälykohinanpoisto” sekä +luovien esiasetusten nimet. Kielen lisäämisen ohje on tiedostossa +[localization.md](localization.md). diff --git a/docs/localization-id.md b/docs/localization-id.md new file mode 100644 index 000000000..13fc1c7ee --- /dev/null +++ b/docs/localization-id.md @@ -0,0 +1,63 @@ +# LightCraft in Indonesian + +Choose **Edit → Language → Bahasa Indonesia** or **Settings → General → Language**. The choice applies +at once and is saved for the next launch (`language: "id"` in `ui.json`); the control command is +`app.language.indonesian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `id`, `id-ID` +and `id_ID.UTF-8`; the saved value is always `id`. LightCraft does not follow the system language, for +Indonesian as for every other language. + +The Indonesian catalogs cover every plain and formats message the other catalogs have (2,258 + 297), +plus 69 command labels no catalog translated yet: menus, shortcuts, library, editing, crop, masks, +people, import/export, print, slideshow, settings, built-in presets and profiles, history, date +headings, status messages and the smart-album rule editor. The embedded release notes (What's New) +stay in English. Technical errors from lower layers stay in English, as in the other languages. +The catalogs were written with an agent from the meaning of the English labels and reviewed for +natural, standard app Indonesian; 356 labels reuse the Indonesian wording of the sibling apps +(PhotoCraft, VectorCraft, FilmCraft, FigCraft) so the suite reads the same. A native speaker's pass +is welcome. + +File names, command ids, template placeholders and albums, presets or metadata you named yourself are +not translated. + +## Style + +- Sentence case everywhere, as in the English; names, brands and abbreviations keep their capitals. +- Buttons and menu commands use the bare verb (Simpan, Hapus, Ekspor, Pangkas); progress lines use + me- with an ellipsis (Mengimpor…); finished-state toasts put the subject first (`{n} foto diekspor`). +- The user is addressed as *Anda*, only where a sentence needs a subject; never *kamu*. +- Indonesian keeps a noun unchanged after a count (`3 foto`), so English plural endings are dropped + with `{:.0}`. +- Dates read `Minggu, 20/09/2026` (`{weekday}, {day:02}/{month:02}/{year}`), months `09/2026`. +- Delete is **Hapus**; remove from a container (album, stack, favorites) is **Keluarkan dari …**. + Select is **Pilih**; the Pick flag is **Pilihan**. +- `▸` and `▾` are written `→` and `⌄`, as in the other catalogs. + +## Terminology + +| Source | Indonesian | +|---|---| +| Develop | Olah | +| File / Edit / View | File / Edit / Tampilan | +| Settings / General / Language | Pengaturan / Umum / Bahasa | +| Import / Export | Impor / Ekspor | +| Photos / Albums / Folders | Foto / Album / Folder | +| Smart Album | Album pintar | +| Crop | Pangkas | +| Masks / Masking | Masker | +| Exposure / Contrast | Eksposur / Kontras | +| Highlights / Shadows / Whites / Blacks | Sorotan / Bayangan / Putih / Hitam | +| Temperature / Tint | Suhu / Rona | +| Vibrance / Saturation | Vibrance / Saturasi | +| Clarity / Texture / Dehaze | Kejernihan / Tekstur / Hilangkan kabut | +| Sharpening / Noise Reduction | Penajaman / Pengurangan noise | +| Vignette / Grain | Vinyet / Butiran | +| White Balance / Tone Curve | Keseimbangan putih / Kurva tone | +| Lens Corrections | Koreksi lensa | +| Presets / Profile | Preset / Profil | +| Keywords / Rating | Kata kunci / Rating | +| Flag / Pick / Reject | Tanda / Pilihan / Tolak | +| Stack / Virtual Copy | Tumpukan / Salinan virtual | +| History / Recently Deleted | Riwayat / Baru dihapus | +| Compare / Before / After | Bandingkan / Sebelum / Sesudah | +| Slideshow / Print / Template | Tayangan slide / Cetak / Templat | +| People | Orang | diff --git a/docs/localization-ka.md b/docs/localization-ka.md index 234ab5ee9..4d517c6ca 100644 --- a/docs/localization-ka.md +++ b/docs/localization-ka.md @@ -23,8 +23,10 @@ interface never uses Mtavruli (a test checks the whole catalog). Commands are ve are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with `{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. -Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes -until a Georgian face is available to the UI. +Inter has no Georgian letters, so the UI draws Georgian with craft-fonts' Noto Sans Georgian (`Geor`), +like the Thai and Arabic faces: desktop release builds embed it, and a web build made with +`CRAFT_FONTS_DIR` keeps it in the WASM (`crates/engine/build.rs`). The release web build doesn't use +craft-fonts yet, and a build without `CRAFT_FONTS_DIR` has no Georgian face and shows boxes. ## Terminology diff --git a/docs/localization-parity.md b/docs/localization-parity.md index b7900d350..41669e4b7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-20 · **Change:** minor (German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` ([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), [pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), -[ru](localization-ru.md), [ka](localization-ka.md)). +[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md)). ## The target @@ -18,12 +18,12 @@ go beyond the target. ## How the numbers are measured -**Measured** (a Python count over `crates/ui-egui/locales/*.json`, 2026-10-10): +**Measured** (a Python count over `crates/ui-egui/locales/*.json`, 2026-10-13): -- The denominator is every message any catalog knows: the union of the plain catalogs' keys (2,074 +- The denominator is every message any catalog knows: the union of the plain catalogs' keys (2,222 English source strings: menus, commands, controls, panels, dialogs, tooltips, presets, What's New - lines) plus the 230 messages of the formats catalogs (`*-formats.json`: text with runtime values and - date patterns), 2,304 messages in all. + lines) plus the 249 messages of the formats catalogs (`*-formats.json`: text with runtime values and + date patterns), 2,471 messages in all. - A message counts as translated when the catalog has a non-empty value that differs from the English key. Some identical values are correct (`ISO`, `OK` in German), so the figure slightly understates coverage; on the other hand, strings that never reach a catalog (technical errors raised in lower @@ -41,6 +41,10 @@ Native-speaker review: no catalog has had a formal review. Several catalogs have contributors whose commits only touch that language (ja, zh-hans, zh-hant, fr, de, ru, uk, pt-br); that is promising but is not recorded as review, so the column says `no (community edits)`. +Form of address: German speaks to the user with *du* ("Wähle ein Foto", "Dein Name"), never *Sie*. +LightCraft is built by its community, and *du* is how people helping each other talk. A new German +string follows that. + ## The twelve key languages | # | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | @@ -52,11 +56,11 @@ that is promising but is not recorded as review, so the column says `no (communi | 5 | Arabic | `ar` | 0 (0%) | none | **RTL layout and Arabic shaping missing** | no | none (target: not shipped) | 60–110 h (RTL mirroring 30–50 h + shaping, shared with Hindi) | | 6 | French | `fr` | 2,315 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | -| 8 | Indonesian | `id` | 0 (0%) | none | Latin (Inter covers it) | no | none (target: not shipped) | 10–16 h | +| 8 | Indonesian | `id` | 2,555 / 2,555 (100%; 2026-10-19: every plain and formats message any catalog has, plus 69 command labels; What's New left in English) | as zh-hans | Latin (Inter covers it) | no (written with an agent; native-speaker pass pending) | partial (target: not shipped) | 2–4 h (native review, lower-layer errors) | | 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | -| 10 | German | `de` | 2,338 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | +| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | -| 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | +| 12 | Vietnamese | `vi` | 2,471 / 2,471 (100% key coverage of current union; intentional English like `ISO`/`OK` kept) | menus, panels, dialogs, presets, profiles, dates, formats; lower-layer errors English | Latin with stacked diacritics (Inter) | no (community contribution) | partial (catalog complete; lower-layer errors English) | 1–3 h | ## Other languages LightCraft ships (4) @@ -65,8 +69,9 @@ that is promising but is not recorded as review, so the column says `no (communi | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | -| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | +| Italian | `it` | 2,628 / 3,278 (80%, measured 2026-10-15; every interface string is translated, the other 650 are What's New lines, left in English) | Latin (Inter) | partly (written with an agent; corrections and the 2026-10-15 additions by a native speaker) | partial (lower-layer errors English) | 3–5 h | | Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | +| Finnish | `fi` | 2,610 / 3,308 (79%, measured 2026-10-17: 2,313 plain + 297 formats; the rest is What's New) | Latin (Inter) | yes (written with an agent; native-speaker review by @roopeg) | partial (lower-layer errors English) | 3–5 h | Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai @@ -93,8 +98,14 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Indonesian interface catalog (`id`): every plain and formats message any catalog has, plus 69 command labels; What's New left in English | +| 2026-10-17 | minor | Finnish interface catalog (`fi`): 2,610 of 3,308 messages (2,313 plain + 297 formats), What's New left in English | +| 2026-10-15 | trivial | Italian (`it`): 339 interface messages and 60 format strings that were missing or still in English translated (2,628 of 3,278; only What's New left in English); native-speaker corrections: Exposure → *Esposizione*, Sharpening → *Nitidezza*, Preset → *Preset*, blurry → *sfocate* | +| 2026-10-13 | minor | Remeasured denominator (2,222 plain + 249 formats = 2,471); Vietnamese catalog complete at 2,471/2,471 (status partial: lower-layer errors still English) | | 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) | | 2026-10-12 | minor | Georgian (`ka`) row: every catalog message | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | | 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | +| 2026-10-16 | minor | German (`de`): 217 interface strings and 862 What's New lines added (agent translations, native review welcome); re-measured 2,572 → 3,644 of 3,754 messages (80% → 97%). The denominator grew because German now translates What's New lines no other catalog has; other rows keep their earlier counts | +| 2026-10-20 | minor | German (`de`) speaks to the user with *du*; the 11 messages that used *Sie* changed, no count changes | diff --git a/docs/localization-vi.md b/docs/localization-vi.md new file mode 100644 index 000000000..2bd6c74e0 --- /dev/null +++ b/docs/localization-vi.md @@ -0,0 +1,76 @@ +# LightCraft bằng tiếng Việt + +Chọn **Chỉnh sửa → Ngôn ngữ → Tiếng Việt** hoặc **Cài đặt → Chung → Ngôn ngữ**. +Lựa chọn áp dụng ngay và được lưu cho lần mở sau (`language: "vi"` trong `ui.json`). + +Các tag locale hệ thống sau cũng chọn tiếng Việt: `vi`, `vi-VN`, `vi_VN`, `vi_VN.UTF-8`. + +## Phạm vi + +Catalog tiếng Việt phủ **toàn bộ** bề mặt localization hiện tại của LightCraft +(đo 2026-10-13: union plain **2 222** + formats **249** = **2 471** thông điệp): + +- `crates/ui-egui/locales/vi.json` — thông điệp plain / nhãn hiển thị (2 222 khóa) +- `crates/ui-egui/locales/vi-formats.json` — thông điệp có giá trị runtime và mẫu ngày (249 khóa) + +Điều này là **100% bề mặt localizable/runtime hiện tại**, không phải “mọi chuỗi tiếng Anh +trong toàn bộ repository”. Lỗi kỹ thuật từ crate tầng dưới vẫn có thể là tiếng Anh, giống +các ngôn ngữ khác. + +## Quy ước thuật ngữ + +| English | Vietnamese | +|---|---| +| File / Folder | Tệp / Thư mục | +| Library / Album / Smart Album | Thư viện / Album / Album thông minh | +| Import / Export | Nhập / Xuất | +| Exposure / Contrast | Phơi sáng / Tương phản | +| Highlights / Shadows / Whites / Blacks | Vùng sáng / Vùng tối / Vùng trắng / Vùng đen | +| White Balance / Temperature / Tint | Cân bằng trắng / Nhiệt độ / Tông | +| Vibrance / Saturation | Độ rực rỡ / Độ bão hòa | +| Texture / Clarity / Dehaze / Grain / Vignette | Kết cấu / Độ rõ / Khử sương mù / Hạt / Tối góc | +| Tone Curve | Đường cong tông màu | +| Sharpening / Noise Reduction / AI Denoise | Làm nét / Giảm nhiễu / Khử nhiễu AI | +| Masking / Brush / Linear Gradient | Mặt nạ / Cọ / Chuyển sắc tuyến tính | +| Preset / Profile / History / Version | Thiết lập sẵn / Hồ sơ / Lịch sử / Phiên bản | +| Appearance (theme) | Giao diện | +| Subject (AI) | Chủ thể | +| Candidate / Select | Ảnh ứng viên / Chọn | +| Flag / Picked / Rejected / Unflagged | Gắn cờ / Đã chọn / Đã loại / Không gắn cờ | +| Shutter Speed | Tốc độ màn trập | +| Smart Previews | Xem trước thông minh | + +Tên sản phẩm và chuẩn giữ nguyên khi là định danh: LightCraft, ArtCraft, GitHub, Discord, +JPEG, PNG, TIFF, WebP, AVIF, HEIF, HEIC, RAW, DNG, RGB, HDR, ICC, EXIF, XMP, GPU, MCP, SAM, ISO, OK. + +Tên album / preset / profile do người dùng tạo, tên tệp, command ID và metadata nhập tay +không được dịch. + +## Ngày và số lượng + +- Ngày đầy đủ: `{weekday}, {day}/{month}/{year}` (ví dụ `Chủ Nhật, 20/9/2026`) +- Tháng/năm: `Tháng {month} năm {year}` +- Thứ viết tắt trên date picker: T2…T7, CN +- Thông điệp đếm dùng dạng trung tính (`22 ảnh`), không tạo số ít/số nhiều nhân tạo như tiếng Anh + +## Font + +Tiếng Việt dùng Inter (Latin + dấu). Không cần craft-fonts CJK. Kiểm tra glyph trong +`vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts`. + +## Kiểm tra + +```sh +cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture +LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000 +LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800 +``` + +Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album, +đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu. + +## Nguồn dịch (clean-room) + +Dịch từ chuỗi tiếng Anh nguồn của LightCraft. PhotoCraft / VectorCraft / PDFCraft chỉ dùng +làm bộ nhớ thuật ngữ khi cùng nguồn tiếng Anh và cùng ngữ cảnh UI. Không dùng gói ngôn ngữ +Adobe Lightroom hay tài nguyên độc quyền. diff --git a/docs/localization.md b/docs/localization.md index 84feb4d8d..94abfe236 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,11 +2,11 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`) and Vietnamese (`vi`). This file is the reference for **adding or maintaining a language**; the per-language notes are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), [`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), [`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md), -[`localization-cs.md`](localization-cs.md) and [`localization-ka.md`](localization-ka.md). +[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md) and [`localization-vi.md`](localization-vi.md). ## Adding a language @@ -34,6 +34,8 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); + Fi, "fi", "Suomi", "Latn", include_str!("../locales/fi.json"); + Vi, "vi", "Tiếng Việt", "Latn", include_str!("../locales/vi.json"); } ``` diff --git a/docs/mcp.md b/docs/mcp.md index 00841bec8..af4c53ca9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,8 +146,11 @@ Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` Ports and connect mode are unchanged. Every listed tool has a title and all four MCP hints. Generated command tools are conservatively -marked as edits. Helpers with optional output paths are marked as writers; `export` is not -idempotent because the default conflict policy chooses a new filename on repeated calls. +marked as edits. `export`, `render_photo` and `screenshot` have `readOnlyHint: false` and +`destructiveHint: true`: their caller-supplied output paths can replace existing files, even +when a path is optional. The image-only `render_preview` and `ui_screenshot` remain read-only +and non-destructive. These annotations are client hints, not a file-access sandbox. +`export` is not idempotent because the default conflict policy chooses a new filename on repeated calls. Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments. Generated `cmd_*` tools and nested command params remain free-form: the registry has prose parameter docs, not machine-readable schemas. Existing command validation (including strict @@ -176,7 +179,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true | +| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation (Lightroom's Dimensions); with `fit: "widthHeight"` they limit the width and the height as the photo stands (Width & Height); `dontEnlarge` defaults to true | `get_develop {id}` and `render_photo {id}` use that photo without changing the selection. `set_develop`, `crop` and `export` with a supplied photo `id` end the current edit interaction, diff --git a/docs/merge.md b/docs/merge.md index e3f3e71ad..ce9c71beb 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -27,7 +27,9 @@ All algorithms are our own implementations from published papers (cited in the m **HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity / -homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured +homography (the simplest model that explains ≥ 97 % of the matches). Frames are resampled with Lanczos-3, held to +the range of their 4 × 4 neighbours so edges don't ring (`lightcraft_raster::sample`); bilinear lost a third of the +finest detail. Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 13c878cd8..f6632968a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (export text offers all bundled non-Latin fallback faces; integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (#2052: Thai export glyph fallback only; horizontal mark positioning and Arabic joining/bidi remain open; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -33,7 +33,7 @@ cited here still exists. Formerly docs/parity.md. | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | -| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | +| H. Crop & rotate (CROP) | 11 | 0 | 0 | 1 | 8/8 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | | K. Masking (MASK) | 15 | 5 | 5 | 0 | 7/8 (88%) | 7/7 (100%) | @@ -50,12 +50,12 @@ cited here still exists. Formerly docs/parity.md. | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | -| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | +| Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | -| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 418 | 62 | 59 | 35 | 194/201 (97%) | 147/161 (91%) | +| Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | +| **Total** | 421 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 539 in-scope rows — P0 98.3% of 201 · P1 94.7% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.5% of 177. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -69,9 +69,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | -| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | @@ -80,11 +78,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | +| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -103,11 +101,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges (a click expands / collapses the stack, selected or not), expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | @@ -125,19 +123,19 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report) | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending) | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | -| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions` | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | @@ -148,7 +146,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown | +| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); plain words also match the people named on faces; double quotes keep several words in one token (`person:"Jane Doe"`, `"jane doe"`, issue #1449); no suggestions dropdown | | LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | | LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | | LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | @@ -157,7 +155,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | -| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | +| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip, `person:` (`person:"Jane Doe"` for a name of several words) or a plain word in Search Photos (part of the name); no People picker in the filter bar and one person at a time (issue #1449); no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; choosing Random or Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | @@ -170,8 +168,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -193,7 +191,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | @@ -214,13 +212,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | @@ -238,7 +236,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG whose profile defines no tone curve (absent or identity `ProfileToneCurve`, with or without a `LookTable`, as Adobe Standard in Lightroom-written DNGs) gets its tone fitted to its embedded preview when that is at least 10% of the raw's long edge and 384 px (Lightroom's 1024 px previews qualify): two Lightroom Classic 15.1 lossy ILCE-7CM2 DNGs went from about 0.8 EV darker and flat to within 3% of Lightroom's mean linear RGB. A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); near-black frames with too few colour pairs can take a guarded tone-only fallback on their existing source colour (#1587); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -249,11 +247,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | +| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests_guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | +| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match | | LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays | @@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -308,7 +308,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests_tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | @@ -319,7 +319,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | +| LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | | LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | @@ -352,10 +352,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | -| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) @@ -390,8 +390,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | -| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | +| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | @@ -399,7 +399,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, then the existing Mincho-first CJK faces, then other bundled non-Latin faces (Arabic and Thai when present in `CRAFT_FONTS_DIR`), shared with contact-sheet captions and slideshow overlays; horizontal bidi reordering and contextual shaping remain unsupported, and Hebrew still needs a bundled face (partial fix for #2052). Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | +| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | @@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | @@ -481,7 +481,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | @@ -515,6 +515,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | | MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | +| MENU-PHOTO-SHOWFOLDERS | Show in Folders | P2 | ✅ | `cmd:app.showInFolders`, `crates/ui-egui/src/panels/left.rs` (`folder_row_of`), tests `show_in_folders_*` in `tests_panels.rs` | Photo ▸ Show in Folders and the photo right-click menu: selects the active photo's folder in the sidebar's Folders section, opens the rows above it and scrolls to it; opens the Library sidebar if it is behind another tab; disabled for demo scenes and photos outside a library folder | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | | MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | | MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step | @@ -668,13 +669,13 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-GENAI | Generative toggles / variations — ⌥⇧G, ⌥←/→ | OOS | 🚫 | | | | KEY-DETECT | Detect objects — ⌥⇧O | P2 | ⬜ | | | | KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ✅ | `cmd:crop.aspect` (`toggle`, `current`) | A while cropping locks the current shape / unlocks | -| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | `cmd:crop.rotateAspect` | | +| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | `cmd:crop.rotateAspect` | in the crop tool X swaps once instead of also running Reject's swap, which put the crop straight back (issue #1866) | | KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation | | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/tests_slider_reset.rs` | double-click a slider's name or thumb: back to its default as one undo step (a click on the thumb no longer moves it first, which made it two); resting the pointer on either says so (“Exposure — double-click to reset it”, as the curve channels), the value keeps “Click to type a value” (issue #1436); a disabled slider's name doesn't reset it. No per-slider reset button (Lightroom has none); no ⌥ panel headings turning into Reset buttons yet (`cmd:develop.resetSection` is command-only) | -| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`) | -| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`). The vertical wheel nudges only a slider a click or drag selected, while the pointer stays in its row: leaving the row (even within one frame) disarms it until the next click or drag, and Tab focus arms only the arrow keys (issue #1355, `crates/ui-egui/src/widgets/tests_numeric_entry.rs`) | +| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`, `a_long_click_keeps_the_field_open`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts; the field stays open however long the click is held (a release over the value keeps it, issue #1435) | | KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | @@ -694,15 +695,15 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) | -| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side, a ⌘/Ctrl- or Shift-click changes the selection without touching the pair; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) | +| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side, a ⌘/Ctrl- or Shift-click changes the selection without touching the pair; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE); the Info panel's stars and colour labels (and the Set Color Label menu) act on the active photo only, and each pane's caption shows its colour label (#2398) | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | -| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | +| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does. Escape → Detail without losing edits; pins are catalog-local; missing/deleted photos refused and deleted references no longer drawn (Windows regression/native smoke verified) | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | @@ -729,7 +730,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | -| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | @@ -737,7 +738,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | | LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) | | LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | | -| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); no online provider | +| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); the list holds towns of 15 000 people or more, so a smaller town gets the nearest larger one (the tooltip and command help say so); `maxDistance` leaves City empty past a distance and names the town as `nearestCity` (issue #2242); no online provider | | LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | | | LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | | | | LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | | | @@ -751,7 +752,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-TEMPLATES | Slideshow templates | P2 | 🟡 | `cmd:slideshow.templates.list`, `cmd:slideshow.templates.apply`, `cmd:slideshow.templates.save`, `cmd:slideshow.templates.update`, `cmd:slideshow.templates.rename`, `cmd:slideshow.templates.delete`, `cmd:slideshow.templates.import`, `cmd:slideshow.templates.export`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_template.rs`, `path:crates/ui-egui/src/panels/slide_templates.rs` | Template Browser at the top of Settings ▸ Slideshow: a template is the Playback (not "prepare previews"), Layout / Options / Backdrop, Overlays and Titles settings as one versioned JSON document. Five built-ins of our own (Plain Black, Gallery Wall, Edge to Edge, Cinema Wide, Shooting Data; no Lightroom names or designs, no image or font files) and the user's own, saved as files in `<settings>/slideshow-templates` (machine-wide, like Classic's): save current settings, Update with Current Settings, rename, delete (confirmed), import / export `.json`. Click applies (one step, saved with the library); pointing at one previews it on the selected photo (or the intro / ending) in the live preview without changing anything (`slideshow.preview {template}`). No undo for library preferences: the settings a template replaced stay as "Previous settings" for the session (applying it again goes forward). Every section is read through its settings' own validation (out-of-range values clamped, wrong types or unknown keys refused), files are capped (256 KB, 1000 listed), a newer format version is refused with a message, a bad file is listed as skipped, names are sanitised into file names that can't leave the folder, a missing plate / watermark image or background photo applies with a warning. Missing: the Slideshow module's left-panel browser (it lives in Settings until the module view exists), template folders (New Folder, moving templates between folders), saving a slideshow with a collection, a preview thumbnail inside the browser itself | | LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | -| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts non-Latin fallbacks, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering and horizontal contextual shaping (Arabic draws in logical order with isolated glyphs; Hebrew still has no bundled face) | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK and Thai, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, horizontal Thai mark positioning, Arabic joining and right-to-left reordering (no Arabic export fallback; Arabic / Hebrew remain missing-glyph boxes with the bundled faces) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | @@ -762,7 +763,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | @@ -772,13 +773,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | | LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | | LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | -| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | solo itself (⌥-click a header, Solo Mode) is done: see LR-EDIT-SECTION-SOLO | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | 🟡 | `cmd:view.filmstrip`, `cmd:view.leftPanel`, `cmd:view.rightPanel`, `crates/ui-egui/src/shortcuts.rs` | F6 / F7 / F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid); Tab / ⇧Tab not bound (Tab moves the keyboard focus), no F5 or T; solo itself (⌥-click a header, Solo Mode) is done: see LR-EDIT-SECTION-SOLO | | KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail). Missing: Shift+P outside grids (Presets), ⌘L Enable Filters | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | @@ -797,7 +798,27 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | #2052 (partial): shared export text includes bundled Arabic and Thai fallbacks after the existing Inter and CJK fallback chain; font availability and layout limitations remain explicit; LR-EXP-WATERMARK reassessed as partial because shaping, bidi reordering and unbundled scripts remain unsupported | +| 2026-10-22 | minor | #2052 (partial): shared export text adds only Thai faces after Inter and the existing CJK chain. Thai horizontal mark positioning remains incomplete; Arabic fallback is deferred until joining and bidi reordering exist. LR-EXP-WATERMARK reassessed as partial for these layout and font limits; UI fallback unchanged | +| 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | +| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | +| 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | +| 2026-10-21 | minor | KEYC-PANELS: F8 shows / hides the right panel (`view.rightPanel`), with F6 / F7 already bound; ⬜ → 🟡 | +| 2026-10-20 | minor | LR-PRE-PANEL: the preset applied to a photo is marked again (with its Amount slider) on returning to the photo, until the photo is edited further (#2392); status unchanged (✅) | +| 2026-10-20 | minor | LR-MASK-OVERLAY: creating, picking, duplicating or adding to a mask shows the overlay a mask slider hid (#1800); status unchanged (✅) | +| 2026-10-20 | patch | LRC-LIB-COMPARE, LR-VIEW-SURVEY: in Compare and Survey the Info panel's stars and colour labels and the Set Color Label menu act on the active photo only (they labelled the whole selection), and the panes show the colour label (#2398); statuses unchanged | +| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged | +| 2026-10-19 | patch | LR-CROP-RECT: on a straightened photo a moved crop slides along the tilted edges, so a box that fills the photo's height reaches its sides (#2299); status unchanged | +| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | +| 2026-10-18 | patch | LR-META-XMP: Lightroom's profile and version bookkeeping (`CompatibleVersion`, profile digests, lens profile identity, `CropConstrainToUnitSquare`, default-strength lens profile scales) is no longer reported as unimported XMP; status unchanged (✅) | +| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | +| 2026-10-19 | patch | LR-LIB-STACK: the filmstrip's stack badge expands / collapses the stack like the grid's, and a badge click works on a stack that isn't selected (it is selected first). Statuses and feature counts unchanged. | +| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | +| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | +| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | +| 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS: folder discovery includes SR2/SRF; MCP and CLI reuse the engine extension list. DSC-F828 remains preview-only; decoder support and row status unchanged | +| 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); status unchanged (🟡) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | @@ -814,6 +835,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged | | 2026-10-14 | patch | LR-MASK-BRUSH: ⇧-click with the brush draws a straight stroke from where the last stroke ended, tapering when the brush changed in between (`cmd:mask.brushStroke` `connect`); status unchanged | | 2026-10-14 | minor | LR-VIEW-ZOOM 🟡 → ✅: soft proofing gets zoom windows (mask overlays already had them; row text corrected), 1:1 pixels tested equal to the full-size export, slider-drag latency at 1:1 measured on Linux | +| 2026-10-14 | patch | LR-IMP-LOCAL: browsing a folder from the sidebar records it as browsed, as `library.browse` does, so its untouched photos aren't forgotten while in use; status unchanged (✅) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | @@ -829,12 +851,14 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) | | 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | | 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) | +| 2026-10-12 | minor | LR-META-XMP: bounded complete-APP1 manual JPEG reread, fail-closed sidecar precedence and scoped regression coverage; LR-META-EXIF-FULL: reuse loaded bytes; statuses unchanged | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Pentax PEFs without area tags lose their masked border lines (issue #1271); status unchanged (🟡) | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path | +| 2026-10-11 | minor | LRC-DEV-AUTOSYNC: preserve per-photo HDR tuning when synchronizing HDR enabled state; status unchanged. | | 2026-10-12 | minor | LR-BEHAV-ACCESS: canvas handles by keyboard with AccessKit names and values; Tab order test; status stays 🟡 | | 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | | 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord | @@ -873,11 +897,13 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | +| 2026-10-21 | minor | LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; reference pins stay within their catalog, unavailable or malformed photo ids refused, deleted reference not drawn and its pane released, a switch leaves Reference view; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | | 2026-10-11 | minor | LR-IMP-FORMATS: EOS R6 Mark III CR3 opens with its as-shot white balance and black level (`ColorData` version 66); status unchanged (🟡) | | 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) | +| 2026-10-10 | minor | LR-VIEW-PHOTOGRID: retain queued stand-in identity while updating visible/prefetch priority; status unchanged. | | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh Adobe models verified on one CC0 file each; status unchanged (🟡) | @@ -888,3 +914,17 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `<file>.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | +| 2026-10-21 | minor | LR-MERGE-HDR / LR-MERGE-PANO: HDR alignment warps, panorama projection and Boundary Warp sample Lanczos-3 instead of bilinear (`lightcraft_raster::sample`); status unchanged | +| 2026-10-18 | minor | LR-EDIT-OPTICS-PROFILE: raws that name no lens model (Nikon DSLRs record only the lens specification in their maker note) look up the lens database by a name built from it (`24-70mm f/2.8`) and use only a profile named exactly that; status unchanged (🟡) | +| 2026-10-19 | minor | LR-GUIDED-SELECT and LR-GUIDED-DELETE: Guided Upright guides select on the press near their line (another press deselects), start a new guide where the press lands (not where egui's drag threshold was crossed), and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step); Delete / Backspace with the tool open removes only the selected guide; new rows land as ✅ (`tests_guided_guides` 8/8) | +| 2026-10-21 | patch | LR-VIEW-ZOOM: a pinch also holds the size the loupe's fit view is shrunk to, so it no longer renders the whole frame at every zoom step; status unchanged (✅) | +| 2026-10-21 | patch | LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables (X-T4 and other newer bodies) read at the plain radius, the corner at the eighth knot; X-T4 zoom raws now get their distortion correction (253 of 425 checked had none) and corners no longer brighten up to 6.5 % more than the camera's; status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | +| 2026-10-18 | minor | LR-IMP-FORMATS: Minolta / Konica Minolta MRW decodes from sensor data (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum 7D verified on CC0 files); status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | +| 2026-10-16 | minor | LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan row says how many folders and files it has listed while it lists the folder (an indeterminate bar, not "0 of N"), and ✕ stops the listing; statuses unchanged | +| 2026-10-19 | patch | LRC-LIB-LIBFOLDERS: Synchronize Folder on a folder that is up to date says so with one Close instead of four empty choices and a Synchronize that started an empty job; status unchanged (✅) | +| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged | +| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) | +| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) | diff --git a/docs/process-versions.md b/docs/process-versions.md index ea31d3950..1da9367a9 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -22,6 +22,11 @@ stronger Light sliders, issue #196) without changing a single photo someone has - Changes to how a file is decoded or which camera colour it gets change the source, not how settings are interpreted; the process doesn't reach that stage today. +**Neutral display tone map** (JPEG, PNG and other display-referred sources with Contrast, Whites and +Blacks at 0) is the identity up to white: the SDR shoulder no longer pulls 1.0 down to 0.9875 (8-bit +255 to 254, issue #1945). It landed in place as a bug fix, since the tone map promised identity at +neutral settings; edited curves keep the shoulder and their V1 look. + ## Adding a process 1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..2923ea880 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -15,14 +15,14 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **545** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **651** (45%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **212** (15%) | measured | +| Decoded from sensor data and **verified** on a real file | **551** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **656** (45%) | measured from the format rules (631 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **201** (14%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~85%** | estimated, same weights | -| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | +| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | @@ -31,6 +31,13 @@ model from the format rules in the script (which mirror `crates/raw/src`) plus t rewrites [raw/coverage.tsv](raw/coverage.tsv) and prints the tables below; `--check` fails when the committed table is stale. +Folder discovery, desktop file pickers, CLI and MCP share +`lightcraft_engine::import::EXTENSIONS`. The list includes formats that may open only from an +embedded preview or report a decoder error; an extension is not a promise of sensor decoding. +The 2026-10-12 release CLI check of the public DSC-R1 SR2 and DSC-F828 SRF samples found the +same photos and decode status through recursive and direct import. DSC-F828 moves from +unsupported to preview-only in the counts; sensor-decoding readiness is unchanged. + ## How it was measured - **Adobe's list**: helpx "Camera Raw supported cameras" (the live page refuses scripted requests, so @@ -78,7 +85,7 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | +| Sony | 149 | 94 | 43 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified | | Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) | @@ -95,8 +102,8 @@ committed table is stale. | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) | -| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 12 | 16 | 13 | 15 | DCR, GPR not imported; KDC, X3F preview (Epson ERF and Minolta MRW decode) | +| **All** | **1,446** | **551 (38%)** | **656 (45%)** | **201 (14%)** | **38 (3%)** | | ## By use (estimated) @@ -142,9 +149,10 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | | ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) | -| ARW | A100 ARW; SRF (F828, V3) | ⬜ | refused (#702); SRF not imported | +| ARW | A100 ARW | ⬜ | recorded as refused (#702); not rechecked here | +| SRF | DSC-F828; DSC-V3 | ⬜ | discovered during folder import; F828 imports as preview-only, V3 has not been checked on a real file | | ARW | ARQ pixel shift | ⬜ | no code | -| SR2 | DSC-R1 | ✅ | CC0 3221 sample | +| SR2 | DSC-R1 | ✅ | CC0 3221 sample; folder and direct import both decode sensor data | | RAF | uncompressed, lossless and lossy compressed; Bayer and X-Trans; GFX 14/16-bit | ✅ | 24 files from 13 bodies exact | | RAF | pre-X FinePix without a raw IFD | ⬜ | `raf.rs` refuses (26 Adobe models, estimated split) | | ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 | @@ -155,14 +163,15 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | | SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | -| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | +| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files; lossy tiles keep their declared transfer curve (Lightroom Classic 15.1 LinearRaw DNGs label linear data with the sRGB curve); PQ / HLG tiles preview-only | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | -| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | +| MRW | 12-bit packed, MSB first, or 12-bit in big-endian 16-bit words; RGGB and GBRG | ✅ | 4 CC0 corpus files (DiMAGE 7Hi, A1, A200, Dynax 7D) plus DiMAGE 7 and A2 checked against their previews; `vendor/mrw.rs` | +| IIQ, MOS, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | | MEF | uncompressed 12-bit (Mamiya ZD) | ✅ | 1 CC0 corpus file; crop and WB from its uncompressed RGB preview (no JPEG, no WB tag); `vendor/mef.rs` | | DCR, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | @@ -205,11 +214,11 @@ sample files. A pointer for contributors, not legal advice. | Item | Adobe | LightCraft | |---|---|---| -| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | +| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 X-Trans CMOS 4 Fujifilm bodies take the X-T3's); else a neutral matrix | | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | -| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): @@ -234,7 +243,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -269,10 +278,10 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | -| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | +| 7 | Sony A100 regression (#702) and DSC-F828 sensor decoding (preview-only) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | -| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, KDC, DCR, GPR (ERF, MEF, MRW done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: @@ -287,6 +296,13 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | +| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | +| 2026-10-15 | minor | DNGs with a look table and no tone curve (Lightroom-written, Adobe Standard) fit their tone to the embedded preview, also Lightroom's 1024 px one: two lossy ILCE-7CM2 DNGs no longer render 0.8 EV dark and flat | +| 2026-10-15 | minor | Lossy JPEG XL DNG tiles that declare a non-linear SDR transfer curve (sRGB, BT.709, DCI, gamma) decode with that curve restored instead of falling back to the embedded preview: Lightroom Classic 15.1 LinearRaw DNGs (Rec. 2100 primaries, sRGB curve; 2 private ILCE-7CM2 files) | +| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | +| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | +| 2026-10-12 | minor | SR2/SRF folder discovery and shared CLI/MCP extensions; public DSC-R1 sensor import and DSC-F828 preview-only import agree with direct import. F828 reclassified as preview-only (219 preview, 37 unsupported in the current generated coverage); sensor-decoding scores unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | @@ -311,3 +327,4 @@ Beyond decoding: | 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged | | 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | | 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again | +| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) | diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index c4552feee..a9a5f521f 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -66,6 +66,8 @@ def classify(maker, camera, ext_field): return "unverified", "ARW", "ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr)" if e == "SR2": return "unverified", "SR2", "SR2 decoder" + if e == "SRF": + return "unsupported", "SRF", "SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file" if e == "RAF": if RAF_OLD.search(camera): return "preview", "RAF", "pre-X FinePix RAF without a raw IFD" @@ -87,7 +89,9 @@ def classify(maker, camera, ext_field): return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)" if e == "ERF": return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)" - if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"): + if e == "MRW": + return "unverified", "MRW", "MRW decoder (12-bit packed, 16-bit words)" + if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "X3F", "RAW", "ORI"): return "preview", e, f"{e} is opened from its embedded JPEG only" return "unsupported", e, f"{e or 'unknown'} is not imported" diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 8bf7c67a8..cc5808471 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -552,17 +552,17 @@ Kodak EasyShare Z1015 IS KDC 2008 preview KDC is opened from its embedded JPEG o Kodak EasyShare Z980 KDC 2008 preview KDC is opened from its embedded JPEG only Kodak EasyShare Z981 RAW 2010 preview RAW is opened from its embedded JPEG only Kodak EasyShare Z990 RAW 2010 preview RAW is opened from its embedded JPEG only -Konica Minolta Alpha-5 Digital (China) MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 5 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7i MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7Hi MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A1 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A2 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A200 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta Maxxum 5D Dynax 5D MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta Maxxum 7D Dynax 7D MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta Alpha-5 Digital (China) MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 5 MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 7 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE 7i MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 7Hi MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A2 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE A200 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 5D Dynax 5D MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta Maxxum 7D Dynax 7D MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Leaf AFi II 6 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf AFi II 7 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf Aptus 17 MOS 2007 preview MOS is opened from its embedded JPEG only @@ -1306,10 +1306,10 @@ Sony A6400 (ILCE-6400) ARW 2018 verified issue #535: raw.pixls.us bulk check of Sony A6500 (ILCE-6500) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6600 (ILCE-6600) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6700 (ILCE-6700) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 SRF 2003 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-V3 SRF 2005 unsupported SRF not in the import list +Sony DSC-V3 SRF 2005 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 6e60a6828..a09978c8d 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -465,10 +465,10 @@ Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 So Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded +Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-V3 unsupported SRF not in the import list +Sony DSC-V3 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file Sony FX3 (ILME-FX3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony NEX-3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -528,7 +528,7 @@ Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus. Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo -Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -563,3 +563,9 @@ Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pi Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Konica Minolta DiMAGE 7Hi verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE 7 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE A2 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index ed32fc85d..dff886b13 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -7,11 +7,11 @@ convention; that page's *Two conventions* section has the comparison and what wo Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted -`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table -of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the -FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS (the ILCE-7RM6's distortion is applied too, by its own -conversion above). Every other model, -and shading and CA on every model, are decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and applies distortion here on two validated +bodies: **ILCE-7RM4A (A7R IVA)** and **ILCE-7CR (A7CR)**. The A7R IVA keeps its existing Bayer 3:2 conversion, +validated with the FE 24-105mm F4 G OSS and FE 200-600mm F5.6-6.3 G OSS. The A7CR uses a separate conversion, +limited to full-frame Bayer 3:2 files with distortion Auto or forced Auto. Apart from the separate ILCE-7RM6 converter, other models and shading and CA on every model are +decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image (`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in `crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. @@ -20,7 +20,8 @@ control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off -setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +setting does not erase its table. A7CR Off, missing, unavailable and unknown settings produce no warp. The A7R IVA's +existing behavior is unchanged. Newly imported photos use LightCraft's existing embedded-profile default (enabled); the profile correction control can disable or adjust it. ## What is decoded @@ -67,7 +68,10 @@ setting does not erase its table. Newly imported photos use LightCraft's existin conversion refused the table). A readable table never implies a usable correction; only `Applied` components become opcodes. -The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +For the A7CR, `CameraCorrectionDisabled` records an Off, missing, unavailable or unknown setting. APS-C, +aspect crops and lossless M/S remain `NotValidatedMode`. The raw IFD's correction table is required. + +The A7R IVA's applied path is unchanged: its conversion still receives the raw IFD's `0x7037` table exactly as the TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh @@ -85,17 +89,8 @@ part of this repository. (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) storage. The decoder reproduces this inventory exactly (see *Local inventory check*). -- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does - not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were - 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at - radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from - 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings - 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera - JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the - A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge - On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on - the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer - 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Distortion.** The A7R IVA interpretation misses the A7CR camera JPEG at the wide end. The A7CR now uses the + separately measured corrected-radius convention below. It uses the raw IFD table, not the MakerNote array. - **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading @@ -112,6 +107,78 @@ part of this repository. - **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay excluded. +## A7CR camera-JPEG validation + +The A7CR conversion substantially reduces the corrected camera JPEG's geometry mismatch. Its empirical table unit is +`15559.300457825264`, fixed by the earlier four-file FE 24-50mm fit before checking other files and lenses. +This is a measured approximation for the tested scope, not a Sony specification or a universal Sony constant. + +For output radius `rho` in native crop pixels: + +``` +spacing = hypot(9504, 6336) / 30 +S(r) = r * (1 + interpolate(table, r / spacing) / 15559.300457825264) +source_radius(rho) = S(rho / zoom) +``` + +Interpolation is linear. The final segment extends beyond the last knot. Positive samples increase the source +radius before the fill zoom; negative samples reduce it. + +The fill zoom makes the maximum of +`S(rho / zoom) / rho` on the rectangular crop border equal to one. The optical center is the default crop's +pixel-center midpoint. The measured crop is 9504 by 6336 at (32, 20). + +Storage padding affects DNG normalization +only, so 9600 by 6376 and 9728 by 6656 storage produce the same physical warp. + +A four-term odd polynomial represents the map as `WarpRectilinear`. A dense 32769-point check rejects a polynomial +that folds or differs from the table by more than 0.25 px at a 1440 px long edge. This approximation check is +separate from agreement with the camera JPEG. The converter applies no shading or lateral CA. + +Validation uses all 131 owner files numerically, nine public raw.pixls.us codings, one independent public FE 50mm +file and the public issue #1300 FE 16-25mm file. Private pixels stay in memory. No private images, names or source +identities are saved in the new measurement artifacts. + +The nine private correction-on files cover the FE 24-50mm +at 24, 49 and 50 mm. The other 122 private files have correction Off and cover the Tamron 35-150mm and the +Voigtlander APO-LANTHAR 28mm and 50mm. Off references validate preservation, not correction-on optics for those lenses. + +Green-channel SIFT (Scale-Invariant Feature Transform) matches at 2376 px are filtered by an independent radial fit. Residuals remove median translation +only. They fit no scale, center or lens coefficient to the candidate. + +High-pass correlation uses held-out pixels +at 1200 px. File checks hold out the second file and then every fifth file in each lens/focal/setting group, and +both public correction-on files. The private checks were also examined in the earlier study, so they are file +checks rather than a fresh independent capture campaign. + +The public second-lens checks do not tune the unit. + +The A7R VI study uses a 0.75 px camera validation budget at 1440 px. The reporter has support to 0.933 half +diagonals. Its direct table's largest radial-bin residual is 0.590 px at 1200 px (0.708 px at 1440 px). + +The implemented polynomial reaches 0.638 px in a radial bin and 0.692 px in a supported edge region at 1200 px +in source coordinates. Actual corrected-raster matches give 0.173/0.605 px median/p95, a 0.763 px radial-bin +maximum and a 0.969 px edge-region maximum at 1200 px. That edge region is 1.162 px at 1440 px, so this does not +pass a strict 0.75 px budget everywhere measured. + +The unit test against the independently fitted smooth feature curve uses the combined 1.0 px table and +polynomial approximation budget; its observed maximum is 0.895 px at 1440 px. These are different metrics. + +The held-out private 24 mm file has weak outer-radius support. No unsupported ideal corner is counted as measured. + +APS-C, aspect crops and lossless M/S are excluded. Portrait captures use the same sensor-coordinate map and the +existing EXIF orientation conversion. Other lenses and the 16-25mm's tele end need more correction-on references. + +No Imaging Edge On/Off pairs were measured for this body. Camera JPEGs are the reference for this change. + +This branch supplies the warp for camera-look pairing in PR #1586. It does not duplicate that pairing code. + +`LOOK_VERSION` 12 refreshes smart previews that stored no A7CR lens data; `RENDER_CACHE_VERSION` 69 redraws cached +renders. + +Existing edits are preserved. Reload from Disk reads the new lens data for old imports. Enable the +profile correction control when an existing edit has it disabled. + ## Local inventory check `crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8299f91d9..0a7091a05 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (watermark completeness reassessed after #2052; current checklist recount synchronized) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; Platforms: the Windows x64 installer verified on Windows 11) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -12,7 +12,7 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | Number | Value | Kind | |---|---:|---| -| **Feature breadth**, checklist | **83.3%** of 539 in-scope rows (P0 98.3%, P1 94.7%, P2 55.7%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, checklist | **81.6%** of 532 in-scope rows (P0 98.0%, P1 95.2%, P2 50.0%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | | **Mainstream practitioner** | **~51%** (range 46–56%) | estimated: weekly areas of a working photographer, depth 74.0% × 0.804 × colour 0.85 | @@ -46,15 +46,15 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| -| Features (weighted areas below) | ~77% (checklist 83.3%, measured) | ~61% | 600–1,100 h | [Features](#features) | +| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | | ↳ RAW cameras, colour and lens profiles (part of features) | 83% of Adobe's 1,446 models decode (38% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | -| Localization (12 key languages) | 7 of 12 present | ~55% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | +| Localization (12 key languages) | 8 of 12 present | ~64% (mean of the 12, measured) | 130–210 h for the target's set; +135–239 h for Hindi, Arabic, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | | Performance | ~80% | ~70% | 30–50 h | 24 MP GPU slider 3.9 ms, export 0.3 s (measured, [gpu-pipeline.md](gpu-pipeline.md)); 85k library opens in 1.7–4.7 s; 100k test pending | | Stability | — | ~60% | 30–60 h | never-crash lints and guards workspace-wide, crash-tested journal, 2,561 tests; but CI runs tests only on FreeBSD, user reports keep finding bugs in ✅ areas | -| Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; Windows installer UI unverified on Windows (#79); web experimental (CPU only) | +| Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; the Windows x64 installer is verified on Windows 11 (0.9.1) except for a confirmation after uninstalling from Settings → Apps (#892); web experimental (CPU only) | | Ecosystem / plug-ins | ~25% | ~15% | 40–80 h + owner decision | no Lightroom SDK equivalent (export, publish, metadata plug-ins); beyond the target: JSON-lines control channel, MCP server with 28 tools + one per command, CLI | | AI features | ~30% | ~20% | 120–220 h + model decisions | [AI](#ai) (overlaps the masking and Enhance rows; not added twice) | @@ -74,7 +74,7 @@ from the checklist sections and the deep-dive documents. | Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror | | Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% | | Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | | -| Export | 6% | 89% | 75% | 15–30 h | checklist S 86.8% | +| Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% | | Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 88% (HDR merge 🟡: AI Denoise on merged DNGs, JPEG response, memory; gap 29), HDR 70% (no HDR display) | | Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | | Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | @@ -136,12 +136,12 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | UI/UX fidelity | 8% | 70% | | File formats (non-raw) | 6% | 70% | | Hardware | 3% | 40% | -| Localization | 3% | 55% | +| Localization | 3% | 64% | | Performance | 6% | 70% | | Stability | 8% | 60% | | Platforms | 4% | 60% | | Ecosystem / plug-ins | 2% | 15% | -| **Weighted sum** | 100% | **61.2% ≈ 61%** (range 56–66%) | +| **Weighted sum** | 100% | **61.5% ≈ 61%** (range 56–66%) | Mainstream comes out lower than full: its scope is the raw-develop core, where LightCraft is weakest (camera colour, CR3 coverage), and its discounts are multiplicative, while the full sum is lifted by @@ -158,7 +158,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | Discount | Mainstream | Essentials | Evidence | |---|---:|---:|---| | Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) | -| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) | +| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer, fixed in 0.9.1); tests run in CI on FreeBSD only (gap 9) | | File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | @@ -276,7 +276,6 @@ most of its list; what remains is the data-heavy part (colour, lenses, models) i | Date | Checklist (all / P0 / P1 / P2) | Ready for real work | Remaining to full parity | Source | |---|---|---|---|---| -| 2026-10-17 | 83.3% / 98.3% / 94.7% / 55.7% (539 rows) | unchanged | unchanged | Current recount synchronized; LR-EXP-WATERMARK reassessed as partial for existing text-layout and font limits (#2052), not a rendering regression | | 2026-10-12 | 81.1% (unchanged) | ~61% full · ~51% mainstream · ~65% essentials | unchanged | 22 more CR3 bodies verified (raw-parity.md) | | 2026-10-10 (RAW) | 81.1% (unchanged) | ~61% full · ~49% mainstream · ~64% essentials | unchanged | RAW measured model by model against Adobe's list; RAW decode and colour as explicit rows | | 2026-10-10 (final) | 81.1% (unchanged) | ~59% full (weighted sum) · ~51% mainstream · ~63% essentials | full 1,000–1,800 h · mainstream 450–840 h · essentials 160–300 h | method aligned with the standard | @@ -301,12 +300,14 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | LR-EXP-WATERMARK reassessed as partial for existing shaping, bidi and font limits (#2052); current checklist recount 83.4% to 83.3% of 539 rows. Stale headline figures synchronized to that recount; readiness and effort estimates unchanged | +| 2026-10-19 | minor | Localization ~55% → ~64% (mean of the 12 key languages): Indonesian (`id`) catalog, every message but What's New; full target 61.2% → 61.5% (still ~61%) | +| 2026-10-19 | patch | Platforms: the Windows installer is no longer listed as unverified (the 0.9.1 x64 MSI is tested on Windows 11; a confirmation after uninstalling from Settings → Apps, #892, remains); #790 noted as fixed in 0.9.1; percentages and hours unchanged | | 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | | 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged | | 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages 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_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged | | 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) | | 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5e0c6e5f..f267092eb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -30,7 +30,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 | | Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys | | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | -| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | +| Context menus | everywhere | 31 sites (grid, filmstrip, albums, folders, keywords, masks, presets, text fields…), all through `widgets::context_menu` (`crates/ui-egui/src/widgets.rs`): a right-click elsewhere while a menu is open closes it and opens the menu there, also on the same widget (issue #1877: that one flashed at the new spot and closed, as egui counts the opening click as a click while open) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | | Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab; crop handles, spots, gradient handles and mask pins by keyboard | 75% | brush strokes and red-eye ellipses need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | @@ -66,15 +66,15 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. Remaining Classic gaps: filter-bar keys. +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. Remaining Classic gaps: ⌘L (Enable Filters). **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). **Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… ; View → Visualize HDR has no default key (`Shift+H` cycles the Tool Overlay, issue #224; `Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. **Still missing / broken:** -- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, - visualize spots and merges have commands now). +- Tab / ⇧Tab (hide the side panels / all panels): Tab moves the keyboard focus here (accessibility), so it is + not bound; F5 (hide the top panel) is not bound either. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -126,10 +126,30 @@ unnamed was in the way. Not done: brush strokes and red-eye ellipses still need a pointer. Nobody has tried the app with a real screen reader on any platform yet. +## Reference view lifecycle + +Escape leaves Reference for Detail, preserving the active photo and its edits; a second Escape +returns to the grid. Existing dialogs, tools and Lights Out retain their higher-priority Escape +handling. The reference stays pinned when leaving the view, but a successful Open/New Library +clears it: numeric photo IDs are local to each catalog. A refused switch keeps the current pin, +including Open/New refused while an import or its folder scan is running. + +`photo.setReference` refuses missing or Recently Deleted photos without replacing the current +pin. Deleting a pinned reference stops drawing it; re-entering Reference falls back to a live +active photo. Restoring the photo permits pinning it again. An empty catalog cannot supply one. + +Verification: four new behavioral regressions failed on upstream `76b5377` before the fix. +The patched Windows x64 app on RTX 5080 / DX12 passed Escape, edit preservation, delete/restore, +and New/Open Library checks using isolated catalogs. Saved exposure survived reopening. Native +1400 × 900 captures did not fall back to headless rendering. Control-channel input does not +establish OS keyboard delivery or cross-platform acceptance. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged | +| 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left | | 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | | 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | | 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | @@ -138,5 +158,6 @@ a real screen reader on any platform yet. | 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged | | 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | +| 2026-10-21 | minor | Reference Escape exits to Detail; catalog-local pins and missing/deleted reference handling verified with regressions and native Windows smoke | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | diff --git a/docs/user/guides/edit-a-photo.md b/docs/user/guides/edit-a-photo.md index 126324159..366bf5212 100644 --- a/docs/user/guides/edit-a-photo.md +++ b/docs/user/guides/edit-a-photo.md @@ -32,7 +32,7 @@ Open them from the **Window** menu, or press the key shown next to each one: | Panel | Key | |-------------------|-------------| | **Crop & Rotate** | `C` | -| **Remove** | `H` | +| [**Remove**](remove-distractions.md) | `H` | | **Masking** | `M` | | **Presets** | `Shift`+`P` | diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md index b3c656946..f64dd2335 100644 --- a/docs/user/guides/index.md +++ b/docs/user/guides/index.md @@ -7,5 +7,6 @@ order: 20 - [Import photos](import-photos.md) - [Rate, flag and label photos](cull-photos.md) - [Edit a photo](edit-a-photo.md) +- [Remove distractions from a photo](remove-distractions.md) - [Export photos](export-photos.md) - [Move over from Lightroom](import-lightroom-catalog.md) diff --git a/docs/user/guides/remove-distractions.md b/docs/user/guides/remove-distractions.md new file mode 100644 index 000000000..ae2705141 --- /dev/null +++ b/docs/user/guides/remove-distractions.md @@ -0,0 +1,70 @@ +--- +title: Remove distractions from a photo +description: Paint over a small distraction, see when the repair takes effect, and adjust or undo it. +order: 35 +verified: 0.8.0 +--- + +## Remove a distraction + +Paint over the distraction and release the mouse button. LightCraft applies the repair automatically; there is no **Apply** button. + +For example, remove a small speck from a plain background: + +1. Double-click the photo in the grid to open it in **Detail** view. If it is not in your library yet, [import the photo](import-photos.md) first. +2. Press `H`, choose **Window** > **Remove**, or click the **Remove** tool at the far right. +3. Choose **Remove** at the top of the panel. +4. Adjust **Size** so the brush covers the speck. Set **Opacity** to 100 for a full repair. **Feather** softens the edge. +5. Click once for a round spot, or hold the mouse button and drag to paint over a longer distraction. +6. Release the mouse button. LightCraft adds the repair and updates the photo. The painted area is not waiting for confirmation. Allow the preview to finish updating. +7. Press `E` to open **Edit** and inspect the result without the repair outlines. Return with `H` if you need to [adjust the repair](#adjust-or-undo-a-repair). +8. [Export the photo](export-photos.md) to save an edited copy. For a rendered photo with the repair, choose a format such as **JPEG**. + +## Adjust or undo a repair + +Each click or completed stroke creates one repair, called a spot in the panel. + +1. Open **Remove** and click the repair's pin on the photo to select it. Clicking an existing pin selects that repair instead of adding another one. +2. Change **Size**, **Feather** or **Opacity** to adjust the selected repair. Drag its target pin to move the repaired area. +3. For **Remove**, **Heal** or **Clone**, drag the source outline to a cleaner area if the copied detail looks wrong. Or click **Refresh Source (/)** to ask LightCraft to choose another source. It may report that no other source fits. +4. To replace the painted shape or try another mode, click **Delete (⌫)**, choose the mode, and paint again. The mode buttons choose the mode for new repairs. +5. To undo your last change, choose **Edit** > **Undo**. To delete a particular repair, select its pin and click **Delete (⌫)**. + +## Other repair modes + +Choose the mode before painting a new repair. + +| Mode | What it does in LightCraft 0.8.0 | +|---|---| +| **Remove** | Chooses a nearby source patch and blends its detail with the tone around the repaired area. You can move or refresh the source afterward. | +| **Content-Aware** | Rebuilds the painted area from patches of its surroundings. It does not use one source patch, so moving or refreshing a source does not change the fill. | +| **Heal** | Uses the same blending as **Remove** in this release. It also starts with an automatically chosen source that you can move or refresh. | +| **Clone** | Copies the source patch's colour and detail, with the edge and strength controlled by **Feather** and **Opacity**. It also starts with an automatically chosen source. | + +## Common questions + +### I painted an area. Why does it look unchanged? + +1. Release the mouse button and let the preview update. +2. Check **Opacity**. At 0, a repair has no visible effect. Select the repair and raise it to 100. +3. Check that the brush covers the distraction. Clicking an existing pin only selects a repair. +4. Turn off **Visualize Spots (A)** to judge the normal photo. This black-and-white view helps reveal specks; it does not apply a repair. +5. If **View** > **Show Original** is on, choose it again to return to the edited photo. +6. If the copied patch is unsuitable, adjust its source or delete the repair and paint again. + +### Is there an Apply button? + +No. A click adds a round repair. A brush stroke is applied when you release the mouse button. Selecting another panel does not discard it. + +### Why do I see circles, pins or an arrow? + +These mark the repaired area and, for a selected repair that uses one, its source. They let you select and move the repair. Press `E` to inspect the photo without them. They are not part of the exported photo. + +### How do I see the original? + +1. Choose **View** > **Show Original** to see the original. +2. Choose **View** > **Show Original** again to return to the edited photo. + +### Is my original photo overwritten? + +No. Repairs are stored as edits in the library. Export an edited copy when you want a file to share; see [Export photos](export-photos.md). diff --git a/docs/user/index.md b/docs/user/index.md index a0655a0d3..80df9587b 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -14,6 +14,7 @@ You import photos, sort them with ratings, flags and albums, and edit them witho - [Import photos](guides/import-photos.md) - [Rate, flag and label photos](guides/cull-photos.md) - [Edit a photo](guides/edit-a-photo.md) +- [Remove distractions from a photo](guides/remove-distractions.md) - [Export photos](guides/export-photos.md) - [Move over from Lightroom](guides/import-lightroom-catalog.md) - [A raw photo opens as "Preview only"](troubleshooting/preview-only-raw.md) diff --git a/docs/web.md b/docs/web.md index b275e837d..98d0dd91c 100644 --- a/docs/web.md +++ b/docs/web.md @@ -111,7 +111,9 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not grant it, a notice says the library may be evicted under storage pressure. - **File ▸ Back Up Library…** downloads a zip of the library files and every stored original (`originals/<hash>/<file name>`, so an unzipped backup is browsable; entries are stored, not - compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a + compressed; at most 4 GB). While a save is failing it refuses to start and says how many + changes are unsaved and why: the zip copies the stored library files, so it would miss them + (issue #1508). **File ▸ Restore Library from Backup…** reads such a zip into a new library folder (`library-restored-<time>/`; originals already stored are skipped, every entry's checksum is verified, and each restored original must match its content hash) and only then switches to it (`active-library` names the folder in use) and reloads: the previous @@ -121,6 +123,11 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not writes, so commands report `saved in memory but not written`, the top bar shows the unsaved warning (as on the desktop, see `docs/control-protocol.md`), and saving is retried every 2 s until it works (`web.stats` → `saveError`). + - A failed read at start-up is not taken for a missing file: only `NotFoundError` means absent. + When a stored library file (or the `active-library` pointer) exists but can't be read, the + library isn't opened and nothing is saved, so the stored copy stays as it was; the window says + which file failed (issue #1507). A thumbnail index that can't be read keeps the stored + thumbnails instead of deleting them as unindexed. - A picked or dropped photo whose bytes can't be stored is not added (it would be gone after a reload); a notice says why. - One tab at a time: the page holds a Web Lock (`navigator.locks`, `lightcraft-library`); a diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..cffff83e2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,7 +1,159 @@ # What's new in LightCraft +## 2026-10-21 + +### Sharper resampling in HDR and panorama merges +- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a + photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around + hard edges such as clipped highlights. + +### Scrolling past editing sliders +- Leaving a selected slider disarms its vertical mouse-wheel adjustment (issue #1355), even when the pointer leaves + and comes back between two screen updates. Scrolling back over it now scrolls the panel instead of unexpectedly + changing the photo. Click or drag the slider again to adjust it with the vertical wheel; keyboard arrow + adjustments and an ongoing drag still work. A slider reached with Tab takes the arrow keys but not the wheel: + click it to scroll-adjust it. Sideways scrolling still adjusts the slider under the pointer without selecting it + first. + +### A pinch no longer re-renders the photo at every zoom step +- Pinching the loupe now keeps the size the fit view is rendered at until the gesture is quiet, as it already did for + the render sizes and windows. Before, each frame of a pinch asked for a new full-frame render at a new size. + +### Fujifilm lens corrections that match the camera +- X-T4 raws now read the lens's own distortion and vignetting tables the way the camera does. Many photos from zoom + lenses got no distortion correction at all (more than half of those checked from the XF16-55 and XF70-300), and + corners came out up to 6.5 % brighter than in the camera's JPEG. The corrected photo now lines up with the camera's + framing. Other recent Fujifilm bodies whose tables have nine knots are read the same way, but only the X-T4 was + measured. Thumbnails are redrawn when the library next shows them. + +### Reference view reliability +- Escape now leaves Reference for Detail without changing the active photo or its edits; the next + Escape returns to the grid. A dialog, tool or Lights Out still handles Escape first. +- Opening or creating a different library clears the pinned reference. Photo IDs belong to one + catalog; a pin no longer silently becomes another library's photo with the same numeric ID. +- Missing or Recently Deleted photos cannot be pinned. Deleting the pinned reference stops drawing + it; re-entering Reference chooses a live photo, and restoring the deleted photo makes it pinnable again. + In Recently Deleted, Set as Reference Photo is greyed out, and a refusal says to restore the photo. +- Switching library from Reference view goes to the grid instead of an empty Reference canvas. A refused + or failed switch keeps both the reference and the Compare pair. +- `photo.setReference` refuses an `id` that is not a photo id (a string, a negative or fractional + number) instead of pinning the active photo; only an absent or null `id` means the active photo. + +## 2026-10-20 + +### Smart albums check File Path rules faster +- A smart album rule on File Path (contains, doesn't contain, is, isn't, starts with, ends with, is empty, isn't + empty) no longer builds new text for every photo. On a synthetic library of 312,000 photos, a File Path "contains" + or "doesn't contain" rule took about 17 ms per pass instead of about 62 ms, around 3.5 times faster, with the same + photos matched; about 6 ms of that is only reading the paths. Paths and rule text with letters beyond A–Z are + compared as before. + +## 2026-10-19 + +### AI Denoise on DirectX 12 with NVIDIA GTX 10-series cards +- AI Denoise on the GPU under DirectX 12 (the Windows default) gives the same result as on the CPU again on an NVIDIA + GeForce GTX 1070. The driver ran the denoise network's output stores wrong, so every second pixel's colour + channels came out shifted and the denoised photo was wrong without any error. Vulkan was not affected. + +### The Before label stays in view when zoomed in +- Zoomed in, the "Before" label (\ key or the Before view) now sits at the canvas' corner instead of leaving the + screen with the photo's corner (issue #2267). + +### Synchronize Folder on a folder that is up to date +- When the scan finds nothing to import, relink, remove or read, the Synchronize Folder dialog says the library is up + to date with the folder and has one Close button. It used to show four greyed choices, two of them still ticked, + and a Synchronize button that started a job with nothing to do. Files it skipped (already in the library elsewhere, + or unreadable) are still listed. + + +### Hidden Local locations come back one at a time +- With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own + (Restore All is there too). The row used to bring every hidden location back at once (issue #2114). + +## 2026-10-18 + +### Thumbnails of Lightroom HDR and panorama merges in a second +- Grid and filmstrip thumbnails of DNGs that Lightroom merged (HDR, Panorama, HDR Panorama) now develop from the + small copies of the raw image Lightroom keeps inside the file (256 to 2048 px), not from the whole merge. A 23836 × + 7078 drone panorama's 512 px thumbnail took 38 s and now takes about a second, with the same look; your edits still + apply. Loupe previews larger than the biggest copy, 1:1 views and exports still read the full image. + +### Fewer false "not imported" warnings for Lightroom raws +- Importing raws with Lightroom XMP sidecars no longer lists settings Lightroom writes only for itself as adjustments + that weren't imported: `CompatibleVersion`, the camera and lens profile checksums, the lens profile's name, file and + setup, the crop's "constrain to image" option, and the lens profile's strength when it is at its default of 100 %. + A raw sidecar used to name all of them for every photo. Settings LightCraft really doesn't apply, such as `Look`, + are still reported. + + +### Fill Location from GPS: no more neighbouring town in City +- The offline place names list towns of 15,000 people or more, so a photo taken in a smaller one got the nearest larger + town in City, sometimes another municipality (issue #2242). The Fill Location tooltip and the command help now say so, + and `metadata.reverseGeocode` takes `maxDistance` (metres): past it, City stays empty (State and Country still fill) + and the reply names the town it found as `nearestCity`. + +### A preset that couldn't be saved is no longer lost silently +- When presets.json can't be written (a full disk, or the browser's storage quota), the new or changed preset now + counts as unsaved, like other changes: the top bar warns, saving is retried, and quitting asks first (issue #2266). + Before, the failure was only logged, and the preset could be lost without any warning. + +### Portrait crop ratios stay portrait +- A crop ratio no longer flips to the photo's orientation (issue #2253): after X turns the crop portrait, picking + another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a + portrait crop. + +## 2026-10-16 + +### Synchronize Folder shows that it is listing the folder +- A folder with tens of thousands of photos, above all on a network share, can take minutes to list before + Synchronize Folder checks a single photo. Its row in the activity stack used to sit at "0 of 64085" with an empty + bar all that time, as if it had stopped. It now says how many folders and files it has listed so far, with a bar + that moves on its own, and then counts the photos it checks. The dialog says the same. +- ✕ on the row stops the listing too, not only the checks after it. + +### The photo grid keeps up with imports into a large library +- While photos are imported into a large library, the grid takes in each batch as it arrives instead of reading + every photo of the view again: at about 180,000 photos a batch cost the window a tenth of a second, over and over + for as long as the import ran. Changing one photo's capture time or crop is quicker too. + +## 2026-10-15 + +### Photo Merge takes each photo once +- A merge that names the same photo more than once (through `ids` on the control channel, MCP or the CLI) now merges + it once (issue #1947). Naming one photo three times no longer "merges" it with itself, and a long repeated list no + longer decodes the photo again for every entry. + +### Constrain Crop tightens the crop you see +- Geometry ▸ Constrain Crop now changes the crop itself, as in Lightroom (issue #1927). Ticking it, or moving a + Geometry slider, Upright or a lens correction while it is ticked, shrinks the crop about its centre until the blank + corners left by the correction are gone, and the Crop tool shows the tighter crop. Before, it only applied when the + photo was shown outside the Crop tool, so in the Crop panel, where the checkbox is, nothing seemed to change. A crop + you draw by hand is kept as drawn, and unticking keeps the tighter crop. + +### Web: no backup that misses unsaved changes +- In the browser version, File ▸ Back Up Library… no longer reports success while a save is failing (storage full or + cleared): the backup copies the stored library, so it left out the changes that couldn't be saved (issue #1508). It + now refuses to start and says how many changes are unsaved and why. LightCraft retries the save with every change; + back up once nothing is left unsaved. + +### Web: an unreadable library is not opened +- In the browser version, a library file that exists but can't be read at start-up is no longer taken for a missing + one (issue #1507). LightCraft used to open the library without it, showing an incomplete catalog that the next save + would write over the stored copy. It now leaves the library unopened, saves nothing and says which file couldn't be + read; reload the page to try again. + ## 2026-10-14 +### Model downloads time out on a name that doesn't resolve +- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, + and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time + limit, and each of a server's addresses then got the whole connect timeout of its own. + +### Sony A7CR distortion correction + +- Full-frame 3:2 Bayer A7CR raws with distortion Auto or forced Auto now use their embedded distortion table. The corrected render is closer to the camera JPEG geometry. Off files keep their original geometry. APS-C, aspect crops and lossless M/S remain excluded. +- The warp also supplies the geometry needed by the lens-aligned camera-look fit in PR #1586. The pairing code is a separate change. Details and numeric validation are in `docs/sony-lens-corrections.md`. + ### Lightroom radial masks on the right side - Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in @@ -152,10 +304,21 @@ ⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step. `mask.brushStroke {connect: true}` does the same for agents. +### Local folders you browse are kept +- Browsing a folder under Local now records it as browsed, so the photos in folders you use aren't forgotten after + the number of days set in Settings → Performance. Only the `library.browse` command recorded it before, so a + folder browsed every day from the sidebar was forgotten anyway and read again from scratch on the next visit, + which is slow on a network share. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. +### Search finds people +- Typing a person's name in Search Photos finds the photos with that named face (issue #1449). +- Quotes keep several words together: `person:"Jane Doe"` finds exactly that person, `"jane doe"` those words side + by side, and `keyword:"new york"` a keyword of two words. + ### Where the camera focused - **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`). diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index c467a471a..b90e76c40 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere. |---|---| | Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. | | Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. | -| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). | +| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). Manual rereading uses embedded XMP in raw/DNG and JPEG files when no sidecar exists. A discovered sidecar that cannot be read or parsed is reported in `failed`, without applying embedded values. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). A file arriving in an import is ranked as if it were catalogued already: it reads its own `<file>.xmp` when another file of its stem (a raw first) owns the shared stem sidecar (issue #1905). | | Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. | What we write (standard namespaces, so other tools can read the metadata): @@ -41,6 +41,29 @@ When foreign `crs:` settings are only partly transferable, `library.import` incl report. Warnings identify unsupported profile names, saved Upright geometry that will be recalculated, and other unmapped adjustments; they do not change the source packet or become catalog edits. +Manual rereading and undo leave the JPEG original unchanged. Manual rereading also leaves sidecar files unchanged, +even with automatic XMP writing on. With automatic XMP writing off (the default), undo/redo leave sidecars unchanged; +with it enabled, subsequent edits and undo/redo can trigger the existing sidecar writer. `photo.allMetadata` uses +its already loaded file bytes for embedded XMP, with an existing sidecar taking precedence. + +Manual JPEG rereading uses the first standard XMP APP1 before the first SOS (scan) or EOI (end) marker. +Scanning is limited to a 16 MiB header span, including SOI, segment headers and skipped payloads, and 4096 +marker/fill steps after SOI. Non-APP1 payloads are skipped without loading them. Each APP1 payload must be +complete and is at most 65,533 bytes. Header extraction succeeds for the first complete +standard XMP packet within both limits, including a packet ending exactly at either limit; otherwise it +fails without a whole-file fallback, including on I/O errors or an incomplete APP1 whose available bytes +form parseable XMP. A valid JPEG with +XMP beyond those limits therefore cannot be manually reread through this path. Extended XMP assembly and +packets between scans are not supported by manual JPEG rereading. + +Packet completeness is stricter than the shared best-effort JPEG extractor; it is not strict whole-JPEG +validation. A complete early XMP packet can be returned without examining the remaining file. Import and +`photo.allMetadata` retain their existing extraction paths. Command regressions in +`crates/engine/src/tests_xmp.rs` cover metadata/undo and sidecar precedence, an over-nested XMP packet and a +truncated APP1 without an XMP signature. Separate reader cases in `sidecar::jpeg_header_tests` use small +counted readers and injected limits for span/step boundaries, complete versus truncated APP1, early stopping, +short reads and I/O errors. + ## Saving into an existing sidecar A sidecar may already hold another application's data — e.g. its `crs:` develop settings and `xmpMM:History` — often @@ -74,6 +97,10 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `<file>.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +An import ranks the files it is adding the same way, before they are catalogued: a TIFF or JPEG arriving beside a +raw of its stem reads its own `<file>.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives +with the raw or after it (issue #1905). + ## Face regions (MWG) LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema @@ -192,11 +219,12 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV | | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | +| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | | `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape). `Flipped="true"` means effect inside: in `MaskGroupBasedCorrections` with `Version` 2 (what Lightroom writes for an ordinary radial) and in the legacy `CircularGradientBasedCorrections` Radial Filter, whose default (no `Flipped`) is outside. A `MaskGroupBasedCorrections` radial without `Version` is inside whatever `Flipped` says, and its `Feather` is halved (Lightroom rescales it that way when it upgrades one). Inverting is `MaskInverted` | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | -| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | +| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported | | `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | | Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh index c14ca5725..3b84227f2 100644 --- a/packaging/check-app-specific.sh +++ b/packaging/check-app-specific.sh @@ -22,3 +22,41 @@ for key in ("runtime", "runtime-version", "sdk", "finish-args"): assert manifests[0][key] == manifests[1][key], f"Flatpak manifests disagree on {key}" print("LightCraft package identity ok") PYEOF + +# The macOS bundle must declare only file types LightCraft can open. It used to carry PhotoCraft's +# `.pcraft` document type and imported PSB/QOI declarations, so Launch Services typed PhotoCraft +# documents as LightCraft's and `.pcraft` had three claimant apps (issue #1951). plistlib parses the +# template as a property list too, which is the syntax check package.sh's `plutil -lint` repeats on +# the substituted file. +python3 - <<'PYEOF' +import plistlib +with open("packaging/macos/Info.plist.in", "rb") as source: + plist = plistlib.load(source) +for key, value in { + "CFBundleIdentifier": "ai.storyteller.lightcraft", + "CFBundleExecutable": "LightCraft", + "CFBundleIconFile": "LightCraft", + "LSMinimumSystemVersion": "11.0", +}.items(): + assert plist.get(key) == value, f"Info.plist {key}: {plist.get(key)!r}, expected {value!r}" +for key in ("CFBundleShortVersionString", "CFBundleVersion", "LightCraftVersion", "LightCraftBuildCommit"): + assert plist.get(key), f"Info.plist lost {key}" +declared = set() +for section in ("UTExportedTypeDeclarations", "UTImportedTypeDeclarations"): + for entry in plist.get(section, []): + tags = entry.get("UTTypeTagSpecification", {}) + declared.update(tags.get("public.filename-extension", [])) + declared.update(tags.get("public.mime-type", [])) + declared.add(entry.get("UTTypeIdentifier", "")) +unsupported = {name.lower() for name in declared} & { + "pcraft", + "psb", + "qoi", + "application/x-lightcraft", + "ai.storyteller.lightcraft.document", + "ai.storyteller.lightcraft.qoi", + "com.adobe.photoshop-large-image", +} +assert not unsupported, f"Info.plist declares types LightCraft cannot open: {sorted(unsupported)}" +print("LightCraft macOS bundle types ok") +PYEOF diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 383c20f07..084972f72 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -114,7 +114,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 03220ab0a..fd87f601e 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -49,66 +49,11 @@ <key>NSHumanReadableCopyright</key> <string>Copyright © the LightCraft authors. MIT OR Apache-2.0.</string> - <!-- Lightcraft's own format. --> - <key>UTExportedTypeDeclarations</key> - <array> - <dict> - <key>UTTypeIdentifier</key> - <string>ai.storyteller.lightcraft.document</string> - <key>UTTypeDescription</key> - <string>LightCraft Document</string> - <key>UTTypeConformsTo</key> - <array> - <string>public.data</string> - <string>public.image</string> - </array> - <key>UTTypeIconFile</key> - <string>LightCraft</string> - <key>UTTypeTagSpecification</key> - <dict> - <key>public.filename-extension</key> - <array><string>pcraft</string></array> - <key>public.mime-type</key> - <array><string>application/x-lightcraft</string></array> - </dict> - </dict> - </array> - <!-- Types macOS may not know; the system's own declaration wins when it has one. --> - <key>UTImportedTypeDeclarations</key> - <array> - <dict> - <key>UTTypeIdentifier</key> - <string>com.adobe.photoshop-large-image</string> - <key>UTTypeDescription</key> - <string>Photoshop Large Document</string> - <key>UTTypeConformsTo</key> - <array> - <string>public.data</string> - <string>public.image</string> - </array> - <key>UTTypeTagSpecification</key> - <dict> - <key>public.filename-extension</key> - <array><string>psb</string></array> - </dict> - </dict> - <dict> - <key>UTTypeIdentifier</key> - <string>ai.storyteller.lightcraft.qoi</string> - <key>UTTypeDescription</key> - <string>QOI Image</string> - <key>UTTypeConformsTo</key> - <array> - <string>public.data</string> - <string>public.image</string> - </array> - <key>UTTypeTagSpecification</key> - <dict> - <key>public.filename-extension</key> - <array><string>qoi</string></array> - </dict> - </dict> - </array> + <!-- No UTI declarations: LightCraft has no document type of its own, and the formats it opens + (JPEG, PNG, TIFF, HEIC, the raw formats) are declared by the system or by another app. + Declaring PhotoCraft's `.pcraft` type here (and importing PSB and QOI) made Launch Services + type PhotoCraft documents as LightCraft's; `.pcraft` ended up with three claimant apps + (issue #1951). --> </dict> </plist> diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 64b6d2149..16691a6ba 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,6 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" if [ "${1:-}" != "--skip-build" ]; then + # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the + # version in Cargo.lock. Locally, `cargo xtask web` says which version to install. + if [ -n "${CI:-}" ]; then + WASM_BINDGEN_VERSION="$(awk '$0 == "name = \"wasm-bindgen\"" { getline; gsub(/version = |"/, ""); print; exit }' "$ROOT/Cargo.lock")" + [ -n "$WASM_BINDGEN_VERSION" ] || { echo "error: wasm-bindgen not found in Cargo.lock" >&2; exit 1; } + if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF " $WASM_BINDGEN_VERSION"; }; then + cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + fi + fi (cd "$ROOT" && cargo xtask web) fi diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..d0480c0ed --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_lightcraft +sonar.organization=storytold diff --git a/tools/install-sam3.sh b/tools/install-sam3.sh index 4ec9d0a9c..b9d95cf7f 100755 --- a/tools/install-sam3.sh +++ b/tools/install-sam3.sh @@ -83,13 +83,13 @@ BASE="https://huggingface.co/$REPO/resolve/main" fetch() { set -- "$1" "$2" "${3:-}" if [ -n "$TOKEN" ]; then auth="Authorization: Bearer $TOKEN"; else auth="X-No-Auth: 1"; fi - code=$(curl -fsSL ${3:+-C -} -H "$auth" -w '%{http_code}' -o "$2" "$BASE/$1") || true + code=$(curl --proto '=https' -fsSL ${3:+-C -} -H "$auth" -w '%{http_code}' -o "$2" "$BASE/$1") || true case "$code" in 200|206) return 0 ;; 416) echo "the partial download of $1 doesn't match the server's; starting over" >&2 rm -f "$2" - code=$(curl -fsSL -H "$auth" -w '%{http_code}' -o "$2" "$BASE/$1") || true + code=$(curl --proto '=https' -fsSL -H "$auth" -w '%{http_code}' -o "$2" "$BASE/$1") || true [ "$code" = 200 ] && return 0 echo "download of $REPO/$1 failed ($code)" >&2; return 1 ;; 401|403) echo "access denied to $REPO/$1 (has your request for facebook/sam3 been approved?)" >&2; return 1 ;; diff --git a/tools/sam3_reference.py b/tools/sam3_reference.py index 66d9def90..99a06f3ee 100644 --- a/tools/sam3_reference.py +++ b/tools/sam3_reference.py @@ -58,6 +58,7 @@ for i, text in enumerate(["moon", "sky", "bird"]): out[f"det{i}_presence"] = o.presence_logits.contiguous() out[f"det{i}_boxes"] = o.pred_boxes.contiguous() out[f"det{i}_masks"] = o.pred_masks.contiguous() + out[f"det{i}_semantic"] = o.semantic_seg.contiguous() sc = (o.pred_logits.sigmoid() * o.presence_logits.sigmoid())[0] print(text, "presence", o.presence_logits.sigmoid().item(), "top", sc.topk(3).values.tolist(), file=sys.stderr) for i, f in enumerate(det.vision_encoder(px).fpn_hidden_states): diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8a7d597b5..7af8c82ac 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1388,6 +1388,26 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "mrw-minolta-dimage7hi-words16.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW", + "2919a05757197a5424e02970c016893bca5aa29363a67b62b1a4b36326a10f6a", + ), + ( + "mrw-minolta-dimage-a1.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A1/RAW_MINOLTA_A1.MRW", + "bde1e2cd788e49c9bef20dfdd41be948fb02c2ee198c6b0959a67a3f79fd41c9", + ), + ( + "mrw-minolta-dimage-a200-gbrg.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A200/RAW_MINOLTA_DIMAGE_A200.MRW", + "2a95f6af2168e536dfb6d02b47e3b6119b35b497eebf179d7c20a6e496c26641", + ), + ( + "mrw-minolta-dynax7d.mrw", + "https://raw.pixls.us/data/Minolta/Dynax%207D/PICT3410.MRW", + "713819e33e5302ec864d576d57223cfc8dcf46e8d45e0ae744d1fec40290a89f", + ), ( "nef-nikon-d700.nef", "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",