diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 9dc8fe0b..b777e26b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,6 +27,12 @@ jobs: timeout-minutes: 300 steps: - uses: actions/checkout@v4 + # Optional font build input, so the Japanese font tests run (they skip without it). + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 with: @@ -44,5 +50,6 @@ jobs: # The checkout is synced in under another owner; let tests that call git read it. git config --global --add safe.directory "*" rustc --version + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 41b361cb..2b53ae6c 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -47,6 +47,10 @@ jobs: - name: XML, desktop entry, AppStream, Flatpak manifest run: | xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* + # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. + if grep -oE '"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b04f35f..cae4a17d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,12 @@ jobs: LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin @@ -87,6 +93,8 @@ jobs: run: packaging/macos/import-cert.sh - name: Build, sign, notarize env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} @@ -112,11 +120,20 @@ jobs: include: - { arch: x64, target: x86_64-pc-windows-msvc } - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } env: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -133,6 +150,8 @@ jobs: - name: Build, sign, package shell: pwsh env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -167,6 +186,12 @@ jobs: NFPM_VERSION: 2.47.0 steps: - uses: actions/checkout@v4 + # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - name: Install build and packaging tools run: | sudo apt-get update @@ -184,6 +209,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$LIGHTCRAFT_VERSION" - name: Build and package (AppImage, deb, rpm, tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/linux/package.sh - name: Check the packages run: | @@ -212,6 +240,12 @@ jobs: WASM_BINDGEN_VERSION: 0.2.129 steps: - uses: actions/checkout@v4 + # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -221,13 +255,17 @@ jobs: - name: Install wasm-bindgen CLI run: | # Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it). - command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \ - || cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then + cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + fi wasm-bindgen --version - name: Apply version override if: inputs.version != '' run: cargo xtask version set "$LIGHTCRAFT_VERSION" - name: Build and zip + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 00000000..f9f8cef2 --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: lightcraft-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install and run on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: lightcraft-windows-arm64 + path: dist + - name: Install the MSI, check it, run it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + $dir = Join-Path $env:ProgramFiles 'LightCraft' + foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + } + # The installed CLI runs natively on ARM64. + $cli = Join-Path $dir 'lightcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok lightcraft-cli --version: $($out.Trim())" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/.gitignore b/.gitignore index fe958866..823f9276 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ plan/ /log .DS_Store .claude/ +/craft-fonts diff --git a/AGENTS.md b/AGENTS.md index fdda2e93..393db2b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,21 @@ of it. Full standard: `../craftrules/standards/never-crash.md` ## Assets: icons, images, fonts (ABSOLUTE RULE — never violate) - **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not. - **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no - Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL). + Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL); + Japanese fonts come from craft-fonts (below). - Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly. - **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them. - **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit + font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft + uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` + then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI + (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip + without it; the FreeBSD CI job runs them with it. Rules: craftrules + [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). - Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons. - Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published. - **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched diff --git a/CLAUDE.md b/CLAUDE.md index fdda2e93..393db2b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,10 +62,21 @@ of it. Full standard: `../craftrules/standards/never-crash.md` ## Assets: icons, images, fonts (ABSOLUTE RULE — never violate) - **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not. - **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no - Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL). + Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL); + Japanese fonts come from craft-fonts (below). - Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly. - **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them. - **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit + font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft + uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` + then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI + (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip + without it; the FreeBSD CI job runs them with it. Rules: craftrules + [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). - Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons. - Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published. - **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched diff --git a/Cargo.lock b/Cargo.lock index 07324919..1952f5c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2073,7 +2073,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.2.0" +version = "0.2.1" dependencies = [ "eframe", "egui", @@ -2091,7 +2091,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.2.0" +version = "0.2.1" dependencies = [ "crc32fast", "lightcraft-develop", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "dhat", "lightcraft-codecs", @@ -2120,7 +2120,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bytemuck", "image", @@ -2145,7 +2145,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.2.0" +version = "0.2.1" dependencies = [ "lightcraft-geom", "proptest", @@ -2154,7 +2154,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -2165,7 +2165,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ab_glyph", "libc", @@ -2193,7 +2193,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.2.0" +version = "0.2.1" dependencies = [ "proptest", "serde", @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bytemuck", "lightcraft-color", @@ -2217,7 +2217,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.2.0" +version = "0.2.1" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.2.0" +version = "0.2.1" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -2244,7 +2244,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.2.0" +version = "0.2.1" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -2256,7 +2256,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.2.0" +version = "0.2.1" dependencies = [ "image", "lightcraft-color", @@ -2272,7 +2272,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.2.0" +version = "0.2.1" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -2284,7 +2284,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.2.0" +version = "0.2.1" dependencies = [ "libc", "lightcraft-color", @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.2.0" +version = "0.2.1" dependencies = [ "jpeg-encoder", "lightcraft-color", @@ -2315,7 +2315,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.2.0" +version = "0.2.1" dependencies = [ "image", "lightcraft-color", @@ -2326,11 +2326,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.2.0" +version = "0.2.1" [[package]] name = "lightcraft-tiff" -version = "0.2.0" +version = "0.2.1" dependencies = [ "proptest", "serde", @@ -2339,7 +2339,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.2.0" +version = "0.2.1" dependencies = [ "egui", "lightcraft-catalog", @@ -2359,7 +2359,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.2.0" +version = "0.2.1" dependencies = [ "crc32fast", "eframe", @@ -5128,7 +5128,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.2.0" +version = "0.2.1" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7a2ee8a5..b1882f9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/NOTICE b/NOTICE index a95eb76a..e6f95a8a 100644 --- a/NOTICE +++ b/NOTICE @@ -15,6 +15,8 @@ list (author, source, license) is in assets/ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. -BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors -(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1; -see assets/fonts/OFL-BIZUDMincho.txt. +Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) +also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md +(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ +UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's +licence as OFL-.txt. diff --git a/README.md b/README.md index 28fc1ac0..e7454f24 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,16 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light cargo xtask ci # fmt, clippy, tests, layering, wasm checks ``` +**Japanese text** needs the shared font repo, an optional build input (official releases always include it): + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft +``` + +Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this +repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). + The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI (`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). @@ -356,7 +366,8 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T work from public specs and black-box observation. - **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe 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. + 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. - **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit (one task id per commit). - **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or @@ -420,7 +431,9 @@ with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTIO Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River* (1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA, *Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font: -Inter (SIL OFL 1.1). All icons are original. +Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) +also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its +[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as diff --git a/ROADMAP.md b/ROADMAP.md index a0836d77..c1cc9d8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,7 +161,8 @@ The milestone estimates in the table above were made before work started and are ## Raw format coverage and known gaps Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, -LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and +LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered +maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants it can't decode yet. @@ -194,8 +195,9 @@ own chart-based calibration (M11.4). Adobe matrices are never used. ## Japanese interface and text watermarks English/Japanese interface language is persisted in UI state. Core menus have Japanese -translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled -under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept +translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic; +watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the +optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept `vertical: true` in export JSON/presets and expose an orientation selector. Japanese characters stay upright in top-to-bottom columns, with newlines starting columns to the left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 5a4b7aab..9d41873a 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -183,6 +183,42 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!((d.width, d.height), (640, 480)); } +/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, +/// without creating a GPU device (no driver is loaded), and says why. +#[test] +fn snapshot_starts_without_a_gpu() { + let script = tmp("nogpu.jsonl"); + std::fs::write( + &script, + format!( + "{}\n{}\n{}\n", + json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), + json!({"method": "ui.screenshot"}), + ), + ) + .unwrap(); + for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] { + let out = tmp(&format!("nogpu-{var}.png")); + let o = Command::new(BIN) + .env_remove("LIGHTCRAFT_GPU") + .env_remove("LIGHTCRAFT_GPU_BACKEND") + .env(var, value) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"]) + .output() + .unwrap(); + assert!(o.status.success(), "{var}: {}", String::from_utf8_lossy(&o.stderr)); + let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}"); + let gpu = &replies[1]["result"]; + assert_eq!(gpu["available"], false, "{var}: {gpu}"); + assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}"); + assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}"); + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (480, 320)); + } +} + fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec, String) { let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); if let Some(text) = stdin { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 49fd24ff..2bdfa16f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -71,6 +71,43 @@ impl eframe::App for App { } } +/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 +/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: +/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12). +fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { + let mut c = eframe::egui_wgpu::WgpuConfiguration::default(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { + n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends(); + } + c +} + +/// Written while the GPU compute device is created, removed once that returned +/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). +fn gpu_marker_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("gpu-init.marker")) +} + +/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU +/// driver while creating the device), the notice to show — GPU rendering then starts off. +fn gpu_crash_check() -> Option { + let marker = gpu_marker_path(); + let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker); + lightcraft_engine::gpu::backend::set_init_marker(marker); + left.map(|what| gpu_crash_notice(&what)) +} + +fn gpu_crash_notice(what: &str) -> String { + format!( + "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ + To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ + LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." + ) +} + fn config_dir() -> Option { if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft")) @@ -374,6 +411,10 @@ OPTIONS: --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; see docs/control-protocol.md) --version, --help + +ENVIRONMENT: + LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, + Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. "; fn main() -> eframe::Result { @@ -416,6 +457,11 @@ fn main() -> eframe::Result { .map(Into::into) }); let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); + // GPU compute: off if the preference says so, or if the last launch died creating the device + // (issue #136) — before anything can create it + let gpu_crash = gpu_crash_check(); + let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); + lightcraft_engine::gpu::set_enabled(gpu_on); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("LightCraft") @@ -428,6 +474,7 @@ fn main() -> eframe::Result { .with_icon(app_icon()) // Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id .with_app_id(APP_ID), + wgpu_options: window_wgpu_options(), ..Default::default() }; eframe::run_native( @@ -449,6 +496,11 @@ fn main() -> eframe::Result { keep_file: keep_prefs_file, ..Default::default() }; + // after the snapshot above, so the writer saves the switched-off preference + if let Some(notice) = gpu_crash { + app.ui.settings.gpu = false; + app.notices.push(notice); + } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); @@ -506,6 +558,35 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. + #[test] + fn gpu_crash_marker_is_reported_once() { + let d = dir("gpu-marker"); + let m = d.join("gpu-init.marker"); + assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); + std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); + let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); + let notice = gpu_crash_notice(&what); + assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); + assert!(!m.exists()); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. + #[test] + fn window_backends_follow_the_platform_default() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + let b = n.instance_descriptor.backends; + if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { + if cfg!(windows) { + assert_eq!(b, eframe::wgpu::Backends::DX12); + } else if cfg!(target_os = "macos") { + assert_eq!(b, eframe::wgpu::Backends::METAL); + } + } + assert!(!b.is_empty()); + } + #[test] fn ui_json_is_written_atomically() { let d = dir("atomic"); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 3d2133c5..654f9002 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -9,8 +9,6 @@ covers a whole directory), or if a licence file referenced in the Licence column | Path | Asset | Creator | Source | Licence | Added | Modifications | |---|---|---|---|---|---|---| | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | -| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none | -| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | @@ -20,9 +18,12 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | - -| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none | +| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter. +- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in + [storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its + `ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official + releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`). diff --git a/assets/fonts/BIZUDMincho-Regular.ttf b/assets/fonts/BIZUDMincho-Regular.ttf deleted file mode 100644 index b5a3f4cc..00000000 Binary files a/assets/fonts/BIZUDMincho-Regular.ttf and /dev/null differ diff --git a/assets/fonts/BIZUDPGothic-Bold.ttf b/assets/fonts/BIZUDPGothic-Bold.ttf deleted file mode 100644 index 8cfe3455..00000000 Binary files a/assets/fonts/BIZUDPGothic-Bold.ttf and /dev/null differ diff --git a/assets/fonts/BIZUDPGothic-Regular.ttf b/assets/fonts/BIZUDPGothic-Regular.ttf deleted file mode 100644 index 0753da09..00000000 Binary files a/assets/fonts/BIZUDPGothic-Regular.ttf and /dev/null differ diff --git a/assets/fonts/OFL-BIZUDGothic.txt b/assets/fonts/OFL-BIZUDGothic.txt deleted file mode 100644 index 4df8604d..00000000 --- a/assets/fonts/OFL-BIZUDGothic.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-BIZUDMincho.txt b/assets/fonts/OFL-BIZUDMincho.txt deleted file mode 100644 index 1664f9f6..00000000 --- a/assets/fonts/OFL-BIZUDMincho.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index 1e482b81..df239a17 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -1,11 +1,23 @@ //! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and //! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]). //! +//! **Write policy** (issue #134): what can't be recreated — originals, XMP sidecars, the catalog, +//! settings, DNGs that replace raws, smart previews (the only stand-in while an original is +//! offline), an edit copy that becomes a library photo — is written **durably**: synced to disk +//! before it counts as written. What can always be made again from the originals — exports, +//! renders, screenshots, merge previews — is written **atomically** only: a half-written file +//! never replaces a good one, but there is no per-file sync (on a USB drive or a NAS that sync +//! dominates a large export). +//! //! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive //! either the old or the complete new content is there — never a truncated mix. The content //! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and //! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp //! file and leaves the target as it was. +//! - [`write_atomic_nosync`] is the same temp file + rename without the two syncs, for +//! recreatable outputs only: a failed write still removes the temp file and leaves the target as +//! it was, but after a power cut the new file may be lost (on some file systems left empty) — +//! it is then simply exported again. //! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file //! and sync, plus a read-back check, and the file then appears under its final name **without //! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the @@ -21,6 +33,19 @@ use std::sync::atomic::{AtomicU64, Ordering}; thread_local! { static FAIL_AFTER: Cell> = const { Cell::new(None) }; + static SYNCS: Cell = const { Cell::new(0) }; +} + +/// How many file and folder syncs this module made **on the calling thread** (for tests: a +/// durable write path syncs, a recreatable output doesn't). +#[doc(hidden)] +pub fn syncs_on_this_thread() -> u64 { + SYNCS.with(Cell::get) +} + +fn sync_file(f: &File) -> io::Result<()> { + SYNCS.with(|c| c.set(c.get() + 1)); + f.sync_all() } /// Fault injection for tests (in any crate): while the returned guard lives, every write made by @@ -78,7 +103,7 @@ fn parent_of(path: &Path) -> &Path { /// where NTFS journals the rename itself, so errors are ignored. pub fn sync_dir(dir: &Path) { if let Ok(d) = File::open(dir) { - let _ = d.sync_all(); + let _ = sync_file(&d); } } @@ -100,9 +125,9 @@ fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> { Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display()))) } -/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp -/// file is removed. Returns its path and length. -fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> { +/// Write a temp file next to `path` with what `fill` writes, synced to disk when `sync`. On +/// failure the temp file is removed. Returns its path and length. +fn write_temp(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> { let (tmp, file) = create_temp(path)?; let r = (|| { let limit = FAIL_AFTER.with(Cell::get); @@ -111,7 +136,9 @@ fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<() w.flush()?; let n = w.n; let f = w.inner.into_inner().map_err(|e| e.into_error())?; - f.sync_all()?; + if sync { + sync_file(&f)?; + } Ok(n) })(); match r { @@ -132,12 +159,26 @@ pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { /// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old /// file). Returns the length written. pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result { - let (tmp, n) = write_temp(path, fill)?; + replace_with(path, true, fill) +} + +/// Replace (or create) `path` with `data` atomically but **without syncing** it to disk — only for +/// outputs that can always be recreated (exports, renders, screenshots); see the module docs. +/// Anything that can't be made again goes through [`write_atomic`]. +pub fn write_atomic_nosync(path: &Path, data: &[u8]) -> io::Result<()> { + replace_with(path, false, &mut |w| w.write_all(data)).map(|_| ()) +} + +/// Temp file (synced when `sync`) renamed over `path`, then the folder synced when `sync`. +fn replace_with(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result { + let (tmp, n) = write_temp(path, sync, fill)?; if let Err(e) = fs::rename(&tmp, path) { let _ = fs::remove_file(&tmp); return Err(e); } - sync_dir(parent_of(path)); + if sync { + sync_dir(parent_of(path)); + } Ok(n) } @@ -155,8 +196,11 @@ pub fn write_new_unique(candidates: &mut dyn Iterator, data: &[u let Some(first) = candidates.peek().cloned() else { return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write")); }; - let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?; + let (tmp, _) = write_temp(&first, true, &mut |w| w.write_all(data))?; let r = (|| { + // Kept (issue #134 point 4): these files replace raws (Convert / Copy as DNG) or become + // library photos (merges). On a local disk the read usually comes from the cache, but on a + // network volume it can go back to the server, and next to encoding a DNG it costs little. if !same_content(&tmp, data)? { return Err(io::Error::other("the written file reads back different (failing drive or connection?)")); } @@ -287,6 +331,33 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn recreatable_outputs_are_atomic_without_a_sync() { + let d = temp("nosync"); + let p = d.join("export.jpg"); + let before = syncs_on_this_thread(); + write_atomic_nosync(&p, b"one").unwrap(); + write_atomic_nosync(&p, b"two two").unwrap(); + assert_eq!(syncs_on_this_thread(), before, "no file or folder sync"); + assert_eq!(fs::read(&p).unwrap(), b"two two"); + assert_eq!(names(&d), vec!["export.jpg"], "no temp file left"); + // still atomic: a failure part-way leaves the previous file and no temp file + { + let _f = fail_writes_after(3); + assert!(write_atomic_nosync(&p, &[9u8; 10_000]).is_err()); + } + assert_eq!(fs::read(&p).unwrap(), b"two two"); + assert_eq!(names(&d), vec!["export.jpg"]); + // the durable writers do sync (the file; the folder too where it can be opened) + let before = syncs_on_this_thread(); + write_atomic(&p, b"three").unwrap(); + let mid = syncs_on_this_thread(); + assert!(mid > before); + write_new(&d.join("new.dng"), b"dng").unwrap(); + assert!(syncs_on_this_thread() > mid); + let _ = fs::remove_dir_all(&d); + } + #[test] fn new_files_never_replace_anything() { let d = temp("new"); diff --git a/crates/engine/build.rs b/crates/engine/build.rs new file mode 100644 index 00000000..6665cf44 --- /dev/null +++ b/crates/engine/build.rs @@ -0,0 +1,59 @@ +//! Optional build input: with `CRAFT_FONTS_DIR=`, embed every font +//! in its `fonts/manifest.txt` as `CRAFT_FONTS` (see src/fonts.rs). Unset, `CRAFT_FONTS` is empty. +//! 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 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), 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 _; +use std::path::PathBuf; + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + match craft_fonts(&dir) { + Ok(entries) => src.push_str(&entries), + Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + src.push_str("];\n"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// One `CraftFont { .. }` initialiser per manifest line. +fn craft_fonts(dir: &std::path::Path) -> Result { + let manifest = dir.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = String::new(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, ..] = f.as_slice() else { + return Err(format!("malformed manifest line: {line}")); + }; + if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") { + continue; + } + let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", + scripts.join(", "), + path.display().to_string(), + ); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index c81267bf..1641d63f 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -149,7 +149,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { }; let mut written = Vec::new(); let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> { - crate::export::write_file(path, bytes)?; + // durable: the TIFF becomes a library photo the external editor then changes + crate::export::write_file_durable(path, bytes)?; written.push(path.to_string()); Ok(()) }; diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index d3b63389..3dacc31f 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -160,7 +160,9 @@ fn smart_run( if damaged && crate::smart::is_valid(&path) { n.built += 1; } else { - // atomic: a failed write leaves no partial proxy that would pass for a built one + // atomic: a failed write leaves no partial proxy that would pass for a built one; + // and synced (issue #134): built so the photo can be edited while its original + // is offline, when it is the only copy — the sync is small next to the decode let r = source .ok_or_else(|| "nothing to build from".to_string()) .and_then(|s| s.load_source()) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6c30b1a0..ab0d8edf 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -244,14 +244,23 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -/// BIZ UDMincho (OFL), Japanese fallback on both native and web. -/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback. -pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf"); + +/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the +/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone, +/// and Japanese characters draw as Inter's missing-glyph box. +fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { + let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect(); + japanese.sort_by_key(|f| !f.is_mincho()); + std::iter::once(WATERMARK_FONT) + .chain(japanese.into_iter().map(|f| f.bytes)) + .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) + .collect() +} /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { let width = img.width; - watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { let p = &mut img.data[y * width + x]; for c in 0..3 { p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; @@ -264,14 +273,14 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { let (width, trc) = (img.width, img.space.trc()); match &mut img.samples { - DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let p = v[i + c] as f32; v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; } }), - DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let target = trc.decode(col[c] as f32 / 255.0); @@ -282,9 +291,16 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { } /// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for -/// every covered pixel (shadow pass first). -fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { - use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point}; +/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter +/// so tests can render without it). +fn watermark_coverage( + width: usize, + height: usize, + wm: &Watermark, + craft: &'static [crate::fonts::CraftFont], + mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), +) { + use ab_glyph::{Font, PxScale, ScaleFont, point}; if !wm.image.trim().is_empty() { logo_coverage(width, height, wm, blend_px); return; @@ -293,9 +309,8 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: if text.is_empty() || width == 0 || height == 0 { return; } - let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; - let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return }; - let fonts = [font, japanese]; + let fonts = watermark_fonts(craft); + let Some(latin) = fonts.first() else { return }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); let mut glyphs = Vec::new(); @@ -333,9 +348,11 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: '…' => '︙', other => other, }; - let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original }; - let face = usize::from(fonts[0].glyph_id(ch).0 == 0); - let sf = fonts[face].as_scaled(PxScale::from(px)); + let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0; + let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; + // The first face with the glyph; Inter (its missing-glyph box) when none has it. + let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); + let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px)); let id = sf.glyph_id(ch); if wm.vertical { let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; @@ -1131,15 +1148,27 @@ pub fn export_batch( } /// Write an exported or rendered file on disk: its folder is created if needed, and the file is -/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then -/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The -/// native writer behind exports, renders and screenshots (app, CLI, MCP). +/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file +/// renamed into place), so a failure part-way leaves any previous file intact and no truncated +/// one. Not synced to disk (issue #134): an export can always be made again from the original. +/// The native writer behind exports, renders and screenshots (app, CLI, MCP). pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, false) +} + +/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy) +/// and so can't simply be exported again once the catalog points at it. +pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, true) +} + +fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> { + use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; let p = std::path::Path::new(path); if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } - lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}")) + if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}")) } /// The path of the sidecar with extension `ext` of the exported file `main`. @@ -1334,21 +1363,46 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } - #[test] - fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() { - use ab_glyph::Font; - let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap(); - for c in "日本語".chars() { - assert_ne!(font.glyph_id(c).0, 0); - } - let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); - assert!(!old.watermark.unwrap().vertical); + fn vertical_japanese() -> Watermark { let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); - let wm = options.watermark.unwrap(); + options.watermark.unwrap() + } + + #[test] + fn japanese_watermark_options_and_legacy_defaults() { + let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); + assert!(!old.watermark.unwrap().vertical); + let wm = vertical_japanese(); assert!(wm.vertical); let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); assert!(round.vertical); + } + + /// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's + /// missing-glyph boxes), and Latin text is unaffected. + #[test] + fn watermarks_work_without_craft_fonts() { + assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); + for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { + let mut covered = 0usize; + watermark_coverage(400, 300, &wm, &[], |_, _, k, _| covered += usize::from(k > 0.0)); + assert!(covered > 0, "{:?} draws something", wm.text); + } + } + + #[test] + fn japanese_watermarks_support_vertical_columns() { + use ab_glyph::Font; + let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); + if fonts.len() < 2 { + eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + return; + } + for c in "日本語の文字".chars() { + assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}"); + } + let wm = vertical_japanese(); let mut img = Rgba8::new(400, 300); img.data.fill([0, 0, 0, 255]); draw_watermark(&mut img, &wm); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index b4c9f863..f4a57a6f 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -253,13 +253,19 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; + // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. + let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - [ - ((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0), - ((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0), - ((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0), - ] + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + match &tables { + Some(tables) => tables.apply(rgb, gain).map(|v| v.max(0.0)), + None => rgb.map(|v| (v * gain).max(0.0)), + } }); stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); @@ -280,11 +286,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< } let (temp, tint) = xy_to_temp_tint(xy); let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback; + let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; - return Ok(( - img, - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone: camera_look.map(|p| p.tone) }, - )); + return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); } let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; drop(bytes); @@ -293,6 +297,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) } +/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the +/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white +/// the camera tone's shoulder continues it. +pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { + let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); + [x, curve.eval(x).min(0.9995)] + }); + lightcraft_pipeline::tone::CameraTone::new(knots) +} + /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { if jpeg_orientation > 1 { @@ -398,6 +413,41 @@ mod tests { b } + /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is + /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. + #[test] + fn dng_profile_look_is_applied() { + use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let sat = |img: &Rgb32f| { + img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() + / img.data.len() as f32 + }; + let (before, info) = load_bytes(&plain, 64).unwrap(); + assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); + assert!(info.camera_tone.is_none()); + // a map that removes all saturation, and a tone curve + let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; + raw.color.profile = + ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) }; + let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (after, info) = load_bytes(&with, 64).unwrap(); + assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); + // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) + let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; + assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); + let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); + assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); + // a look table alone changes the render too (applied after exposure) + raw.color.profile = ProfileLook { + look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), + ..Default::default() + }; + let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); + } + #[test] fn unsupported_raw_falls_back_to_embedded_preview() { let b = cr3_with_preview(48, 32); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..954d9263 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,52 @@ +//! Fonts from the optional craft-fonts build input (storytold/craft-fonts). +//! +//! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its +//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses +//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it +//! from here. See craftrules `standards/fonts.md`. + +/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the font is for, e.g. `"Jpan"`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +impl CraftFont { + /// Whether the font is meant for `script` (ISO 15924, e.g. `"Jpan"`). + pub fn covers(&self, script: &str) -> bool { + self.scripts.contains(&script) + } + + /// Mincho (serif) faces, the preferred Japanese faces for document-like text. + pub fn is_mincho(&self) -> bool { + self.family.contains("Mincho") + } +} + +/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts). +pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator { + fonts.iter().filter(|f| f.covers("Jpan")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn craft_fonts_are_well_formed_or_absent() { + if CRAFT_FONTS.is_empty() { + eprintln!("built without CRAFT_FONTS_DIR: no craft-fonts to check"); + return; + } + for f in CRAFT_FONTS { + assert!(!f.family.is_empty() && !f.style.is_empty() && !f.scripts.is_empty(), "{}", f.family); + assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style); + } + assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); + } +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index a85d6287..6f35b670 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -375,13 +375,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress /// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…; /// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, -/// synced and compared byte for byte); a bad one is removed and the photo reported as failed. -fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result { +/// synced and checked against `probe_hash`, the content hash the probe computed — or compared +/// byte for byte with the source when there is none); a bad one is removed and the photo +/// reported as failed. +fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result { let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); let name = name .map(str::to_string) .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); - crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.to_string_lossy().to_string()) + let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); + crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string()) } /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network @@ -784,7 +787,7 @@ impl ImportJob { let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })); } - match copy_into(root, &path, &folders, name.as_deref()) { + match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) { Ok(p) => p, Err(e) => { out.items.push(PreparedItem::Failed(path, e)); diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index 7221c595..3465eaa7 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -171,9 +171,15 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result Result { +/// disk and verified; a bad copy is removed and reported, so a card is never wiped on the strength +/// of it. Returns the new path. +/// +/// `expect` is the content hash the import's probe computed from its full read of the source +/// ([`lightcraft_preview::hash_bytes`]): the copy is read back and checked against it, which spares +/// a third read of the card (issue #134). It also catches a source that changed since the probe +/// (whose recorded metadata and hash would be stale). Without one the copy is compared byte for +/// byte with the source. +pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str, expect: Option) -> Result { fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let (stem, ext) = match name.rsplit_once('.') { Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), @@ -181,7 +187,7 @@ pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result return Ok(d), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())), @@ -262,12 +268,14 @@ fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> { Err(_) => {} } } - copy_verified(src, dst) + // Move keeps the byte-for-byte comparison: the source is deleted on the strength of it + copy_verified(src, dst, None) } -/// Copy into a new file, sync it to disk and compare it with the original; on any failure the -/// partial copy is removed. -fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> { +/// Copy into a new file, sync it to disk and verify it: against `expect` (the probe's hash of the +/// source) when given, else byte for byte against the source. On any failure the partial copy is +/// removed. +pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option) -> io::Result<()> { let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?; let r = (|| { let mut inp = File::open(src)?; @@ -283,10 +291,14 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> { if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) { let _ = out.set_modified(m); } - if !same_bytes(src, dst)? { - return Err(io::Error::other("the copy differs from the original")); + match expect { + Some(h) if hash_file(dst)? != h => { + Err(io::Error::other("the copy differs from the original as it was read for the import (a bad copy, or the file changed meanwhile)")) + } + Some(_) => Ok(()), + None if !same_bytes(src, dst)? => Err(io::Error::other("the copy differs from the original")), + None => Ok(()), } - Ok(()) })(); drop(out); if r.is_err() { @@ -295,6 +307,20 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> { r } +/// [`lightcraft_preview::hash_bytes`] of a file's content, read in chunks. +fn hash_file(path: &Path) -> io::Result { + let mut f = File::open(path)?; + let mut h = lightcraft_preview::Hasher128::new(); + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = fill(&mut f, &mut buf)?; + if n == 0 { + return Ok(h.finish()); + } + h.update(buf.get(..n).unwrap_or(&[])); + } +} + /// Do two files hold the same bytes? pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result { if fs::metadata(a)?.len() != fs::metadata(b)?.len() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b76d7a15..68200a35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod demo; pub mod devices; pub mod export; pub mod files; +pub mod fonts; pub mod guard; pub mod import; mod import_move; @@ -38,6 +39,7 @@ mod view; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use fonts::{CRAFT_FONTS, CraftFont}; use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort}; use lightcraft_develop::DevelopSettings; pub use media::{RenderJob, SourceLevel}; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 7fd8a596..c61470d0 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 6; +pub const RENDER_CACHE_VERSION: u64 = 8; /// 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]; @@ -830,7 +830,9 @@ pub struct ProbeInfo { pub captured: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, - /// Hash of the file's bytes (hex), for duplicate detection. + /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit + /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), + /// Import → Copy verifies each copy against it instead of reading the source again. pub content_hash: Option, /// Lens corrections embedded in the file (DNG opcodes). pub embedded_lens: Option, diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index d20d2fe8..130d22a2 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -79,9 +79,14 @@ impl crate::Session { OriginalGuard::new(&self.catalog) } - /// `Err` when writing `path` would replace a catalogued photo's original or sidecar. + /// `Err` when writing `path` would replace a catalogued photo's original or sidecar. A free + /// path (the usual case) is answered without building the guard over the whole library. pub fn check_write_target(&self, path: &str) -> Result<(), String> { - self.original_guard().check(Path::new(path)) + let path = Path::new(path); + if std::fs::symlink_metadata(path).is_err() { + return Ok(()); // nothing there to replace + } + self.original_guard().check(path) } } diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 42260133..faba3e3c 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -349,3 +349,24 @@ fn export_protects_the_target_of_an_imported_symlink() { assert_eq!(std::fs::read(&sidecar).unwrap(), b""); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #134: exports can always be made again, so they are written atomically but not synced to +/// disk file by file; an edit copy that becomes a library photo still is. +#[test] +fn exports_are_atomic_without_a_sync() { + use crate::export::{Conflict, Destination}; + use lightcraft_catalog::safe_file::syncs_on_this_thread; + let (mut s, id, dir, _src, _) = library_with_jpeg("export-nosync"); + let out = dir.join("out"); + let to = Destination { dir: out.to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { conflict: Conflict::Unique, ..Default::default() }; + let before = syncs_on_this_thread(); + for _ in 0..3 { + disk_batch(&mut s, id, &o, &to).unwrap(); + } + assert_eq!(syncs_on_this_thread(), before, "no per-file sync for exports"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 3, "three exports, no temp files"); + crate::export::write_file_durable(&out.join("edit.tif").to_string_lossy(), b"tiff").unwrap(); + assert!(syncs_on_this_thread() > before, "the durable writer syncs"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 5eb278fe..76ea0a3d 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -207,6 +207,67 @@ fn copy_import_verifies_each_copy() { let _ = std::fs::remove_dir_all(&base); } +/// Copy verifies against the probe's content hash (issue #134): the hash, not a re-read of the +/// source, is the reference — a copy that matches the source but not the hash is refused, a +/// corrupted copy is caught, and the file hash agrees with `hash_bytes` across read chunks. +#[test] +fn copy_is_verified_against_the_probe_hash() { + use crate::import_move::copy_verified; + let base = temp_dir("copyhash"); + let src = base.join("src.bin"); + // larger than one 1 MB read, not a multiple of it + let bytes: Vec = (0..(3 << 20) + 12_345u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); + std::fs::write(&src, &bytes).unwrap(); + let good = lightcraft_preview::hash_bytes(&bytes); + copy_verified(&src, &base.join("ok.bin"), Some(good)).unwrap(); + assert_eq!(std::fs::read(base.join("ok.bin")).unwrap(), bytes); + // a hash that doesn't match: refused (a byte compare with the source would have passed) + let stale = lightcraft_preview::hash_bytes(b"what the probe read earlier"); + let e = copy_verified(&src, &base.join("stale.bin"), Some(stale)).unwrap_err(); + assert!(e.to_string().contains("differs"), "{e}"); + assert!(!base.join("stale.bin").exists(), "the refused copy is removed"); + // a copy that comes out different: caught by the hash + inject(Fault::Corrupt); + let r = copy_verified(&src, &base.join("bad.bin"), Some(good)); + inject(Fault::None); + assert!(r.is_err()); + assert!(!base.join("bad.bin").exists()); + assert_eq!(std::fs::read(&src).unwrap(), bytes, "the source is untouched"); + let _ = std::fs::remove_dir_all(&base); +} + +/// A file replaced after Review Import probed it is not imported on the strength of the old probe: +/// its copy no longer matches the probe's hash, so it's reported as failed and nothing is left. +#[test] +fn copy_import_refuses_a_file_changed_since_the_review() { + let base = temp_dir("copychanged"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 3); + let mut s = session(); + let r = s.execute("library.importPreview", &json!({"paths": [card.to_string_lossy()]})).unwrap(); + assert_eq!(r["scanned"], 1, "{r}"); + write_png(&card.join("a.png"), 9); // other pixels + let r = s + .execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}), + ) + .unwrap(); + assert_eq!(len(&r, "imported"), 0, "{r}"); + assert_eq!(len(&r, "failed"), 1, "{r}"); + assert!(r.to_string().contains("changed meanwhile"), "{r}"); + assert_eq!(files_under(&dest), Vec::::new()); + // importing again probes afresh and succeeds + let r = s + .execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}), + ) + .unwrap(); + assert_eq!(len(&r, "imported"), 1, "{r}"); + let _ = std::fs::remove_dir_all(&base); +} + /// A source that can't be removed (a read-only card) stays and is reported; the photo is /// imported from its copy. #[test] @@ -320,3 +381,55 @@ fn move_into_the_library_is_saved_before_sources_go() { assert_eq!(photo_paths(&again), [want.to_string_lossy()]); let _ = std::fs::remove_dir_all(&base); } + +/// Timing for issue #134 (not a pass/fail test): `cargo test -p lightcraft-engine --release +/// write_policy_timing -- --ignored --nocapture`. Writes N export-sized files durably (temp + +/// sync + rename) and atomically only, and copies N files verified byte for byte and against the +/// probe hash, alternating so a loaded machine affects both alike. `LIGHTCRAFT_BENCH_DIR` puts +/// the files on another volume (a USB drive or a NAS is where the difference shows). +#[test] +#[ignore] +fn write_policy_timing() { + use crate::import_move::copy_verified; + use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; + use std::time::{Duration, Instant}; + let root = std::env::var_os("LIGHTCRAFT_BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let base = root.join(format!("lc-write-policy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let data = |n: usize, seed: u32| -> Vec { (0..n as u32).map(|i| (i.wrapping_add(seed).wrapping_mul(2_654_435_761) >> 11) as u8).collect() }; + let (n, size) = (200usize, 300 << 10); + let (mut synced, mut unsynced) = (Duration::ZERO, Duration::ZERO); + for i in 0..n { + let b = data(size, i as u32); + let t = Instant::now(); + write_atomic(&base.join(format!("sync-{i}.jpg")), &b).unwrap(); + synced += t.elapsed(); + let t = Instant::now(); + write_atomic_nosync(&base.join(format!("nosync-{i}.jpg")), &b).unwrap(); + unsynced += t.elapsed(); + } + println!("export {n} × {} KB: write_atomic (synced) {synced:?}, write_atomic_nosync {unsynced:?}", size >> 10); + let (n, size) = (40usize, 8 << 20); + let card = base.join("card"); + std::fs::create_dir_all(&card).unwrap(); + let hashes: Vec<_> = (0..n) + .map(|i| { + let b = data(size, 7 * i as u32); + std::fs::write(card.join(format!("{i}.raw")), &b).unwrap(); + lightcraft_preview::hash_bytes(&b) + }) + .collect(); + let (mut bytewise, mut hashed) = (Duration::ZERO, Duration::ZERO); + for (i, h) in hashes.iter().enumerate() { + let src = card.join(format!("{i}.raw")); + let t = Instant::now(); + copy_verified(&src, &base.join(format!("bytes-{i}.raw")), None).unwrap(); + bytewise += t.elapsed(); + let t = Instant::now(); + copy_verified(&src, &base.join(format!("hash-{i}.raw")), Some(*h)).unwrap(); + hashed += t.elapsed(); + } + println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs new file mode 100644 index 00000000..5c50ca04 --- /dev/null +++ b/crates/gpu/src/backend.rs @@ -0,0 +1,234 @@ +//! Which wgpu backends LightCraft lets wgpu use — for its compute device and for the desktop +//! window (eframe) — and the crash sentinel around device creation (issue #136). +//! +//! wgpu loads the system driver of *every* backend in the instance's set while it enumerates +//! adapters, even when it then picks another one: with Vulkan in the set, a broken Vulkan driver +//! can crash the process (an access violation inside the driver DLL, which nothing can catch) +//! although DX12 would have been used. So Windows defaults to DX12 alone. macOS uses Metal, Linux +//! Vulkan (plus GL for the window when the build has it). +//! +//! Overrides, read once per process: +//! - `LIGHTCRAFT_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto` +//! (the platform default), or `off` (no GPU compute; the window still needs one backend and +//! keeps the platform default); +//! - else `WGPU_BACKEND` (wgpu's own variable, same names) for both the window and compute. +//! +//! Backends this build doesn't contain (e.g. `gl`: the GL backend isn't compiled in) are ignored +//! with a warning and the platform default is used. + +use std::path::PathBuf; +use std::sync::Mutex; + +pub use wgpu::Backends; + +/// What the environment asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackendChoice { + /// The platform default. + Auto, + /// No GPU compute. + Off, + /// These backends. + Use(Backends), +} + +/// Parse a backend list (`"dx12"`, `"vulkan,gl"`, `"auto"`, `"off"`…). `None`: nothing usable in +/// it (empty or unknown names). +pub fn parse_backends(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + match s.as_str() { + "" => None, + "auto" | "default" => Some(BackendChoice::Auto), + "off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off), + _ => { + let b = Backends::from_comma_list(&s); + (!b.is_empty()).then_some(BackendChoice::Use(b)) + } + } +} + +/// The choice from `LIGHTCRAFT_GPU_BACKEND` (`lightcraft`) or else `WGPU_BACKEND` (`wgpu`): the +/// first one that parses wins; an unknown value is skipped (with a warning). +pub fn choose(lightcraft: Option<&str>, wgpu: Option<&str>) -> BackendChoice { + for (name, v) in [("LIGHTCRAFT_GPU_BACKEND", lightcraft), ("WGPU_BACKEND", wgpu)] { + let Some(v) = v else { continue }; + match parse_backends(v) { + // WGPU_BACKEND is wgpu's variable: it selects backends, it never turns the GPU off + Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use LIGHTCRAFT_GPU_BACKEND=off)"), + Some(c) => return c, + None => log::warn!("gpu: {name}={v} names no known backend; ignored"), + } + } + BackendChoice::Auto +} + +/// The backends compute devices use when nothing is asked for on `os` (`std::env::consts::OS`). +pub fn default_compute_backends(os: &str) -> Backends { + match os { + // never touch the Vulkan driver unless asked: see the module docs (issue #136) + "windows" => Backends::DX12, + "macos" | "ios" => Backends::METAL, + _ => Backends::PRIMARY, + } +} + +/// The backends the window uses when nothing is asked for on `os`. +pub fn default_window_backends(os: &str) -> Backends { + match os { + "windows" => Backends::DX12, + "macos" | "ios" => Backends::METAL, + // eframe's default (GL only when the build has it) + _ => Backends::PRIMARY | Backends::GL, + } +} + +/// Resolve `choice` against the backends this build contains (`compiled`): `None` = GPU off. +/// Asked-for backends that aren't compiled in fall back to `default`. +pub fn resolve(choice: BackendChoice, default: Backends, compiled: Backends) -> Option { + match choice { + BackendChoice::Off => None, + BackendChoice::Auto => Some(default), + BackendChoice::Use(b) if b.intersects(compiled) => Some(b & compiled), + BackendChoice::Use(b) => { + log::warn!("gpu: backend {b:?} is not in this build ({compiled:?}); using {default:?}"); + Some(default) + } + } +} + +fn env_choice() -> BackendChoice { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + *C.get_or_init(|| { + let lc = std::env::var("LIGHTCRAFT_GPU_BACKEND").ok(); + let wg = std::env::var("WGPU_BACKEND").ok(); + choose(lc.as_deref(), wg.as_deref()) + }) +} + +/// The backends of the compute device (`None`: `LIGHTCRAFT_GPU_BACKEND=off`). +pub fn compute_backends() -> Option { + resolve(env_choice(), default_compute_backends(std::env::consts::OS), wgpu::Instance::enabled_backend_features()) +} + +/// The backends for the desktop window's renderer (eframe/egui-wgpu). The window always needs +/// one: `off` keeps the platform default. +pub fn window_backends() -> Backends { + let default = default_window_backends(std::env::consts::OS); + let choice = match env_choice() { + BackendChoice::Off => BackendChoice::Auto, + c => c, + }; + resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default) +} + +/// `LIGHTCRAFT_GPU_BACKEND=off`. +pub(crate) fn env_off() -> bool { + env_choice() == BackendChoice::Off +} + +static MARKER: Mutex> = Mutex::new(None); + +/// Write this file before the compute device is created and remove it once creation returned +/// (successfully or not). A file still there at the next launch means the process died inside +/// the driver: see [`take_init_marker`]. `None` (the default) writes nothing. +pub fn set_init_marker(path: Option) { + *MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path; +} + +/// Run device creation `f` between writing and removing the init marker (if one is set). +pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> T { + let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(m) = &marker { + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let _ = std::fs::write(m, format!("GPU device creation started (backends {backends:?}, unix time {secs})\n")); + } + let r = f(); + if let Some(m) = &marker { + let _ = std::fs::remove_file(m); + } + r +} + +/// If the init marker `path` is present — the last process died while creating the GPU device — +/// remove it and return what it recorded. +pub fn take_init_marker(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let _ = std::fs::remove_file(path); + Some(text.trim().to_string()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parses_backend_names() { + assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12))); + assert_eq!(parse_backends(" D3D12 "), Some(BackendChoice::Use(Backends::DX12))); + assert_eq!(parse_backends("vulkan"), Some(BackendChoice::Use(Backends::VULKAN))); + assert_eq!(parse_backends("vk,gl"), Some(BackendChoice::Use(Backends::VULKAN | Backends::GL))); + assert_eq!(parse_backends("metal"), Some(BackendChoice::Use(Backends::METAL))); + assert_eq!(parse_backends("auto"), Some(BackendChoice::Auto)); + for off in ["off", "OFF", "none", "cpu", "0"] { + assert_eq!(parse_backends(off), Some(BackendChoice::Off), "{off}"); + } + assert_eq!(parse_backends(""), None); + assert_eq!(parse_backends("directx9"), None); + } + + #[test] + fn lightcraft_variable_wins_over_wgpu_backend() { + assert_eq!(choose(None, None), BackendChoice::Auto); + assert_eq!(choose(None, Some("dx12")), BackendChoice::Use(Backends::DX12)); + assert_eq!(choose(Some("vulkan"), Some("dx12")), BackendChoice::Use(Backends::VULKAN)); + assert_eq!(choose(Some("off"), Some("dx12")), BackendChoice::Off); + // unknown LightCraft value: WGPU_BACKEND still applies + assert_eq!(choose(Some("bogus"), Some("gl")), BackendChoice::Use(Backends::GL)); + // WGPU_BACKEND never turns the GPU off + assert_eq!(choose(None, Some("off")), BackendChoice::Auto); + } + + /// Issue #136: Windows must not load the Vulkan driver unless asked to. + #[test] + fn windows_defaults_to_dx12_only() { + assert_eq!(default_compute_backends("windows"), Backends::DX12); + assert_eq!(default_window_backends("windows"), Backends::DX12); + assert!(!default_window_backends("windows").contains(Backends::VULKAN)); + assert_eq!(default_compute_backends("macos"), Backends::METAL); + assert!(default_compute_backends("linux").contains(Backends::VULKAN)); + assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL)); + } + + #[test] + fn resolves_against_the_build() { + let compiled = Backends::DX12 | Backends::VULKAN; + let def = Backends::DX12; + assert_eq!(resolve(BackendChoice::Auto, def, compiled), Some(def)); + assert_eq!(resolve(BackendChoice::Off, def, compiled), None); + assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN), def, compiled), Some(Backends::VULKAN)); + assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN | Backends::GL), def, compiled), Some(Backends::VULKAN)); + // GL isn't compiled in: the default, not an empty set (no adapter at all) + assert_eq!(resolve(BackendChoice::Use(Backends::GL), def, compiled), Some(def)); + } + + #[test] + fn init_marker_is_written_during_creation_and_removed_after() { + let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + set_init_marker(Some(m.clone())); + let seen = with_init_marker(Backends::DX12, || m.exists()); + set_init_marker(None); + assert!(seen, "the marker exists while the device is created"); + assert!(!m.exists(), "and is removed afterwards"); + // a marker left behind by a crashed process is reported once + std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap(); + assert!(take_init_marker(&m).unwrap().contains("DX12")); + assert_eq!(take_init_marker(&m), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 0659bddf..0d69914d 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -276,14 +276,15 @@ fn module_source(m: &Module, consts: &str) -> String { impl Gpu { /// Create a device on the best available adapter (no software fallback), or why there is none. - pub fn new() -> Result { + pub fn new(backends: wgpu::Backends) -> Result { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); - desc.backends = wgpu::Backends::PRIMARY; + // only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked) + desc.backends = backends; let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on( instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), ) - .map_err(|e| format!("no GPU adapter ({e})"))?; + .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?; let info = adapter.get_info(); if info.device_type == wgpu::DeviceType::Cpu { log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 449b1e5a..70d6246a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -6,8 +6,11 @@ //! same settings on both and bound the difference in 8-bit sRGB. Stages without a kernel run on the //! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`. //! -//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or -//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out +//! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and +//! the crash sentinel around device creation: [`backend`] (issue #136). +//! +//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, +//! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out //! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`] //! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here @@ -22,6 +25,8 @@ use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_raster::Rgb32f; +#[cfg(not(target_arch = "wasm32"))] +pub mod backend; #[cfg(not(target_arch = "wasm32"))] mod ctx; #[cfg(not(target_arch = "wasm32"))] @@ -61,6 +66,10 @@ fn record_fallback(reason: String) { /// Never blocks. pub fn unavailable_reason() -> Option { if env_disabled() { + #[cfg(not(target_arch = "wasm32"))] + if backend::env_off() { + return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()); + } return Some("disabled by LIGHTCRAFT_GPU=0".into()); } if !ENABLED.load(Ordering::Relaxed) { @@ -136,9 +145,15 @@ pub fn enabled() -> bool { ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() } +/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process. fn env_disabled() -> bool { static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); - *OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"))) + *OFF.get_or_init(|| { + let off = std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + #[cfg(not(target_arch = "wasm32"))] + let off = off || backend::env_off(); + off + }) } #[cfg(not(target_arch = "wasm32"))] @@ -146,10 +161,18 @@ static GPU: std::sync::OnceLock> = std::sync::OnceLock: #[cfg(not(target_arch = "wasm32"))] pub(crate) fn device() -> Option<&'static ctx::Gpu> { - if env_disabled() { - return None; + // turned off (preference, environment): don't even load the driver (issue #136) + if env_disabled() || !ENABLED.load(Ordering::Relaxed) { + return existing_device(); } - GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok() + GPU.get_or_init(|| { + let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + backend::with_init_marker(backends, || { + std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) + }) + }) + .as_ref() + .ok() } /// The device if it has been created (never creates it). @@ -158,11 +181,12 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { GPU.get().and_then(|g| g.as_ref().ok()) } -/// Create the device and compile the kernels on a background thread now (app start), so the -/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. +/// Create the device and compile the kernels on a background thread now (once the window is up), +/// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing +/// while GPU rendering is turned off. pub fn warm_up() { #[cfg(not(target_arch = "wasm32"))] - if !env_disabled() && GPU.get().is_none() { + if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() { let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { let _ = device(); }); diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 3d0bbfe5..442fa2ee 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -45,6 +45,7 @@ pub fn color_data(color: &FrameColor) -> ColorData { as_shot_neutral: Some([1.0, 1.0, 1.0]), as_shot_white_xy: None, baseline_exposure: 0.0, + profile: Default::default(), } } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index a7ef1987..1e80c0b3 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -1,6 +1,7 @@ //! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]), //! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists. +use crate::profile::{HsvTable, ProfileLook, ToneCurve}; use crate::tiffraw::{Packing, read_image_in}; use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; use lightcraft_color::Xy; @@ -45,6 +46,27 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData { as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])), baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0) + get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0), + profile: profile_look(ifd0, raw), + } +} + +/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed +/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0. +pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook { + let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 }; + let table = |dims: u16, data: u16, enc: u16| { + let ifd = pick(dims); + let dims = ifd.u64s(dims)?; + let data = ifd.f64s(data)?; + HsvTable::from_tags(&dims, &data, ifd.u64s(enc).and_then(|v| v.first().copied()).unwrap_or(0)) + }; + ProfileLook { + hue_sat_map: [ + table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_1, t::PROFILE_HUE_SAT_MAP_ENCODING), + table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_2, t::PROFILE_HUE_SAT_MAP_ENCODING), + ], + look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING), + tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)), } } diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index ac8ce799..3df8d706 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set(t::AS_SHOT_WHITE_XY, rat_vec(&[xy.x, xy.y])); } ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)])); + // the source's own profile look travels with its data (conversions, smart previews, merges) + let p = &c.profile; + let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { + ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); + if table.srgb_value { + ifd.set(enc, Value::Long(vec![1])); + } + }; + let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); + match &p.hue_sat_map { + [Some(a), b] => { + table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a)); + if let Some(b) = + b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions)) + { + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b)); + } + } + [None, Some(b)] => { + table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b)); + } + [None, None] => {} + } + if let Some(l) = &p.look_table { + table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); + ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l)); + } + if let Some(curve) = &p.tone_curve { + ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); + } for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { if !list.is_empty() { ifd.set(tag, Value::Undefined(opcodes::write_list(list))); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 29e280b6..d4791e7f 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -8,7 +8,8 @@ //! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] //! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) -//! and produces camera → linear Rec.2020 D65 matrices. +//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile +//! look tables and tone curve. //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), //! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer @@ -29,6 +30,7 @@ pub mod highlight; pub mod ljpeg; pub mod opcodes; mod preview; +pub mod profile; mod tiffraw; mod unpack; mod vendor; @@ -393,6 +395,10 @@ pub struct ColorData { pub as_shot_white_xy: Option, /// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`). pub baseline_exposure: f64, + /// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`, + /// `ProfileToneCurve`), applied by [`color`]'s users at render time. + #[serde(default)] + pub profile: profile::ProfileLook, } /// A decoded raw image. diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs new file mode 100644 index 00000000..8b9761ab --- /dev/null +++ b/crates/raw/src/profile.rs @@ -0,0 +1,387 @@ +//! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7, +//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags). +//! +//! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift +//! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor). +//! Hue samples are spread evenly around the circle with wrap-around, saturation and value samples +//! evenly over 0..=1; a dimension with one division is constant. Lookups are tri-linear. +//! - Tables are applied in HSV of linear ProPhoto RGB (D50). With encoding 1 ("sRGB") the value +//! axis is indexed through the sRGB transfer curve (3D tables only). +//! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant +//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve. +//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1). +//! +//! The data is read from the user's own file at run time; nothing here ships profile data. +//! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever +//! lowers them, so a lookup can't clip what the scene-referred pipeline still needs. + +use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear}; +use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford}; +use serde::{Deserialize, Serialize}; + +/// Upper bound on table entries (Adobe profiles use ≤ 90 × 30 × 16; guards header-driven sizes). +const MAX_ENTRIES: usize = 1 << 20; + +/// A DNG hue/saturation/value mapping table (`ProfileHueSatMapData*` / `ProfileLookTableData`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HsvTable { + pub hue_divisions: usize, + pub sat_divisions: usize, + pub val_divisions: usize, + /// `(hue shift °, saturation scale, value scale)`, value-major, hue-middle, saturation-minor. + pub data: Vec<[f32; 3]>, + /// The value axis is indexed through the sRGB encoding curve (`…Encoding` = 1). + pub srgb_value: bool, +} + +impl HsvTable { + /// A table from its `…Dims` (hue, saturation, value divisions), data floats and encoding. + /// `None` when the dimensions are out of range, the data length doesn't match or a value is + /// not finite. + pub fn from_tags(dims: &[u64], data: &[f64], encoding: u64) -> Option { + let [h, s, v] = <[u64; 3]>::try_from(dims).ok()?; + let (h, s, v) = (usize::try_from(h).ok()?, usize::try_from(s).ok()?, usize::try_from(v).ok()?); + if h < 1 || s < 2 || v < 1 || h > 4096 || s > 4096 || v > 4096 { + return None; + } + let n = h.checked_mul(s)?.checked_mul(v)?; + if n > MAX_ENTRIES || data.len() != n * 3 || data.iter().any(|x| !x.is_finite()) { + return None; + } + let data = data.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, (c[1] as f32).max(0.0), (c[2] as f32).max(0.0)]).collect(); + Some(HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value: encoding == 1 && v > 1 }) + } + + /// The entry-wise blend `g · a + (1 − g) · b` of two tables of the same shape (`None` otherwise). + pub fn blend(a: &HsvTable, b: &HsvTable, g: f64) -> Option { + if (a.hue_divisions, a.sat_divisions, a.val_divisions) != (b.hue_divisions, b.sat_divisions, b.val_divisions) { + return None; + } + let g = g.clamp(0.0, 1.0) as f32; + let data = a.data.iter().zip(&b.data).map(|(x, y)| std::array::from_fn(|i| g * x[i] + (1.0 - g) * y[i])).collect(); + Some(HsvTable { data, ..a.clone() }) + } + + fn entry(&self, v: usize, h: usize, s: usize) -> [f32; 3] { + self.data.get((v * self.hue_divisions + h) * self.sat_divisions + s).copied().unwrap_or([0.0, 1.0, 1.0]) + } + + /// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates). + pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] { + let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); + let h0 = (hf as usize).min(self.hue_divisions - 1); + let h1 = (h0 + 1) % self.hue_divisions; + let th = (hf - h0 as f32).clamp(0.0, 1.0); + let axis = |x: f32, n: usize| { + let f = x.clamp(0.0, 1.0) * (n - 1) as f32; + let i0 = (f as usize).min(n.saturating_sub(2)); + let i1 = (i0 + 1).min(n - 1); + (i0, i1, (f - i0 as f32).clamp(0.0, 1.0)) + }; + let (s0, s1, ts) = axis(s, self.sat_divisions); + let (v0, v1, tv) = if self.val_divisions > 1 { axis(v, self.val_divisions) } else { (0, 0, 0.0) }; + let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) }; + let plane = |vi: usize| { + let lo = lerp(self.entry(vi, h0, s0), self.entry(vi, h0, s1), ts); + let hi = lerp(self.entry(vi, h1, s0), self.entry(vi, h1, s1), ts); + lerp(lo, hi, th) + }; + if v0 == v1 { plane(v0) } else { lerp(plane(v0), plane(v1), tv) } + } + + /// Apply the table to one linear ProPhoto RGB colour. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + if max.is_nan() || max <= 0.0 || !max.is_finite() { + return rgb; + } + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let d = max - min; + let hue = hue_of(rgb, max, d); + let sat = d / max; + let v_index = if self.srgb_value { linear_to_srgb(max.min(1.0)) } else { max }; + let [shift, ss, vs] = self.lookup(hue, sat, v_index); + // saturation and value: scaled and clipped to 1.0 (DNG chapter 6), except that inputs + // already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered. + let s2 = limited(sat, ss); + let v2 = if self.srgb_value && max <= 1.0 { + srgb_to_linear((linear_to_srgb(max) * vs).min(1.0)) + } else if self.srgb_value { + max * srgb_to_linear(vs.min(1.0)).min(1.0) + } else { + limited(max, vs) + }; + hsv_to_rgb(hue + shift, s2, v2) + } +} + +/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). +fn limited(x: f32, k: f32) -> f32 { + if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) } +} + +fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { + if d <= 0.0 { + return 0.0; + } + let h = if max == c[0] { + ((c[1] - c[2]) / d).rem_euclid(6.0) + } else if max == c[1] { + (c[2] - c[0]) / d + 2.0 + } else { + (c[0] - c[1]) / d + 4.0 + }; + h * 60.0 +} + +/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of +/// [`hue_of`] for out-of-gamut inputs). +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let hp = h.rem_euclid(360.0) / 60.0; + let sector = (hp as u32).min(5); + let f = hp - sector as f32; + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + match sector { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + } +} + +/// A DNG tone curve (`ProfileToneCurve`): linear-gamma (input, output) pairs, increasing input. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToneCurve { + pub points: Vec<[f32; 2]>, +} + +impl ToneCurve { + /// From the tag's flat float pairs. `None` unless there are 2..=65536 finite pairs within + /// 0..=1 with strictly increasing inputs. + pub fn from_tag(v: &[f64]) -> Option { + if !v.len().is_multiple_of(2) || v.len() < 4 || v.len() > 131_072 { + return None; + } + let points: Vec<[f32; 2]> = v.as_chunks::<2>().0.iter().map(|p| [p[0] as f32, p[1] as f32]).collect(); + let ok = points.iter().all(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))) && points.windows(2).all(|w| w[1][0] > w[0][0]); + ok.then_some(ToneCurve { points }) + } + + /// The curve at `x` (0..=1): monotone cubic (Fritsch–Carlson) through the samples. + pub fn eval(&self, x: f32) -> f32 { + let p = &self.points; + let n = p.len(); + let x = x.clamp(p[0][0], p[n - 1][0]); + let i = p.partition_point(|q| q[0] <= x).clamp(1, n - 1) - 1; + let slope = |k: usize| (p[k + 1][1] - p[k][1]) / (p[k + 1][0] - p[k][0]); + let tangent = |k: usize| -> f32 { + if k == 0 { + return slope(0); + } + if k == n - 1 { + return slope(n - 2); + } + let (a, b) = (slope(k - 1), slope(k)); + if a * b <= 0.0 { 0.0 } else { 2.0 / (1.0 / a + 1.0 / b) } + }; + let (x0, x1) = (p[i][0], p[i + 1][0]); + let h = x1 - x0; + let t = (x - x0) / h; + let (t2, t3) = (t * t, t * t * t); + let y = (2.0 * t3 - 3.0 * t2 + 1.0) * p[i][1] + + (t3 - 2.0 * t2 + t) * h * tangent(i) + + (-2.0 * t3 + 3.0 * t2) * p[i + 1][1] + + (t3 - t2) * h * tangent(i + 1); + y.clamp(0.0, 1.0) + } +} + +/// The DNG profile look tags of a file (all optional). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ProfileLook { + /// `ProfileHueSatMapData1/2` (same shape, `ProfileHueSatMapDims`). + pub hue_sat_map: [Option; 2], + /// `ProfileLookTableData`. + pub look_table: Option, + /// `ProfileToneCurve`. + pub tone_curve: Option, +} + +impl ProfileLook { + pub fn is_empty(&self) -> bool { + self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none() + } + + /// The hue/saturation map for calibration-1 weight `g` (one table: used for any white). + pub fn hue_sat_for(&self, g: f64) -> Option { + match &self.hue_sat_map { + [Some(a), Some(b)] => HsvTable::blend(a, b, g).or_else(|| Some(a.clone())), + [Some(a), None] | [None, Some(a)] => Some(a.clone()), + [None, None] => None, + } + } +} + +/// The tables resolved for one white balance, applied to linear Rec.2020 D65 pixels. +#[derive(Clone, Debug)] +pub struct ProfileTables { + hue_sat: Option, + look: Option, + to_prophoto: [[f32; 3]; 3], + from_prophoto: [[f32; 3]; 3], +} + +impl ProfileTables { + /// `None` when the profile has no hue/saturation map and no look table. + pub fn new(look: &ProfileLook, illuminant_weight: f64) -> Option { + let hue_sat = look.hue_sat_for(illuminant_weight); + if hue_sat.is_none() && look.look_table.is_none() { + return None; + } + let to: Mat3 = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()); + let from = to.inverse()?; + Some(ProfileTables { hue_sat, look: look.look_table.clone(), to_prophoto: to.to_f32(), from_prophoto: from.to_f32() }) + } + + /// Hue/saturation map, then `gain` (exposure compensation), then the look table: + /// linear Rec.2020 D65 in, linear Rec.2020 D65 out. + #[inline] + pub fn apply(&self, rgb: [f32; 3], gain: f32) -> [f32; 3] { + let m = &self.to_prophoto; + let mut p: [f32; 3] = std::array::from_fn(|i| m[i][0] * rgb[0] + m[i][1] * rgb[1] + m[i][2] * rgb[2]); + if let Some(t) = &self.hue_sat { + p = t.apply(p); + } + p = p.map(|v| v * gain); + if let Some(t) = &self.look { + p = t.apply(p); + } + let m = &self.from_prophoto; + std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(h: usize, s: usize, v: usize) -> HsvTable { + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false } + } + + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + #[test] + fn identity_table_is_identity() { + let t = identity(6, 3, 1); + for c in [[0.2, 0.5, 0.1], [0.9, 0.9, 0.9], [0.0, 0.0, 0.4], [3.0, 1.0, 0.5], [0.3, -0.05, 0.2]] { + assert!(close(t.apply(c), c, 1e-5), "{c:?} → {:?}", t.apply(c)); + } + assert_eq!(t.apply([0.0; 3]), [0.0; 3]); + assert_eq!(t.apply([-1.0, -2.0, -0.5]), [-1.0, -2.0, -0.5]); + } + + #[test] + fn parses_tags_and_rejects_bad_shapes() { + let data: Vec = (0..4).flat_map(|_| [0.0, 1.0, 1.0]).collect(); + assert!(HsvTable::from_tags(&[2, 2, 1], &data, 0).is_some()); + assert!(HsvTable::from_tags(&[2, 2], &data, 0).is_none()); + assert!(HsvTable::from_tags(&[2, 1, 2], &data, 0).is_none(), "saturation needs ≥ 2 divisions"); + assert!(HsvTable::from_tags(&[2, 2, 2], &data, 0).is_none(), "length mismatch"); + assert!(HsvTable::from_tags(&[u64::MAX, 2, 2], &data, 0).is_none()); + let mut nan = data.clone(); + nan[1] = f64::NAN; + assert!(HsvTable::from_tags(&[2, 2, 1], &nan, 0).is_none()); + // the sRGB encoding only applies to 3D tables + assert!(!HsvTable::from_tags(&[2, 2, 1], &data, 1).unwrap().srgb_value); + } + + #[test] + fn saturation_scale_follows_the_table_layout() { + // 4 hues (0°, 90°, 180°, 270°), 2 saturations; saturation-minor: entry (h, s) at h·2 + s. + let mut t = identity(4, 2, 1); + t.data[1] = [0.0, 1.5, 1.0]; // hue 0°, saturation 1 → ×1.5 + let red = [0.5, 0.4, 0.4]; // hue 0°, saturation 0.2 + let out = t.apply(red); + // half-way along the saturation axis is ×1.1 (0.2 → 0.22 sat), value kept + let sat = (out[0] - out[1].min(out[2])) / out[0]; + assert!((sat - 0.2 * 1.1).abs() < 1e-4, "{out:?}"); + assert!((out[0] - 0.5).abs() < 1e-6); + // other hues are untouched (hue 180°) + let cyan = [0.3, 0.4, 0.4]; + assert!(close(t.apply(cyan), cyan, 1e-6)); + // saturation clips to 1.0 + assert!(t.apply([1.0, 0.1, 0.1]).iter().all(|v| *v >= -1e-6)); + } + + #[test] + fn hue_wraps_and_value_scales() { + // 2 hues: entries at 0° and 180°, hue shift +20° at 0°, value ×0.5 at 180°. + let mut t = identity(2, 2, 1); + t.data = vec![[20.0, 1.0, 1.0], [20.0, 1.0, 1.0], [0.0, 1.0, 0.5], [0.0, 1.0, 0.5]]; + // 350° sits between the last sample (180°) and the first (0° = 360°), 10/180 from 360 + assert!((t.lookup(350.0, 0.5, 0.5)[0] - 20.0 * (170.0 / 180.0)).abs() < 1e-3); + assert!((t.lookup(180.0, 0.5, 0.5)[2] - 0.5).abs() < 1e-6); + // headroom above 1 only goes down + let bright = [2.0, 2.4, 2.4]; + let out = t.apply(bright); + assert!((out[1] - 1.2).abs() < 1e-5, "{out:?}"); + let mut up = identity(1, 2, 1); + up.data = vec![[0.0, 1.0, 2.0]; 2]; + assert!((up.apply([0.4, 0.4, 0.4])[0] - 0.8).abs() < 1e-6); + assert!((up.apply([0.8, 0.8, 0.8])[0] - 1.0).abs() < 1e-6, "clipped to 1"); + assert!((up.apply([3.0, 3.0, 3.0])[0] - 3.0).abs() < 1e-6, "headroom kept"); + } + + #[test] + fn value_axis_and_srgb_encoding() { + // 1 hue, 2 sats, 2 values: saturation ×2 at value 1 only + let mut t = identity(1, 2, 2); + t.data[3] = [0.0, 2.0, 1.0]; + let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0]; + let dark = [0.25, 0.2, 0.2]; // V 0.25, S 0.2 + // linear: 1/4 of the way → S scale at s=0.2: lerp(1, lerp(1,2,0.2)=1.2, 0.25) = 1.05 + assert!((sat(t.apply(dark)) - 0.2 * 1.05).abs() < 1e-4); + t.srgb_value = true; + let k = linear_to_srgb(0.25); // ≈ 0.537 + let want = 0.2 * (1.0 + 0.2 * k); + assert!((sat(t.apply(dark)) - want).abs() < 1e-4); + } + + #[test] + fn blend_and_tables_round_trip_through_prophoto() { + let a = identity(2, 2, 1); + let mut b = identity(2, 2, 1); + b.data[1] = [10.0, 2.0, 1.0]; + let m = HsvTable::blend(&a, &b, 0.25).unwrap(); + assert_eq!(m.data[1], [7.5, 1.75, 1.0]); + assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none()); + let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None }; + let t = ProfileTables::new(&look, 0.5).unwrap(); + let c = [0.3, 0.2, 0.1]; + assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0)); + assert!(ProfileTables::new(&ProfileLook::default(), 0.5).is_none()); + } + + #[test] + fn tone_curve_interpolates_monotonically() { + assert!(ToneCurve::from_tag(&[0.0, 0.0]).is_none()); + assert!(ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.6, 0.4, 0.7, 1.0, 1.0]).is_none(), "inputs must increase"); + assert!(ToneCurve::from_tag(&[0.0, 0.0, 1.0, 1.5]).is_none()); + let c = ToneCurve::from_tag(&[0.0, 0.0, 0.25, 0.4, 0.5, 0.7, 1.0, 1.0]).unwrap(); + assert_eq!(c.eval(0.0), 0.0); + assert!((c.eval(0.25) - 0.4).abs() < 1e-6); + assert!((c.eval(1.0) - 1.0).abs() < 1e-6); + let mut last = 0.0; + for i in 0..=100 { + let y = c.eval(i as f32 / 100.0); + assert!(y >= last - 1e-6, "monotone at {i}"); + last = y; + } + assert!(c.eval(0.1) > 0.1 && c.eval(0.1) < 0.4); + } +} diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index a9eb2c7d..fc019743 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -14,6 +14,12 @@ //! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14 //! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits. //! +//! White balance: the raw IFD's `0x7313` levels when present (bodies from about 2017 on); otherwise the +//! `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]). Without +//! either, an ARW opened with unit multipliers, i.e. a strong green cast (issue #148). Black level: the raw IFD's +//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]; 800 rather than the +//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go //! through the generic TIFF path. @@ -23,7 +29,7 @@ use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, Ra use lightcraft_geom::Orientation; use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; use lightcraft_tiff::tags::{self as t, photometric}; -use lightcraft_tiff::{Ifd, Tiff}; +use lightcraft_tiff::{Ifd, Tiff, makernote}; use rayon::prelude::*; const TONE_CURVE: u16 = 0x7010; @@ -31,6 +37,137 @@ const BLACK_LEVEL: u16 = 0x7310; const WB_RGGB: u16 = 0x7313; const CROP_TOP_LEFT: u16 = 0x74c7; const CROP_SIZE: u16 = 0x74c8; +/// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). +const MN_TAG2010: u16 = 0x2010; +const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; + +/// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of +/// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was +/// consulted). The substitution, checked black-box on CC0 raw.pixls.us samples: every byte `p < 249` is stored as +/// `p³ mod 249` (a bijection on 0..249, as 3 is coprime to φ(249) = 164) and bytes 249–255 are stored unchanged. +/// Verified on 15 files from 13 bodies covering seven `Tag2010` layouts: the deciphered `SonyISO` field encodes +/// the Exif ISO (`100 · 2^(16 − v/256)`), the green level is 255–256 throughout, and on the ILCE-7M3 the deciphered +/// `WB_RGBLevels` equal the plain raw-IFD `0x7313` levels. +const DECIPHER: [u8; 256] = { + let mut inv = [0u8; 256]; + let mut p = 0usize; + while p < 256 { + let c = if p < 249 { p * p % 249 * p % 249 } else { p }; + inv[c] = p as u8; + p += 1; + } + inv +}; + +/// `Tag2010` layouts (ExifTool "Sony Tag2010a" … "Tag2010i" tables): the models each is documented for and the +/// byte offset of `WB_RGBLevels` (three `u16`, the as-shot white balance as R, G, B gains; Tag2010g and h share +/// the offset). Newer bodies (Tag2010i) also write the levels in plain form in the raw IFD (`0x7313`), which takes +/// precedence. +const TAG2010_WB: &[(&[&str], usize)] = &[ + (&["NEX-5N"], 4476), + (&["SLT-A65", "SLT-A77", "NEX-7", "NEX-VG20E"], 4480), + (&["SLT-A37", "SLT-A57", "NEX-F3"], 4444), + (&["DSC-HX10V", "DSC-HX20V", "DSC-HX200V", "DSC-TX66", "DSC-TX200V", "DSC-TX300V", "DSC-WX50", "DSC-WX100", "DSC-WX150"], 4568), + ( + &[ + "SLT-A58", + "SLT-A99", + "ILCE-3000", + "ILCE-3500", + "NEX-3N", + "NEX-5R", + "NEX-5T", + "NEX-6", + "NEX-VG30E", + "NEX-VG900", + "DSC-RX100", + "DSC-RX1", + "DSC-RX1R", + "DSC-HX300", + "DSC-HX50V", + "DSC-TX30", + "DSC-WX60", + "DSC-WX200", + "DSC-WX300", + ], + 4532, + ), + (&["DSC-RX100M2", "DSC-QX10", "DSC-QX100"], 4204), + ( + &[ + "DSC-HX60V", + "DSC-HX350", + "DSC-HX400V", + "DSC-QX30", + "DSC-RX10", + "DSC-RX100M3", + "DSC-WX220", + "DSC-WX350", + "ILCE-7", + "ILCE-7R", + "ILCE-7S", + "ILCE-7M2", + "ILCE-5000", + "ILCE-5100", + "ILCE-6000", + "ILCE-QX1", + "ILCA-68", + "ILCA-77M2", + "DSC-HX80", + "DSC-HX90V", + "DSC-RX0", + "DSC-RX1RM2", + "DSC-RX10M2", + "DSC-RX10M3", + "DSC-RX100M4", + "DSC-RX100M5", + "DSC-WX500", + "ILCE-6300", + "ILCE-6500", + "ILCE-7RM2", + "ILCE-7SM2", + "ILCA-99M2", + ], + 612, + ), + ( + &[ + "ILCE-6100", + "ILCE-6400", + "ILCE-6600", + "ILCE-7C", + "ILCE-7M3", + "ILCE-7RM3", + "ILCE-7RM4", + "ILCE-9", + "ILCE-9M2", + "DSC-RX0M2", + "DSC-RX10M4", + "DSC-RX100M6", + "DSC-RX100M5A", + "DSC-RX100M7", + "DSC-HX99", + ], + 594, + ), +]; + +/// As-shot white balance from the maker note's enciphered `Tag2010` block (bodies that do not write `0x7313` in +/// the raw IFD: most ARWs before 2017). `model` is the Exif model; Sony appends a regional "V" to some names +/// (SLT-A77V), which the documented lists omit. +fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> { + let model = model.trim(); + let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; + let bytes: Vec = block.get(offset..offset + 6)?.iter().map(|&b| DECIPHER[b as usize]).collect(); + let level = |i: usize| order.read_u16(&bytes, 2 * i).map(f32::from); + let (r, g, b) = (level(0)?, level(1)?, level(2)?); + // levels are fixed-point gains (green 255–256 on every sample seen); reject implausible values from an unknown layout + if !(16.0..=16384.0).contains(&g) { + return None; + } + let (r, b) = (r / g, b / g); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) +} /// The inverse tone curve: 11-bit code → 14-bit sensor value. pub(crate) fn code_curve(thresholds: &[u64]) -> Vec { @@ -163,6 +300,73 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result> { Ok(out) } +/// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`). +const SR2_SUBIFD_OFFSET: u16 = 0x7200; +const SR2_SUBIFD_LENGTH: u16 = 0x7201; +const SR2_SUBIFD_KEY: u16 = 0x7221; +/// Black level inside the encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD), recovered by +/// black-box known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher was used. +/// +/// Every Sony ARW we examined (16 files, 15 bodies, 2012–2021) stores the same key (`11 22 33 44`), so its +/// keystream is the same in every file. The DSC-RX100M6 and ILCE-7M3 also write their black levels in plain form +/// (`0x7310`: 800 and 512); XOR-ing their `SR2SubIFD`s against each other matched `pack(512×4) ^ pack(800×4)` at +/// exactly one position, byte 2510, which gave the eight keystream bytes there. Applied to the other files at that +/// position they decode to four equal levels that agree with each sensor's dark-pixel floor: 800 on every 1″-sensor +/// body (RX10, RX100 … RX100M5), 512 on the APS-C/full-frame ones (NEX-5N/6, SLT-A77, ILCE-6000/7/7M2/7RM2), in +/// both `SR2SubIFD` sizes seen (29252 and 56958 bytes). Newer layouts (ILCE-7M4, 35398 bytes) put other data there +/// and are rejected by the plausibility checks; those bodies write `0x7310` anyway. +const SR2_BLACK_AT: usize = 2510; +const SR2_BLACK_KEYSTREAM: [u8; 8] = [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]; +const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + +/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]), in 14-bit units; `None` +/// unless the file uses the known key and the four decoded levels are equal and plausible. +fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Option { + let private = ifd0.bytes(t::DNG_PRIVATE_DATA)?; + let at = order.read_u32(private, 0)? as u64; + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let (sr2, _) = lightcraft_tiff::parse_ifd_at(bytes, at, order, 0, false, &opts).ok()?; + if sr2.bytes(SR2_SUBIFD_KEY)? != SR2_KEY { + return None; + } + let (start, len) = (usize::try_from(sr2.u64(SR2_SUBIFD_OFFSET)?).ok()?, usize::try_from(sr2.u64(SR2_SUBIFD_LENGTH)?).ok()?); + if len < SR2_BLACK_AT + 8 { + return None; + } + sr2_black_levels(bytes.get(start.checked_add(SR2_BLACK_AT)?..start.checked_add(SR2_BLACK_AT + 8)?)?, order) +} + +/// Decipher the eight `SR2SubIFD` bytes at [`SR2_BLACK_AT`] into one black level (the mean of four near-equal, +/// plausible per-channel levels). +fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { + let plain: Vec = cipher.iter().zip(SR2_BLACK_KEYSTREAM).map(|(c, k)| c ^ k).collect(); + let levels: Vec = (0..4).filter_map(|i| order.read_u16(&plain, 2 * i)).collect(); + let (&lo, &hi) = (levels.iter().min()?, levels.iter().max()?); + (levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::() / 4.0) +} + +/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style +/// default crop when the raw IFD has one, else the maker note's `FullImageSize` (the camera JPEG's size) anchored +/// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant +/// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so +/// the anchored crop removes them while keeping the CFA phase. +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { + let full = Rect::new(0, 0, w, h); + if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) + && *cw > 0 + && *ch > 0 + { + return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h); + } + match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { + // only a plausible trim: never more than 64 pixels per side, never an enlargement + Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => { + Rect::new(0, 0, *fw as usize, *fh as usize) + } + _ => full, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -215,26 +419,43 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?, bits), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float ARW".into())) }; + // "12-bit uncompressed" files (e.g. ILCE-7RM2) say BitsPerSample 12 but store 16-bit words on the 14-bit scale + // of the other modes (black 512, peaks near 16383): black and white follow the data, `bits` keeps the tag + let scale_bits = if info.compression == 1 && out_bits < 14 && strip_len >= (w * h * 2) as u64 && samples.iter().any(|&v| v >> (out_bits + 1) != 0) + { + 14 + } else { + out_bits + }; let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, _ => Cfa::bayer_static("RGGB"), }; - let default_black = if out_bits >= 14 { 512.0 } else { 128.0 }; + let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; let black = match raw.f64s(BLACK_LEVEL).as_deref() { Some([a, b, c, d]) => { BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } } - _ => BlackLevel::uniform(default_black), + _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), }; - let white = raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, out_bits)); - let wb = raw.f64s(WB_RGGB).filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0).map(|v| { - let g = (v[1] + v[2]) / 2.0; - [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] - }); + let white = raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)); + let model = ifd0.string(t::MODEL).unwrap_or_default(); + let mn = tiff + .exif() + .and_then(|e| e.get(t::MAKER_NOTE)) + .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); + let wb = raw + .f64s(WB_RGGB) + .filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0) + .map(|v| { + let g = (v[1] + v[2]) / 2.0; + [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] + }) + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), - _ => Rect::new(0, 0, w, h), + _ => default_crop(raw, mn.as_ref(), w, h), }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); @@ -329,6 +550,58 @@ mod tests { let _ = code_curve(&[60000, 1, 0, 0]); } + #[test] + fn decipher_inverts_the_cube_substitution() { + let mut seen = [false; 256]; + for p in 0..=255u8 { + let c = if p < 249 { (p as u32).pow(3) % 249 } else { p as u32 } as u8; + assert_eq!(DECIPHER[c as usize], p); + seen[DECIPHER[p as usize] as usize] = true; + } + assert!(seen.iter().all(|&s| s), "not a bijection"); + assert_eq!((DECIPHER[8], DECIPHER[27], DECIPHER[94]), (2, 3, 7)); // 7³ = 343 ≡ 94 + } + + fn encipher(plain: &[u8]) -> Vec { + plain.iter().map(|&p| if p < 249 { ((p as u32).pow(3) % 249) as u8 } else { p }).collect() + } + + #[test] + fn tag2010_white_balance_by_model() { + use lightcraft_tiff::ByteOrder::Little; + let mut plain = vec![0u8; 700]; + for (i, v) in [669u16, 256, 441].iter().enumerate() { + plain[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let block = encipher(&plain); + let wb = tag2010_wb("DSC-RX100M3", &block, Little).unwrap(); + assert!((wb[0] - 669.0 / 256.0).abs() < 1e-6 && wb[1] == 1.0 && (wb[2] - 441.0 / 256.0).abs() < 1e-6, "{wb:?}"); + // same layout (Tag2010h), regional "V" suffix, other layouts, unknown models, truncated or implausible data + assert!(tag2010_wb("ILCE-7RM2", &block, Little).is_some()); + assert!(tag2010_wb("DSC-RX100M3V", &block, Little).is_some()); + assert!(tag2010_wb("SLT-A77V", &block, Little).is_none()); // Tag2010b: offset 4480 is past the block + assert!(tag2010_wb("DSC-RX100M3X", &block, Little).is_none()); + assert!(tag2010_wb("ILCE-1", &block, Little).is_none()); + assert!(tag2010_wb("DSC-RX100M3", &block[..615], Little).is_none()); + assert!(tag2010_wb("DSC-RX100M3", &encipher(&[0u8; 700]), Little).is_none()); + let mut odd = plain.clone(); + odd[612..614].copy_from_slice(&9000u16.to_le_bytes()); // R/G ≈ 35 + assert!(tag2010_wb("DSC-RX100M3", &encipher(&odd), Little).is_none()); + } + + #[test] + fn sr2_black_level_decodes_and_rejects_garbage() { + use lightcraft_tiff::ByteOrder::Little; + let cipher = + |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).zip(SR2_BLACK_KEYSTREAM).map(|(p, k)| p ^ k).collect() }; + assert_eq!(sr2_black_levels(&cipher([800; 4]), Little), Some(800.0)); + assert_eq!(sr2_black_levels(&cipher([512, 512, 514, 512]), Little), Some(512.5)); + assert_eq!(sr2_black_levels(&cipher([65320, 64928, 65260, 488]), Little), None); // ILCE-7M4's layout + assert_eq!(sr2_black_levels(&cipher([0; 4]), Little), None); + assert_eq!(sr2_black_levels(&cipher([800; 4])[..5], Little), None); + assert_eq!(sr2_black_levels(&[], Little), None); + } + #[test] fn quad_tiles_place_cfa_cells() { // 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells, diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 8d9ab89f..b92d03f8 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -193,3 +193,70 @@ fn corpus_nef_compressed_matches_uncompressed() { assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}"); } } + +/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and +/// look table; we read them (and render with them). Camera-written DNGs here carry none. +#[test] +fn corpus_adobe_dngs_carry_profile_looks() { + let dir = corpus_root().join("raw"); + let Ok(rd) = std::fs::read_dir(&dir) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + let mut seen = 0; + for p in rd.flatten().map(|e| e.path()) { + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + if !name.starts_with("dng-") || !name.ends_with(".dng") { + continue; + } + let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}")); + let look = &info.color.profile; + if name.starts_with("dng-adobe-") { + seen += 1; + let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map")); + assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}"); + assert!(look.look_table.is_some(), "{name}: no look table"); + // a profile applied to a mid grey keeps it (close to) neutral + let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap(); + let g = t.apply([0.18; 3], 1.0); + assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}"); + } + eprintln!( + "{name:44} profile look: hsm {} look {} tone {}", + look.hue_sat_map[0].is_some(), + look.look_table.is_some(), + look.tone_curve.is_some() + ); + } + eprintln!("{seen} Adobe-converted DNGs checked"); +} + +/// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD. +/// White balance comes from the maker note's enciphered `Tag2010`, the black level from the encrypted `SR2SubIFD` +/// and the crop from `FullImageSize`; without them the RX100 III opened bright green. Expected values: the black +/// levels agree with each sensor's dark-pixel floor, the gains with the neutral sky of the camera JPEG. +#[test] +fn corpus_sony_pre2017_colour_metadata() { + let dir = corpus_root().join("raw"); + // (file, black, approximate R and B gains, crop width × height) + let cases = [ + ("arw-sony-rx100m3.arw", 800.0, [2.61, 1.72], (5472, 3648)), + ("arw-sony-rx100.arw", 800.0, [2.23, 2.00], (5472, 3648)), + ("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.46], (7952, 5304)), + ]; + let mut seen = 0; + for (name, black, [r, b], (cw, ch)) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.black.mean(), black, "{name}: black level"); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}"); + assert_eq!((img.crop.width, img.crop.height), (cw, ch), "{name}: crop"); + assert!(img.white_at(0) > 16000.0, "{name}: white {} (14-bit scale)", img.white_at(0)); + seen += 1; + } + eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files"); +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f670401b..5f0aad28 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -182,6 +182,11 @@ fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder } fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec { + dng_with(raw, order, |_| {}) +} + +/// [`dng`] with extra IFD 0 tags (where DNG writers put camera-profile tags). +fn dng_with(raw: IfdBuilder, order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec { let mut ifd0 = IfdBuilder::new(); ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); @@ -200,6 +205,7 @@ fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec { exif.set(t::ISO_SPEED, Value::Short(vec![200])); ifd0.set_child(t::EXIF_IFD, exif); ifd0.add_sub_ifd(raw); + extra(&mut ifd0); TiffWriter::new(order, false).write(&[ifd0]).unwrap() } @@ -461,3 +467,68 @@ fn rejects_unsupported_and_broken() { raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 128]] }); assert!(decode(&dng(raw, ByteOrder::Little)).is_err()); } + +/// A Lightroom-style DNG: profile look tags in IFD 0, LinearRaw data in a SubIFD (issue #138). +fn profile_dng(order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec { + let (w, h) = (8, 6); + let px = pattern(w, h, 3, 16); + let mut raw = base_ifd(w, h, 16, 3, false); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] }); + dng_with(raw, order, extra) +} + +fn hsv_floats(h: usize, s: usize, v: usize, f: impl Fn(usize, usize, usize) -> [f32; 3]) -> Vec { + (0..v).flat_map(|vi| (0..h).flat_map(move |hi| (0..s).map(move |si| (vi, hi, si)))).flat_map(|(vi, hi, si)| f(vi, hi, si)).collect() +} + +#[test] +fn profile_look_tags_are_read_from_ifd0() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let bytes = profile_dng(order, |ifd0| { + ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1])); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(hsv_floats(6, 3, 1, |_, h, s| [h as f32, 1.0 + s as f32 * 0.1, 1.0]))); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_2, Value::Float(hsv_floats(6, 3, 1, |_, _, _| [0.0, 1.0, 1.0]))); + ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![4, 2, 3])); + ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(hsv_floats(4, 2, 3, |v, _, _| [0.0, 1.0, 1.0 - v as f32 * 0.05]))); + ifd0.set(t::PROFILE_LOOK_TABLE_ENCODING, Value::Long(vec![1])); + ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.25, 0.15, 0.5, 0.55, 1.0, 1.0])); + }); + let img = decode(&bytes).unwrap(); + assert_eq!(img.cpp, 3); + let p = &img.color.profile; + let hsm = p.hue_sat_map[0].as_ref().unwrap(); + assert_eq!((hsm.hue_divisions, hsm.sat_divisions, hsm.val_divisions), (6, 3, 1)); + // value-major, hue-middle, saturation-minor: entry (h = 2, s = 1) is at 2·3 + 1 + assert_eq!(hsm.data[7], [2.0, 1.1, 1.0]); + assert!(p.hue_sat_map[1].is_some()); + let look = p.look_table.as_ref().unwrap(); + assert_eq!((look.hue_divisions, look.sat_divisions, look.val_divisions, look.srgb_value), (4, 2, 3, true)); + assert_eq!(look.data[2 * 4 * 2], [0.0, 1.0, 0.9]); + assert_eq!(p.tone_curve.as_ref().unwrap().points.len(), 4); + // the header-only probe sees the same profile + assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap().color.profile, *p); + // our DNG writer keeps the look with the data (conversions, smart previews) + let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap(); + assert_eq!(again.color.profile, *p); + } +} + +#[test] +fn malformed_profile_look_tags_are_ignored() { + let bytes = profile_dng(ByteOrder::Little, |ifd0| { + // data count doesn't match the dimensions + ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1])); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(vec![0.0, 1.0, 1.0])); + // one saturation division is not allowed + ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![2, 1, 1])); + ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0])); + // decreasing inputs and a NaN + ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.6, 0.5, 0.4, f32::NAN, 1.0, 1.0])); + }); + let img = decode(&bytes).unwrap(); + assert!(img.color.profile.is_empty(), "{:?}", img.color.profile); + // and without any of the tags there is nothing to apply + let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap(); + assert!(plain.color.profile.is_empty()); +} diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs index c7b21e34..aa24ec87 100644 --- a/crates/tiff/src/tags.rs +++ b/crates/tiff/src/tags.rs @@ -152,6 +152,15 @@ pub const CAMERA_CALIBRATION_SIGNATURE: u16 = 50931; pub const PROFILE_CALIBRATION_SIGNATURE: u16 = 50932; pub const AS_SHOT_PROFILE_NAME: u16 = 50934; pub const PROFILE_NAME: u16 = 50936; +pub const PROFILE_HUE_SAT_MAP_DIMS: u16 = 50937; +pub const PROFILE_HUE_SAT_MAP_DATA_1: u16 = 50938; +pub const PROFILE_HUE_SAT_MAP_DATA_2: u16 = 50939; +pub const PROFILE_TONE_CURVE: u16 = 50940; +pub const PROFILE_EMBED_POLICY: u16 = 50941; +pub const PROFILE_LOOK_TABLE_DIMS: u16 = 50981; +pub const PROFILE_LOOK_TABLE_DATA: u16 = 50982; +pub const PROFILE_HUE_SAT_MAP_ENCODING: u16 = 51107; +pub const PROFILE_LOOK_TABLE_ENCODING: u16 = 51108; pub const BASELINE_EXPOSURE_OFFSET: u16 = 51109; pub const DEFAULT_BLACK_RENDER: u16 = 51110; pub const FORWARD_MATRIX_1: u16 = 50964; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a8c6e062..a78fe661 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -286,7 +286,10 @@ mod tests { let mut h = demo([900.0, 600.0]); let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); - h.snapshot(SETTLE) + // Compare only fully settled frames: a timed-out settle would compare half-rendered + // pictures and fail as a misleading pixel diff (seen on loaded CI machines). + assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s"); + h.paint() }; let (a, b) = (shot(), shot()); assert_eq!(a.size, b.size); @@ -413,6 +416,15 @@ mod tests { assert_eq!(after.develop, before.develop); assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); assert!(h.app.session.undo.is_empty()); + // hovering down across group headers within the presets list retains the preview (no flicker) + h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + // hovering another preset switches smoothly to it + h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow")); // thumbnails: variant textures for the visible presets h.app.ui.preset_thumbs = true; h.request("ui.move", json!({"x": 5, "y": 500}), t); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 92355c30..0e2389a4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -143,20 +143,82 @@ mod tests { } assert!(text.contains("マイフォト"), "{text}"); assert!(text.contains("すべての写真"), "{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 message in japanese().values() { - for ch in message.chars().filter(|ch| !ch.is_whitespace()) { - assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}"); + if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { + eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); + } else { + 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 message in japanese().values() { + for ch in message.chars().filter(|ch| !ch.is_whitespace()) { + assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}"); + } } } - } - }); + }); + } // Locale affects presentation only: command ids remain the same. let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); let japanese_ids = ids(&app); set_language(Language::En); assert_eq!(japanese_ids, ids(&app)); } + /// The UI families, after one frame so the font definitions are loaded. + fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + fn ui_families() -> [egui::FontFamily; 3] { + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + } + + /// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family. + #[test] + fn craft_fonts_render_japanese_in_the_ui() { + if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); + return; + } + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in ui_families() { + let font = egui::FontId::new(13.0, family); + for ch in "日本語の文字".chars() { + assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE); + assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size()); + } + }); + } + + /// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs; + /// Latin text keeps Inter. + #[test] + fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.language = Language::Ja; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + set_language(Language::En); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f489f88..8addc61b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -201,8 +201,6 @@ pub struct LightcraftApp { impl LightcraftApp { pub fn new(session: Session, services: Services) -> Self { - // GPU device + kernels off the UI thread, before the first photo is opened - lightcraft_engine::gpu::warm_up(); Self { session, ui: UiState::default(), @@ -597,6 +595,11 @@ impl LightcraftApp { if self.gpu_applied != Some(self.ui.settings.gpu) { self.gpu_applied = Some(self.ui.settings.gpu); let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + // GPU device + kernels off the UI thread, once the window is up and only when GPU + // rendering is on: a broken driver must not keep the window from appearing (issue #136) + if self.ui.settings.gpu { + lightcraft_engine::gpu::warm_up(); + } } let mb = self.ui.settings.memory_mb; // automatic at startup: leave the engine's default alone diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index a8fbc638..0697b75c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -280,8 +280,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if show_before { shown = draw(Slot::Before, img_rect); 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); - } else if let Some((key, label)) = &hover_key - && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id && t.key == *key) + } else if let Some((_key, label)) = &hover_key + && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { p.image(tex.tex.id(), img_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); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 6f94330e..fe8b0478 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -708,7 +708,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label("MIT OR Apache-2.0. Fonts: Inter / BIZ UDPGothic (OFL). Icons: original."); + ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits())); ui.add_space(10.0); let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE)) .fill(t.accent) diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 74b1e129..20a095e0 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -78,7 +78,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 }; - egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; let mut groups: BTreeMap> = BTreeMap::new(); for p in &app.session.presets { groups.entry(p.group.clone()).or_default().push(p.clone()); @@ -141,11 +142,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.hovered() && let Some(s) = look { - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Preset: {name}", name = name), settings: s }); + let label = crate::i18n::tr_format!("Preset: {name}", name = name); + app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s }); + if let Some(id) = active { + ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); + } } if resp.clicked() { let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); ui.data_mut(|d| d.insert_temp(amt_id, (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()); @@ -207,6 +213,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.add_space(30.0); }); + let last_hover_id = egui::Id::new("last-preset-hover"); + let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos)); + if pointer_in_scroll { + if app.hover_preview.is_none() + && let (Some(id), Some(curr)) = (active, current.as_ref()) + && let Some((saved_id, saved_preset, saved_label)) = + ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)) + && saved_id == id + { + app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) }); + } + } else { + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)); + } }); } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index ff54f446..7633cdef 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -81,6 +81,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(6.0); } ui.add_space(30.0); + let last_hover_id = egui::Id::new("last-profile-hover"); + let pointer_in_profiles = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| ui.max_rect().contains(pos)); + if pointer_in_profiles { + if app.hover_preview.is_none() + && let Some((saved_id, saved_profile_id)) = ui.data(|m| m.get_temp::<(PhotoId, String)>(last_hover_id)) + && saved_id == id + && let Some(p) = profile(&saved_profile_id) + { + let s = with_profile(&d, p); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); + } + } else { + ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id)); + } } /// `d` with profile `p` (at 100 % unless it is the applied one). @@ -127,6 +141,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: } if resp.hovered() && !applied && !over_star { app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() }); + ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string()))); } let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); if resp.clicked() { @@ -134,6 +149,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: let _ = app.run("profile.favorite", json!({"id": p.id})); } else if !applied { let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100})); + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a62e8313..b5db692d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -107,20 +107,53 @@ impl Tokens { } pub fn install_fonts(ctx: &egui::Context) { + ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS)); +} + +/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as +/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family). +/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes. +pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); - fonts.font_data.insert("Japanese".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Regular.ttf")))); - fonts.font_data.insert("Japanese-Bold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Bold.ttf")))); - let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let mut prop = vec!["Inter".to_string(), "Japanese".to_string()]; - prop.extend(fallback.clone()); + let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect(); + for f in &japanese { + fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); + } + // Craft-fonts faces in preference order for a family drawn in `style`. + let fallback = |style: &str| { + let mut faces = japanese.clone(); + faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho"))); + faces.into_iter().map(craft_font_name).collect::>() + }; + let defaults: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let mut prop = vec!["Inter".to_string()]; + prop.extend(defaults.iter().cloned()); + prop.extend(fallback("Regular")); fonts.families.insert(FontFamily::Proportional, prop); - let mut semi = vec!["Inter-SemiBold".to_string(), "Japanese-Bold".to_string()]; - semi.extend(fallback); + let mut semi = vec!["Inter-SemiBold".to_string()]; + semi.extend(defaults); + semi.extend(fallback("Bold")); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); - fonts.families.entry(FontFamily::Monospace).or_default().push("Japanese".into()); - ctx.set_fonts(fonts); + fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular")); + fonts +} + +/// The embedded font families for the About box: Inter, plus the craft-fonts families when built +/// with them. +pub fn font_credits() -> String { + let mut families = vec!["Inter"]; + for f in lightcraft_engine::CRAFT_FONTS { + if !families.contains(&f.family) { + families.push(f.family); + } + } + families.join(" / ") +} + +fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { + format!("craft-fonts {} {}", f.family, f.style) } pub fn apply(ctx: &egui::Context) { diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index c4754664..93aada18 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -26,7 +26,7 @@ Synthetic tests cover colour with a nonlinear camera tone, preserved scene-linea Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-display RMS values were 0.04244, 0.03349, 0.04552, 0.02871 and 0.03100. All five unedited supplied JPEG exports stayed pixel-identical to the installed pre-change renderer at equal size/quality. Four supplied JPEGs use Display P3 and two have a narrower crop than the RAW, so they are visual references rather than aligned pixel ground truth. -Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. Rejected and unchanged: the RX100M3 (held-out RMS 0.103; its fallback render already has a strong green cast), an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). +Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification. diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index e82ff01c..7ea22d06 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,12 +4,59 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +## Backends, environment variables and troubleshooting (issue #136) +wgpu loads the driver of **every** backend in an instance's set while it enumerates adapters — even +when it then picks another one. A Vulkan driver that crashes there (issue #136: an access violation +in Intel's `igvk64.dll` on a UHD 630 under Windows 11) takes the process down before any window +appears, and a native crash cannot be caught. So LightCraft only lets wgpu touch the backends it +means to use (`lightcraft_gpu::backend`), for its compute device *and* for the desktop window +(eframe/egui-wgpu, `window_wgpu_options` in `apps/lightcraft/src/main.rs`): + +| platform | default (window and compute) | +|---|---| +| Windows | DX12 only — the Vulkan driver is never loaded unless asked for | +| macOS | Metal | +| Linux / BSD | Vulkan (the window also lists GL, eframe's default; no GL backend is compiled in) | + +Overrides (read once at launch): +- `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | gl | auto | off` (comma lists allowed, e.g. + `vulkan,dx12`): the backends for both the window and GPU rendering; `off` turns GPU rendering off + (CPU pipeline) — the window still needs a backend and keeps the platform default. A backend this + build doesn't contain (`gl`) is ignored with a warning. +- 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). + +**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), +`LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off`, no GPU driver is loaded for rendering at all. + +**Crash sentinel.** The desktop app writes `gpu-init.marker` into its settings folder (next to +`ui.json`: `%APPDATA%\LightCraft`, `~/Library/Application Support/LightCraft`, +`~/.config/lightcraft`) just before the compute device is created and removes it as soon as creation +returns, successfully or not. If the marker is still there at the next launch, the process died inside +the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the +marker and says so in a notice. Checking *Use the GPU for rendering* again tries the GPU once more +(and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). Killing the app during +the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely — +harmless: rendering is then on the CPU until the box is checked again. The sentinel only covers the +compute device; a crash while the window's renderer starts is avoided by the backend defaults above +or worked around with `LIGHTCRAFT_GPU_BACKEND`. + +**Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal +with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of +rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver +from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in +use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`). + ## Where it is used - `lightcraft_engine::media::develop` (called by every `RenderJob`): loupe / before / compare views, `render_now` (CLI, MCP, control channel renders) and exports render on the GPU when one is available; grid/filmstrip thumbnails (many small jobs in parallel) stay on the CPU. - Anything the GPU path cannot do returns `None` and the CPU renders instead: no adapter (CI - machines, software-only adapters), `LIGHTCRAFT_GPU=0` (whole process), the `app.gpu {enabled}` + machines, software-only adapters), `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` (whole + process; see [Backends](#backends-environment-variables-and-troubleshooting-issue-136)), the `app.gpu {enabled}` command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), a buffer larger than the device's storage-buffer limit, or a render the device did not complete correctly (see [Limits, failures and the CPU fallback](#limits-failures-and-the-cpu-fallback)). diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 1cd41b46..4d1aa627 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -5,7 +5,10 @@ LightCraft は日本語と英語(English)の表示に対応しています - 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 - メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。 -- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを組み込みました。 +- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく + [storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力 + `CRAFT_FONTS_DIR=../craft-fonts` を指定したビルド(公式リリースはすべて)に組み込まれます。 + 指定しない場合も動作しますが、日本語の文字は表示されません。 - 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。 - 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。 @@ -18,4 +21,4 @@ LightCraft は日本語と英語(English)の表示に対応しています `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は -`cargo test -p lightcraft-ui-egui i18n::tests` で検証します。 +`cargo test -p lightcraft-ui-egui i18n::tests` で検証します(グリフの検査は `CRAFT_FONTS_DIR` 指定時のみ実行)。 diff --git a/docs/parity.md b/docs/parity.md index cfc61c96..fc94d08a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -114,7 +114,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and 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, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import | | 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 like Move's (a new file, synced, compared byte for byte; 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) | +| 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) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | @@ -122,8 +122,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-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` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW, NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, 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), HEIC/AVIF decode | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, 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), HEIC/AVIF decode | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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; 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | 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) | @@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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/engine/src/camera_preview.rs` | DNG files use the colour matrices they carry; Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/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); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -434,7 +434,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file, synced and renamed into place (issue #93) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | @@ -495,7 +495,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | 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` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); 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); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | @@ -797,7 +797,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- ### Japanese text watermarks `app.export` watermark objects accept `vertical` (boolean, defaults to false). The export -dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back to bundled -BIZ UDMincho. Vertical lettering uses upright em cells and right-to-left newline columns; +dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back to the craft-fonts +Mincho faces (BIZ UDMincho; on the web build, BIZ UDPGothic) when built with `CRAFT_FONTS_DIR`. Vertical lettering uses upright em cells and right-to-left newline columns; Latin stays upright. Advanced Japanese composition remains open. Regression coverage: -`export::tests::japanese_watermarks_support_vertical_columns_and_legacy_defaults`. +`export::tests::japanese_watermarks_support_vertical_columns`, `japanese_watermark_options_and_legacy_defaults`, `watermarks_work_without_craft_fonts`. diff --git a/docs/web.md b/docs/web.md index ed3efcfe..6d3283c8 100644 --- a/docs/web.md +++ b/docs/web.md @@ -52,6 +52,14 @@ codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goe is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further. +**Fonts.** The browser has no system fonts to fall back on, so Japanese text comes from +[craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build +input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32 +`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI, and the watermark fallback), so +the module stays well under Cloudflare's 25 MiB per-file limit: measured 2026-10-06, 17.1 MB +without craft-fonts and 21.8 MB with it (brotli 3.8 MB / 6.3 MB). Without it the web build +works, but Japanese text has no glyphs. + ## Deploying: headers `cargo xtask web --serve` sends these on every response. A production server may send them too; diff --git a/docs/whats-new.md b/docs/whats-new.md index 701600ef..3045dead 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,22 +10,35 @@ - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. ### Reliability +- LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD + 630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless + asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering + ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window + is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while + starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on. - Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with the same name and "Overwrite" (or Export with Previous repeating it), an exact output path from the control channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked. - Exported files are written to a temp file, synced and then renamed into place, so a full disk or an unplugged - drive never leaves a truncated file; the XMP sidecar of an "Original" export follows the "If file exists" - choice too. + Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive + never leaves a truncated file; the XMP sidecar of an "Original" export follows the "If file exists" choice too. - Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue #106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or a full drive) no longer counts as built and Build Smart Previews replaces it. - Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and - compared byte for byte with the card. A copy that fails or differs is removed and reported as a failed import — - so "import complete" means the copies are good before you format the card — and a name that is taken gets -1, - -2… instead of being replaced. + checked against the content read from the card. A copy that fails or differs is removed and reported as a failed + import — so "import complete" means the copies are good before you format the card — and a name that is taken + gets -1, -2… instead of being replaced. +- Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports, + renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk + one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large + export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still + synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of + reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed + on the card after Review Import is reported instead of being imported with stale details. Checking an export path + against the library's originals no longer scans the whole library when nothing is at that path. - Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A sidecar that isn't valid XMP is copied to `.xmp.bak-