Merge main into fix/symlink-original-guard
# Conflicts: # crates/engine/src/tests_export.rs
This commit is contained in:
commit
1ce28c3aea
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -7,3 +7,4 @@ plan/
|
|||
/log
|
||||
.DS_Store
|
||||
.claude/
|
||||
/craft-fonts
|
||||
|
|
|
|||
13
AGENTS.md
13
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
|
||||
|
|
|
|||
13
CLAUDE.md
13
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
8
NOTICE
8
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-<family>.txt.
|
||||
|
|
|
|||
17
README.md
17
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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<Value>, 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 {
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
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<std::path::PathBuf> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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-<family>.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`).
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<Option<u64>> = const { Cell::new(None) };
|
||||
static SYNCS: Cell<u64> = 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<u64> {
|
||||
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<u64> {
|
||||
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<Item = PathBuf>, 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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Optional build input: with `CRAFT_FONTS_DIR=<storytold/craft-fonts checkout>`, 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<String, String> {
|
||||
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<String> = 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)
|
||||
}
|
||||
|
|
@ -149,7 +149,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
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(())
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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<ab_glyph::FontRef<'static>> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<lightcraft_pipeline::tone::CameraTone> {
|
||||
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::<f32>()
|
||||
/ 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::<f32>() / 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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`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<Item = &'static CraftFont> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> {
|
||||
/// 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<String, String> {
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -171,9 +171,15 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
|
|||
|
||||
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
|
||||
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
|
||||
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
|
||||
/// is never wiped on the strength of it. Returns the new path.
|
||||
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
/// 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<lightcraft_preview::Hash128>) -> Result<PathBuf, String> {
|
||||
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<PathBuf, St
|
|||
};
|
||||
for i in 0..100_000u32 {
|
||||
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
|
||||
match copy_verified(src, &d) {
|
||||
match copy_verified(src, &d, expect) {
|
||||
Ok(()) => 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<lightcraft_preview::Hash128>) -> 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<lightcraft_preview::Hash128> {
|
||||
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<bool> {
|
||||
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<String>,
|
||||
/// Lens corrections embedded in the file (DNG opcodes).
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -349,3 +349,24 @@ fn export_protects_the_target_of_an_imported_symlink() {
|
|||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"<x/>");
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> = (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::<String>::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<u8> { (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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BackendChoice> {
|
||||
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<Backends> {
|
||||
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<BackendChoice> = 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<Backends> {
|
||||
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<Option<PathBuf>> = 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<PathBuf>) {
|
||||
*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<T>(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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Gpu, String> {
|
||||
pub fn new(backends: wgpu::Backends) -> Result<Gpu, String> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
if env_disabled() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if backend::env_off() {
|
||||
return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into());
|
||||
}
|
||||
return Some("disabled by LIGHTCRAFT_GPU=0".into());
|
||||
}
|
||||
if !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<bool> = 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<Result<ctx::Gpu, String>> = 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
|||
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)));
|
||||
|
|
|
|||
|
|
@ -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<Xy>,
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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<HsvTable> {
|
||||
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<HsvTable> {
|
||||
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<ToneCurve> {
|
||||
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<HsvTable>; 2],
|
||||
/// `ProfileLookTableData`.
|
||||
pub look_table: Option<HsvTable>,
|
||||
/// `ProfileToneCurve`.
|
||||
pub tone_curve: Option<ToneCurve>,
|
||||
}
|
||||
|
||||
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<HsvTable> {
|
||||
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<HsvTable>,
|
||||
look: Option<HsvTable>,
|
||||
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<ProfileTables> {
|
||||
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<f64> = (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8> = 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<u16> {
|
||||
|
|
@ -163,6 +300,73 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
|
|||
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<f32> {
|
||||
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<f32> {
|
||||
let plain: Vec<u8> = cipher.iter().zip(SR2_BLACK_KEYSTREAM).map(|(c, k)| c ^ k).collect();
|
||||
let levels: Vec<u16> = (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::<f32>() / 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<RawImage> {
|
|||
_ => (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<u8> {
|
||||
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<u8> { 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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
|||
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<u8> {
|
||||
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<f32> {
|
||||
(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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<String, Vec<Preset>> = 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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::<Vec<_>>()
|
||||
};
|
||||
let defaults: Vec<String> = 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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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` 指定時のみ実行)。
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a
|
||||
|
|
@ -120,3 +133,8 @@
|
|||
now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the
|
||||
camera no longer gets baked in. Photos already imported as "preview only" switch over on Reload. (Files that
|
||||
Nikon splits into two differently compressed halves still use the preview for now.)
|
||||
- Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their
|
||||
siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the
|
||||
file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right
|
||||
edge are cropped away, and "12-bit uncompressed" files are no longer clipped. The RX100 series renders much closer
|
||||
to the camera's own JPEG.
|
||||
|
|
|
|||
|
|
@ -44,12 +44,26 @@ warn() {
|
|||
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
|
||||
}
|
||||
|
||||
# Copy licence and readme files that exist into a package directory.
|
||||
# Copy licence and readme files that exist into a package directory (plus the craft-fonts licences
|
||||
# when building with CRAFT_FONTS_DIR).
|
||||
copy_docs() {
|
||||
local dest="$1" f
|
||||
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
|
||||
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do
|
||||
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
|
||||
done
|
||||
copy_font_licences "$dest"
|
||||
}
|
||||
|
||||
# Builds made with CRAFT_FONTS_DIR (storytold/craft-fonts, an optional build input) embed its fonts;
|
||||
# their OFL licences go with the package as OFL-<family-dir>.txt. Nothing to do without it.
|
||||
copy_font_licences() {
|
||||
local dest="$1" lic family
|
||||
[ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
|
||||
for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
|
||||
[ -f "$lic" ] || continue
|
||||
family="$(basename "$(dirname "$lic")")"
|
||||
cp "$lic" "$dest/OFL-$family.txt"
|
||||
done
|
||||
}
|
||||
|
||||
# Portable SHA-256 of a file (prints just the hash).
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
|
|||
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist"
|
||||
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
||||
# Licences (and the craft-fonts font licences when built with CRAFT_FONTS_DIR) inside the bundle.
|
||||
mkdir -p "$APP/Contents/Resources/Licenses"
|
||||
copy_docs "$APP/Contents/Resources/Licenses"
|
||||
|
||||
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
|
||||
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
|
||||
|
|
|
|||
|
|
@ -17,15 +17,15 @@
|
|||
Scope="perMachine"
|
||||
Compressed="yes"
|
||||
Language="1033">
|
||||
<SummaryInformation Description="LightCraft image editor" Manufacturer="Learning Machines LLC" />
|
||||
<SummaryInformation Description="LightCraft photo library and raw developer" Manufacturer="Learning Machines LLC" />
|
||||
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
|
||||
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes"
|
||||
DowngradeErrorMessage="A newer version of LightCraft is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<Icon Id="LightcraftIcon" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="LightcraftIcon" />
|
||||
<Icon Id="LightcraftIcon.ico" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="LightcraftIcon.ico" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/lightcraft" />
|
||||
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/lightcraft" />
|
||||
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
<Component Id="LightcraftApp" Guid="8A1D3A39-F7C1-47B1-97AE-40C80BF82F4A">
|
||||
<File Id="LightcraftExe" Source="$(var.BinDir)\lightcraft.exe" KeyPath="yes">
|
||||
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="LightCraft"
|
||||
Description="LightCraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="LightcraftIcon" IconIndex="0" />
|
||||
Description="LightCraft photo library and raw developer" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="LightcraftIcon.ico" IconIndex="0" />
|
||||
</File>
|
||||
|
||||
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe" Value="[#LightcraftExe]" Type="string" />
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@
|
|||
.EXAMPLE
|
||||
pwsh packaging/windows/package.ps1 -Arch x64
|
||||
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
|
||||
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('x64', 'x86')] [string] $Arch = 'x64',
|
||||
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
|
||||
[switch] $SkipBuild
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
|
@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg
|
|||
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
|
||||
$MsiVersion = ($Version -split '-')[0]
|
||||
|
||||
$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' }
|
||||
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
|
||||
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
|
||||
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
|
||||
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
|
||||
|
|
@ -65,6 +66,23 @@ if (-not $SkipBuild) {
|
|||
}
|
||||
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
|
||||
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
|
||||
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
|
||||
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
|
||||
# reaches the terminal.
|
||||
function Get-PeHeader([string] $Path) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
|
||||
}
|
||||
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
|
||||
foreach ($check in @(@('lightcraft.exe', 2), @('lightcraft-cli.exe', 3))) {
|
||||
$h = Get-PeHeader (Join-Path $Bin $check[0])
|
||||
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
|
||||
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
|
||||
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
|
||||
}
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
|
|
@ -88,13 +106,26 @@ $Portable = Join-Path $TargetDir "windows-package\lightcraft-$Version-windows-$A
|
|||
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
|
||||
Copy-Item (Join-Path $Stage '*.exe') $Portable
|
||||
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
|
||||
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $Portable }
|
||||
}
|
||||
# Builds made with CRAFT_FONTS_DIR (storytold/craft-fonts) embed its fonts: ship their OFL licences.
|
||||
if ($env:CRAFT_FONTS_DIR) {
|
||||
Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Copy-Item $_.FullName (Join-Path $Portable "OFL-$($_.Directory.Name).txt")
|
||||
}
|
||||
}
|
||||
$Zip = Join-Path $Dist "lightcraft-$Version-windows-$Arch-portable.zip"
|
||||
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Portable -DestinationPath $Zip
|
||||
|
||||
Invoke-Native 'lightcraft-cli --version' { & (Join-Path $Stage 'lightcraft-cli.exe') --version }
|
||||
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
|
||||
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
|
||||
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
|
||||
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
|
||||
Invoke-Native 'lightcraft-cli --version' { & (Join-Path $Stage 'lightcraft-cli.exe') --version }
|
||||
} else {
|
||||
Write-Output "skipping lightcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
|
||||
}
|
||||
Get-Item $Msi, $Zip | Format-Table Name, Length
|
||||
|
|
|
|||
|
|
@ -234,6 +234,10 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
|
|||
("arw-sony-a7m4-lossless-l.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW"),
|
||||
("arw-sony-a7m4-lossless-m.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW"),
|
||||
("arw-sony-a7m4-lossless-s.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW"),
|
||||
// pre-2017 bodies: white balance only in the enciphered maker note, black level only in the SR2SubIFD (#148)
|
||||
("arw-sony-rx100m3.arw", "https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW"),
|
||||
("arw-sony-rx100.arw", "https://raw.pixls.us/data/Sony/DSC-RX100/DSC00838.ARW"),
|
||||
("arw-sony-a7rm2-12bit-uncompressed.arw", "https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW"),
|
||||
// CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples
|
||||
("cr2-canon-40d.cr2", "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2"),
|
||||
("cr2-canon-550d.cr2", "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue